Integrating
Webhooks
Most of what happens to a booking happens after your request returns — a supplier confirms, a payment settles, a traveller's data allowance runs low. Webhooks are how you hear about it without polling.
Registering an endpoint
Add an HTTPS endpoint in the portal, or with POST /v1/webhooks/endpoints, and pick the events it should receive. Each endpoint gets its own signing secret, which you can rotate at any time. POST /v1/webhooks/endpoints/{id}/test sends a sample delivery so you can prove the whole path — TLS, signature check, handler — before anything real depends on it.
Events
Two you should not skip
ledger.low_balance and search_quota.threshold are the only warning you get before a 402 or an unexpected invoice line. Subscribe to both even if you skip the rest.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.
import crypto from "node:crypto";
// The raw body is required, so mount the parser accordingly:
app.post("/vacabee/webhooks", express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-Vacabee-Signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2) as [string, string]),
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", process.env.VACABEE_WEBHOOK_SECRET!)
.update(`${parts.t}.${req.body}`) // req.body is a Buffer here
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(400);
}
void enqueue(JSON.parse(req.body.toString("utf8"))); // process async
res.sendStatus(200);
});During a secret rotation both the old and the new secret are valid for the overlap window, so verify against either and accept if one matches.
Delivery and retries
- Answer with any
2xxas soon as you have durably stored the event. Do the work afterwards — a handler that books, emails and writes to three systems before responding will eventually time out and earn itself a retry it did not need. - A non-2xx or a timeout is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. After the last attempt the endpoint is disabled and your partner admins are emailed.
- 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.
- Attempts are kept visible for 30 days in the portal, with the response we got, and every one can be resent by hand.
- Webhook deliveries are never billable and never count against your search quota.