Integrating

TypeScript SDK

Everything the other pages of this documentation ask you to get right — the host that belongs to your key, an Idempotency-Key that survives a retry, the quota headers, the constant-time signature check on your webhook endpoint — the SDK already does. It is thin on purpose: typed calls over fetch, no runtime dependencies, and the same /v1 underneath.

Installing

Not on the registry yet

@vacabeedev/partner-api does not exist on GitHub Packages today. No version of it has ever been uploaded, so the command below answers 404 for everybody, with a correct token and a correct .npmrc — there is nothing there to fetch. We would rather say that here than have you spend an afternoon on your credentials. The package is built, tested and gated on every run; what is missing is the release that pushes it. The changelog carries the entry on the day it lands, and partner admins are e-mailed. Everything from here on is what the install will look like, and it is worth reading now — none of it changes when the package appears.

The package is @vacabeedev/partner-api and its channel is GitHub Packages. That registry never serves npm packages anonymously — not even public ones — so npm install on its own does not work. You need a GitHub account, a personal access token with the read:packages scope, and an .npmrc that points this one scope at the right registry.

.npmrc — only the @vacabeedev scope is redirected
# .npmrc — next to your package.json
@vacabeedev:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}

Keep the token out of the file itself: npm expands ${GITHUB_TOKEN} from the environment, so the same .npmrc can be committed and your build server can inject its own token. Everything else in your project keeps resolving through npmjs.com as before.

Shell
npm install @vacabeedev/partner-api

Why you are being asked for a token

Publishing to npmjs.com, where no token and no GitHub account would be involved, is planned for general availability. It is not the channel today, and we would rather say so here than let you discover it from a 404 during your first install. When it moves, the package name stays the same and the changelog carries the entry.

If the install answers 404

GitHub Packages returns the same 404 for a package you are not allowed to see and for one that is not there, so the two are indistinguishable from the outside. Check the second cause first: each version is published from the release that contains it, and a version that has not been through a release does not exist in the registry, so no amount of fixing your token will change the answer. Today that is the only cause — see the note at the top of this section. Once the package is on the registry, the changelog entry that announces a version is what tells you it is installable; after that, a 404 is about your token.

The first call

One import, one option. The key is the only thing the client needs, and it is also the only thing that decides which environment you are talking to.

TypeScript
import { VacabeeClient } from '@vacabeedev/partner-api';

const vacabee = new VacabeeClient({ apiKey: process.env.VACABEE_API_KEY! });

const account = await vacabee.account();
console.log(account.partner.slug, account.scopes);

The host is not something you configure. The SDK reads the prefix — vcb_test_ goes to the sandbox host, vcb_live_ to the live one — and picks the base URL itself. That removes the most common mix-up of the whole integration: you cannot point a test key at production by leaving a stale URL in your config, because there is no URL in your config. Passing baseUrl explicitly is still possible for a local stub, and it never switches environments silently.

GET /v1/account is deliberately the example. It is the call that proves a key: it returns your partner identity, your scopes, your settlement modes and your limits in one answer.

The typed verticals

Each bookable vertical hangs off the client as its own namespace, with the request and response types generated from the same OpenAPI document the reference is built from. A field that changes shape breaks your build rather than your booking.

TypeScript
// Hotels: search is a session — open it, read it, refine it, book a rate.
const search = await vacabee.hotels.search({ /* … */ });
const booking = await vacabee.hotels.book({
  rateId,
  externalReference: `order-${orderNumber}`, // your own order number
  guests,
  contact: { email: 'ada@example.com' },
});

// Flights and transfers: synchronous search, then book.
const offers = await vacabee.flights.search({ /* … */ });
const transfer = await vacabee.transfers.search({ /* … */ });

// eSIM: catalogue at your purchase prices, orders, usage.
const plans = await vacabee.esim.plans({ /* … */ });
NamespaceWhat it covers
vacabee.hotelsDestinations, search as a session (open, read, refine, load more), book, read a booking, cancel.
vacabee.flightsSynchronous search, re-price a single offer, book, read an order.
vacabee.transfersSynchronous search, book, read an order, cancellation quote, cancel.
vacabee.esimCountries and regions, plans at your purchase prices, orders and usage.
vacabee.webhooksYour delivery targets: create, change, rotate the secret, send a test.
vacabee.deliveriesThe delivery log: what went out, what came back, what to retry.

The booking methods derive the Idempotency-Key from your externalReference, so the same order number cannot become two bookings — not across retries, not across processes, not across days. Pass { idempotencyKey } yourself only if you deduplicate on something else. The rules behind that are on the idempotency page; the SDK is what saves you from implementing them.

Seeing the quota coming

Every response carries the X-Search-Quota-* headers. Reading them by hand means threading them through every call site, so the client offers two ways instead: a callback that fires on every response, and a per-call variant that hands you the parsed values alongside the data.

TypeScript
const vacabee = new VacabeeClient({
  apiKey: process.env.VACABEE_API_KEY!,
  onQuota: (q) => {
    // The free daily SEARCH allowance. Not `remaining` — that one is the
    // rate limit, a different number that runs out for a different reason.
    if (q.freeSearchesRemaining !== undefined && q.freeSearchesRemaining < 50) {
      logger.warn('Free searches are running low today', q);
    }
  },
});

// Or per call:
const { data, quota, requestId } = await vacabee.accountWithResponse();

Two different numbers, one object

The Quota object carries both allowances and they are not interchangeable. freeSearchesRemaining comes from X-Search-Quota-Remaining and is the free daily search allowance — the one that costs money once it is gone. limit, remaining and resetAt come from the X-RateLimit-* headers and are how fast you may call, which refills within the hour and costs nothing. Alerting on remaining when you meant the searches is the easy mistake here, and it is quiet: the alert fires, just for the wrong reason and at the wrong time.

accountWithResponse() returns { data, quota, requestId }. Every namespace method has the plain form; the onQuota callback is the one to reach for when you want a single place that watches the allowance. What counts as a billable search — and what does not — is on the search quota page.

Receiving webhooks

Your endpoint is public, so the signature is the only thing that tells a genuine delivery apart from an invented one. verifyWebhook is the primitive: it compares in constant time, rejects deliveries older than five minutes, and returns the parsed event.

TypeScript — the primitive
import { verifyWebhook } from '@vacabeedev/partner-api';

app.post('/hooks/vacabee', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = verifyWebhook({
      payload: req.body.toString('utf8'),   // the RAW body
      signature: req.header('X-Vacabee-Signature')!,
      secret: process.env.VACABEE_WEBHOOK_SECRET!,
    });
    handle(event);
    res.sendStatus(200);
  } catch {
    res.sendStatus(400);
  }
});

The raw body, always

What is signed is what came over the wire. JSON.parse followed by JSON.stringify almost always produces a different string and therefore a different signature. In Express that means express.raw(…), never express.json(…).

One level up sits webhookHandler, and it is the layer most integrations actually want: it reads the header, verifies, parses, routes by event type and deduplicates. That last part matters — a delivery we do not see a 2xx for is retried, so the same event can legitimately arrive twice.

TypeScript — the handler
import { webhookHandler, memoryDeduplicator } from '@vacabeedev/partner-api';

const hook = webhookHandler({
  secret: process.env.VACABEE_WEBHOOK_SECRET!,
  seen: memoryDeduplicator(),        // survives duplicates, not restarts
  on: {
    'booking.confirmed': async (event) => queue.add(event),
  },
});

app.post('/hooks/vacabee', express.raw({ type: 'application/json' }), hook.express);
  • hook.express, hook.fetch (anything with Request/Response — Cloudflare Workers, Bun, Deno) and hook.node for Node without a framework. Same logic, three transports.
  • memoryDeduplicator() survives duplicates, not restarts. Across several instances, hand in a seen backed by Redis or your database.
  • respondFirst: true answers 200 and then runs your handler. We wait ten seconds; a handler that books, emails and only then returns exceeds that, so we count the delivery as failed and retry work that was long since done. The price is honest: a crash after answering is lost, because we saw a 2xx.
  • onError fires on a bad signature, on a handler that threw, and on an event type you have no handler for. Unknown types are acknowledged with 200 and never thrown — otherwise we could break your receiver simply by adding an event.
  • If none of the three adapters fit — a queue consumer, a test, another framework — receiveWebhook(options, rawBody, headers) is the same logic without the transport. It returns { status, body, reason }, and reason is worth logging: body-already-parsed is the most common webhook bug there is, and it looks exactly like a wrong secret.

Webhook verification needs node:crypto

The client itself — requests, retries, idempotency, errors, quota — is nothing but fetch and runs anywhere. The verification layer uses node:crypto and Buffer: fine on Node and Bun, fine on Deno through its node: compatibility layer, and on Cloudflare Workers it needs compatibility_flags: ["nodejs_compat"]. In a browser it does not run, and a signing secret does not belong there anyway. Moving that layer to WebCrypto is planned.

Versioning

The SDK version follows the contract, not the calendar. Every changelog entry that moves the SDK names the version it ships under, and the classification decides the number: additive is a minor release, a changed answer or a changed shape is a major one, and an SDK-only improvement is a patch. CI refuses a publish whose version disagrees with the changelog or already exists in the registry.

Below 1.0, pin an exact version

The package is still on 0.x, and that is not a formality: until 1.0 a minor release may still change the shape of something. Pin the exact version and read the changelog before you move it. Partner admins are emailed for every entry that changes the API surface, so there is nothing to poll.

The version the client is running travels with every request as its user agent, which is how support can tell from a log line which build produced a call. That string is generated from the package version — it is never typed in twice, so it cannot lie.

  • Per-operation snippets are on each page of the API reference, next to the curl equivalent.
  • A route the typed layer does not cover yet is still reachable: vacabee.request(method, path, body, options) is the untyped escape hatch, and there you set the idempotency key yourself.
  • The generated half of the SDK is regenerated from the OpenAPI document on every CI run and the result compared byte for byte, and the document itself is regenerated from the gateway's own routes and compared the same way. Both run before a merge, so an SDK cannot go stale against the contract without the build going red.
NextErrors and versioningThe error envelope, retry rules and how /v1 evolves.