Integrating
Webhooks
Some of what happens to a booking happens after your request returns — a supplier confirms, a cancellation is refunded, an eSIM profile becomes installable. Webhooks are how you hear about those without polling. Four event types are delivered today; the rest of the catalog is declared and silent, and this page says which is which.
Registering an endpoint
Add an HTTPS endpoint under Webhooks in the portal and pick the events it should receive. Each endpoint gets its own signing secret, shown once.
The same thing works over the API: POST /v1/webhooks/endpoints, GET /v1/webhooks/endpoints, PATCH and DELETE on one endpoint, POST /{id}/rotate-secret and POST /{id}/test — the same store behind both doors, so the portal and your code never disagree about where your events go.
Send a test delivery before anything real depends on it. Today that is an API call, POST /v1/webhooks/endpoints/{id}/test; there is no button for it in the portal yet. It goes out over the same path as a live event — same TLS, same signature, same delivery log — so it proves your endpoint is reachable and your signature check is right.
What the test does not prove
webhook.test, which is deliberately not in the event catalog below. A receiver written against the catalog — including the SDK handler — will verify it, acknowledge it with 2xx and then route it to its unknown-event path rather than to one of your handlers. That is correct behaviour, but it means a green test tells you nothing about the code that processes booking.confirmed. Exercise that with a real sandbox booking.Endpoint management needs its own scope
webhooks:manage, which is not part of a key's default scopes. Whoever can change a delivery target can quietly redirect your entire event stream to someone else's server, and all you would notice is that “nothing arrives any more”. A key that only books should not be able to do that — so issue a separate key for management, and the damage from a lost booking key stays limited to bookings.Events delivered today
These four are produced by real code paths and will arrive at a subscribed endpoint. Every payload carries the event id, type, createdAt and a data object.
Declared but not emitted
The event catalog — in the portal form, in GET /v1/account's neighbours and in the OpenAPI enum — also contains the following, and nothing in the platform publishes them today. Subscribing to one is not coverage; it is silence that looks like coverage. Build against a poll or a reconciliation job instead, and ask your Vacabee contact before you design around any of them.
Two of them are not subscribable at all — they are a 400
checkout.created and checkout.expired stand in the OpenAPI enum, in the SDK's WebhookEventType and in the portal's event picker, but the service that stores subscriptions has never heard of them. Sending either to POST /v1/webhooks/endpoints — or ticking the box in the portal — fails with 400 and the message “unknown event … subscribing to an event nobody publishes looks like coverage and is silence”. The request is rejected whole, so one of these in a list of ten costs you the other nine as well.Leave both out of every subscription. They belong to traveller-paid settlement, which is not available yet, so even once they save there would be nothing to deliver. The other ten rows below do save — they are merely silent.
When a checkout does exist, running out is the normal ending
Write this down now, because it is the part that surprises people later. A hosted checkout holds a price, not a reservation. Nobody holds a room, a seat, a car or a plan while your traveller decides. The link carries a deadline that is deliberately shorter than the price it shows, and when it passes, checkout.expired is not an incident report — nothing was booked, nothing was charged, and there is nothing on either side to unwind. If the traveller still wants the trip, search again and book again at the price you get then.
The same fact also arrives as booking.failed with reason: "checkout_expired", so an integration that already handles failures keeps working without subscribing to anything new. Subscribe to both and you will receive both, with different event ids, describing the one event. None of this is reachable today: traveller-paid settlement is not available yet.
The two alerts you might expect are not there
ledger.low_balance and search_quota.threshold are the two events most partners would rely on, and neither fires. Until they do, poll the ledger for available credit and watch the quota headers on your own traffic. Treating a subscription as your low-balance alarm is how you find out about an empty account from a 402 in front of a traveller.Verifying the signature
Every delivery carries an X-Vacabee-Signature header with a timestamp and an HMAC-SHA256 signature:
X-Vacabee-Signature: t=1756483200,v1=5f2b8c1d…To verify, in this order:
- Take the raw request body — the bytes as received. Parsing and re-serialising the JSON changes them and the signature will not match.
- Build the signed payload as
`${t}.${rawBody}`. - Compute HMAC-SHA256 over it with the endpoint's signing secret and compare with
v1using a constant-time comparison. - Reject the delivery if
tis more than five minutes away from your clock. Without that check a captured delivery can be replayed at any time in the future.
The SDK does all of this
@vacabee/partner-api ships the verification and a ready-made handler. It is not a convenience — a signature check that every partner writes by hand gets written wrong by some of them, and a wrong signature check is worse than none, because it looks like security.import { webhookHandler, memoryDeduplicator } from "@vacabee/partner-api";
const hooks = webhookHandler({
secret: process.env.VACABEE_WEBHOOK_SECRET!,
// Delivery is at least once. Swap this for Redis or a unique column
// on event.id in production — this one is per-process and forgets on restart.
seen: memoryDeduplicator(),
// Acknowledge first, then work: we wait ten seconds for your response.
respondFirst: true,
on: {
"booking.confirmed": async (event) => {
await db.bookings.upsert(event.data.bookingReference, event.data);
},
"payment.refunded": async (event) => {
await ledger.credit(event.data);
},
},
onError: (err, ctx) => log.warn({ err, delivery: ctx.deliveryId }),
});
// Next.js / Bun / Deno / Cloudflare Workers
export const POST = hooks.fetch;
// Express — express.raw is required, see below
app.post("/vacabee/webhooks", express.raw({ type: "application/json" }), hooks.express);The single most common mistake
express.json() parses the body before your handler sees it, and JSON.stringify of the result is almost never byte-identical to what we sent — different key order, number formatting, unicode escaping. The signature then never matches, with a perfectly correct secret, and the error says “invalid signature”, which sends you looking at the secret for a day. Mount express.raw on the webhook route, before any global JSON parser. The SDK detects this case specifically and says so instead of blaming the signature.If you would rather not take the dependency, the check is: split the header into t and v1, reject if t is more than 300 seconds from your clock, compute HMAC-SHA256(secret, `${t}.${rawBody}`) as hex, and compare against every v1 in the header with a constant-time comparison.
There can be more than one v1 in the header. A normal rotation keeps the previous secret alive for 24 hours and signs every delivery in that window with both secrets, so you can deploy the new one without losing an event. Accept the delivery if any v1 matches — the SDK already does.
Two exceptions to the overlap
immediate: true kills the old secret on the spot and produces a single signature — the right choice for a leak, and a guaranteed gap in deliveries until the new secret is deployed. And a second rotation while a previous secret is still inside its 24-hour window is refused with 400, naming the time from which it is safe; immediate is the way past it if you cannot wait.Delivery and retries
- Answer with any
2xxas soon as you have durably stored the event. We wait ten seconds for your response — a handler that books, emails and writes to three systems before answering will run past that and earn a retry it did not need. The SDK'srespondFirstoption acknowledges first and runs your handler afterwards. - A non-2xx or a timeout is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours — seven attempts over about 32 hours, which covers an outage that starts on a Friday evening. After that the delivery is marked exhausted and stops on its own. It is not lost: it stays in the history and you can resend it by hand once your endpoint is healthy.
410 Goneis the one status we do not retry. It is read as “this route is deliberately gone” and the delivery is marked failed after the first attempt. If your gateway or CDN answers410for a removed route during a deploy, you lose those events instead of getting the 32-hour buffer — answer503during deploys, not410.- A single bad event never disables your endpoint. Ten consecutive deliveries that end in failed or exhausted do. Note that a
410counts towards that after one attempt, so ten quick410s can switch an endpoint off in minutes rather than over days. A disabled endpoint stops receiving until you re-enable it, and nothing notifies you — checkstatuson the endpoint if events go quiet. - Delivery is at least once. The same event can arrive twice — deduplicate on the event id and make your handler safe to run again.
- Ordering is not guaranteed. Two events about the same booking can arrive out of order; use the payload's state and timestamp, not arrival order.
- Every attempt is kept for 30 days.
GET /v1/webhooks/deliverieslists them — status, event type, timestamps — andGET /v1/webhooks/deliveries/{id}is the one that carries the payload and the per-attempt detail: the request headers we sent, the status code and the response body you returned. Both are in the portal too. - A retry sends the same stored payload again, serialised from the record we kept, with a signature computed fresh for the new timestamp. Byte-compare it against your own copy of an earlier attempt only if you are prepared for JSON key ordering to differ from the original producer.
GET /v1/webhooks/deliveries?status=EXHAUSTEDis the list that needs you: those are the events where the automatic attempts ran out and nothing more happens on its own. The SDK will page through them for you —for await (const d of client.deliveries.all({ status: "EXHAUSTED" })), thenclient.deliveries.retry(d.id).- Webhook deliveries are never billable and never count against your search quota.

