Skip to content

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}.

EventWhen we send it
reservation.createdA booking is created as a hold (state held). This covers widget and API holds and bookings an operator makes in the dashboard.
reservation.confirmedA booking becomes confirmed. This covers a paid card payment, a guest confirm, and an operator confirm as paid offline.
reservation.cancelledA booking is cancelled with PATCH or DELETE /v1/bookings/{id}.
payment.succeededStripe tells us a card payment for a hold succeeded.
payment.failedStripe tells us a card payment for a hold failed, or was canceled. Each failed try sends one event.
refund.createdA 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.

You need an sk_ key with the webhooks:manage scope. See Authentication.

Terminal window
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-endpoints lists your endpoints as { "webhookEndpoints": [...] }. It never includes secret.
  • lastSuccessAt, lastFailureAt, and failureCount show how deliveries are going. failureCount goes back to 0 after a success.
  • DELETE /v1/webhook-endpoints/{id} returns 204. An unknown id returns 404 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.

The body is JSON. The event type is not in the body. Read it from the X-Booking-Event-Type header.

HeaderValue
X-Booking-Signaturet=<unix seconds>,v1=<hex>. See Verify the signature.
X-Booking-Event-TypeThe event name, for example reservation.confirmed.
X-Booking-Event-IdThe id of this delivery. A retry of the same delivery keeps the same id.
Content-Typeapplication/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"
}
  • reason is payment_failed when a card try failed, or canceled when the payment was canceled.
  • paymentIntentId is null when 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.

Check the signature before you trust a payload. We sign with HMAC-SHA256.

  1. Read the raw request body as text. Do not parse it and write it again.
  2. Read t and v1 from the X-Booking-Signature header.
  3. Build the string <t>.<raw body>.
  4. Compute HMAC-SHA256 of that string. The key is your whole secret, including the whsec_ prefix. Write the result as lowercase hex.
  5. Compare it to v1 with a constant-time compare.
  6. Reject the request if t is 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);
  • Answer with any 2xx status to say you got it. Answer fast. We wait up to 10 seconds for a reply.
  • A 2xx ends the delivery. A 4xx other than 429 also ends it, as a failure. We take it as “this request is wrong” and we do not retry.
  • We do not follow redirects. A 3xx counts as a failed try. Give us the final URL.
  • A 3xx, a 429, a 5xx, 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-Id to skip an event you already handled. To get the current state, read GET /v1/bookings/{id}.
  • Quickstart — make a booking that sends these events.
  • Errors — the webhook_endpoint_not_found code.
  • Changelog — what changed.