Webhooks
Webhooks tell your server when something changes on a booking or a payment.
You give us an HTTPS URL. We send a signed POST to it for each event you
subscribe to.
Use webhooks to keep your own system in step. You do not need to poll
GET /v1/bookings/{id}.
Events
Section titled “Events”| Event | When we send it |
|---|---|
reservation.created | A booking is created as a hold (state held). This covers widget and API holds and bookings an operator makes in the dashboard. |
reservation.confirmed | A booking becomes confirmed. This covers a paid card payment, a guest confirm, and an operator confirm as paid offline. |
reservation.cancelled | A booking is cancelled with PATCH or DELETE /v1/bookings/{id}. |
payment.succeeded | Stripe tells us a card payment for a hold succeeded. |
payment.failed | Stripe tells us a card payment for a hold failed, or was canceled. Each failed try sends one event. |
refund.created | A refund is made, by you or by us. We refund a payment that arrives after its hold expired. |
A hold that is never paid expires after its hold time. We do not send a
webhook when a hold expires. Read GET /v1/bookings/{id} to see its state.
Register an endpoint
Section titled “Register an endpoint”You need an sk_ key with the webhooks:manage scope. See
Authentication.
curl -X POST https://api.<domain>/v1/webhook-endpoints \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/booking", "eventTypes": ["reservation.confirmed", "reservation.cancelled"] }'url must start with https:// and point to a public host. A plain
http:// URL, localhost, or a private IP address (such as 10.0.0.5 or
192.168.1.10) returns 400 validation_failed. eventTypes needs at least
one value.
The response is 201:
{ "id": "0192f0c4-7d2e-7a10-9a51-3f6f2b1c9e01", "url": "https://example.com/hooks/booking", "eventTypes": ["reservation.confirmed", "reservation.cancelled"], "enabled": true, "createdAt": "2026-10-25T03:00:00.000Z", "lastSuccessAt": null, "lastFailureAt": null, "failureCount": 0, "secret": "whsec_..."}GET /v1/webhook-endpointslists your endpoints as{ "webhookEndpoints": [...] }. It never includessecret.lastSuccessAt,lastFailureAt, andfailureCountshow how deliveries are going.failureCountgoes back to0after a success.DELETE /v1/webhook-endpoints/{id}returns204. An unknown id returns404 webhook_endpoint_not_found.- There is no call to edit an endpoint or to switch it off. To change the events or the URL, delete it and create a new one.
What we send
Section titled “What we send”The body is JSON. The event type is not in the body. Read it from the
X-Booking-Event-Type header.
| Header | Value |
|---|---|
X-Booking-Signature | t=<unix seconds>,v1=<hex>. See Verify the signature. |
X-Booking-Event-Type | The event name, for example reservation.confirmed. |
X-Booking-Event-Id | The id of this delivery. A retry of the same delivery keeps the same id. |
Content-Type | application/json |
reservation.confirmed and reservation.cancelled:
{ "reservationId": "<RESERVATION_ID>", "tenantId": "<TENANT_ID>", "resourceId": "<RESOURCE_ID>", "state": "confirmed", "startsAt": "2026-11-03T02:00:00.000Z", "endsAt": "2026-11-03T03:00:00.000Z", "totalMinorUnits": "150000", "currency": "IDR"}reservation.created has the same fields, with "state": "held", plus the
time the hold ends:
{ "reservationId": "<RESERVATION_ID>", "tenantId": "<TENANT_ID>", "resourceId": "<RESOURCE_ID>", "state": "held", "startsAt": "2026-11-03T02:00:00.000Z", "endsAt": "2026-11-03T03:00:00.000Z", "totalMinorUnits": "150000", "currency": "IDR", "holdExpiresAt": "2026-10-26T03:10:00.000Z"}payment.succeeded:
{ "tenantId": "<TENANT_ID>", "paymentIntentId": "<PAYMENT_INTENT_ID>", "stripePaymentIntentId": "pi_...", "reservationId": "<RESERVATION_ID>"}payment.failed:
{ "tenantId": "<TENANT_ID>", "reservationId": "<RESERVATION_ID>", "paymentIntentId": null, "stripePaymentIntentId": "pi_...", "stripeEventId": "evt_...", "reason": "payment_failed"}reasonispayment_failedwhen a card try failed, orcanceledwhen the payment was canceled.paymentIntentIdisnullwhen the guest paid on the hosted checkout page, because we store that payment only once it succeeds.- A failed payment does not change the booking. The guest can try again until the hold expires.
refund.created:
{ "tenantId": "<TENANT_ID>", "refundRequestId": "<REFUND_REQUEST_ID>", "reservationId": "<RESERVATION_ID>", "amountMinorUnits": "150000", "currency": "IDR"}A refund that we make because a payment arrived after its hold expired also
has "reason": "payment_after_hold_expired".
Money is a string of digits in minor units, like everywhere else in the API.
Parse it with BigInt. A webhook never carries guest details. To read the
guest’s name, email, and phone, call GET /v1/bookings/{id} with an sk_
key.
Verify the signature
Section titled “Verify the signature”Check the signature before you trust a payload. We sign with HMAC-SHA256.
- Read the raw request body as text. Do not parse it and write it again.
- Read
tandv1from theX-Booking-Signatureheader. - Build the string
<t>.<raw body>. - Compute HMAC-SHA256 of that string. The key is your whole secret,
including the
whsec_prefix. Write the result as lowercase hex. - Compare it to
v1with a constant-time compare. - Reject the request if
tis more than a few minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyBookingSignature(secret, rawBody, header) { const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header ?? ""); if (!match) return false; const [, timestamp, signature] = match;
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp)); if (ageSeconds > 600) return false;
const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex");
const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b);}Use it in a handler that keeps the raw body. This example uses Express:
import express from "express";
const app = express();
app.post("/hooks/booking", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); const ok = verifyBookingSignature( process.env.BOOKING_WEBHOOK_SECRET, rawBody, req.header("x-booking-signature"), ); if (!ok) return res.status(400).send("bad signature");
const eventType = req.header("x-booking-event-type"); const eventId = req.header("x-booking-event-id"); // Skip it if you already handled eventId, then handle the event. console.log(eventType, eventId, JSON.parse(rawBody)); res.sendStatus(200);});
app.listen(3000);How we deliver
Section titled “How we deliver”- Answer with any
2xxstatus to say you got it. Answer fast. We wait up to 10 seconds for a reply. - A
2xxends the delivery. A4xxother than429also ends it, as a failure. We take it as “this request is wrong” and we do not retry. - We do not follow redirects. A
3xxcounts as a failed try. Give us the final URL. - A
3xx, a429, a5xx, a timeout, or a network error is retried. The waits between tries are 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours. After the first try and 5 retries (6 tries in all), we give up. - We check for due retries once a minute, so a retry can come up to about a minute after its wait ends.
- We keep your last response status and the start of its body for our own debugging. There is no call to replay a delivery that failed for good.
- Events can arrive twice, and not always in order. Use
X-Booking-Event-Idto skip an event you already handled. To get the current state, readGET /v1/bookings/{id}.
Next steps
Section titled “Next steps”- Quickstart — make a booking that sends these events.
- Errors — the
webhook_endpoint_not_foundcode. - Changelog — what changed.