The Bookable webhook system delivers real-time notifications to your endpoint when something changes — a booking is created, amended or cancelled, a venue operator messages you, or a venue you distribute closes.
- Create a webhook subscription (your callback URL) and receive its
webhookIdandsecretKey - Store the secret key securely — you'll use it to verify every incoming request
- Implement an HTTPS endpoint that accepts POST requests and verifies the HMAC-SHA256 signature
- Test it with
POST /webhooks/testand read the delivery report - Respond with
204 No Contentto every event you accept
Webhooks work in both sandbox and production. You can exercise the entire flow — subscription, delivery, and signature verification — in sandbox before going live. Subscriptions are per environment: one created in sandbox does not exist in production.
| Sandbox | Production | |
|---|---|---|
| Base URL | https://api-sandbox.bookabletech.com | https://api.bookabletech.com |
| Auth endpoint | https://auth-sandbox.bookabletech.com/oauth/token | https://auth.bookabletech.com/oauth/token |
POST /webhooks/test | webhook.ping, booking.updated, message.received, venue.updated | webhook.ping only |
The examples below use the production URLs — substitute the sandbox host and your sandbox credentials to test.
There are two ways to get events in sandbox:
- Your own booking actions — there is no external reservation system driving events, so any booking you create, amend, or cancel through the sandbox API delivers a signed
booking.updatedto your callback. Same payload andX-API-Keysignature as production. The booking's lifecycle state is in itsstatusfield (for exampleconfirmedorcancelled). POST /webhooks/test— send any event type on demand, including ones your own actions can't trigger (operator messages, no-shows, actuals, operator cancellations, venue closures). See Step 3.
End to end:
- Create a subscription against the sandbox base URL (Step 1) and store the returned
secretKey. - Call
POST /webhooks/test, or create, amend, or cancel a booking via the sandbox API. - Your endpoint receives the
POST— verify theX-API-Keysignature with yoursecretKey(see Signature verification).
A distributor can hold several webhook subscriptions. Each has its own:
callbackUrl— public HTTPS URL the events are POSTed tosecretKey— signs that subscription's deliverieseventTypes— optional filter (omit for every event)label— optional name, to tell them apartactive—falsepauses deliveries without deleting
Creating, changing or deleting one never affects the others, so an application acting on your behalf can subscribe without touching your own integration.
| Method | Path | Purpose |
|---|---|---|
GET | /webhooks/subscriptions | List your subscriptions (secrets never returned) |
POST | /webhooks/subscriptions | Create one — returns webhookId and secretKey (shown once) |
GET | /webhooks/subscriptions/{webhookId} | Get one |
PUT | /webhooks/subscriptions/{webhookId} | Partial update — only the fields you send change |
DELETE | /webhooks/subscriptions/{webhookId} | Delete one (204) |
POST | /webhooks/subscriptions/{webhookId}/secret | Rotate the signing secret (new one shown once) |
POST | /webhooks/test | Send a test event now and get a delivery report |
The old single-webhook endpoints (GET, POST, PUT, DELETE /webhooks) were removed in Register Webhook API 2.0.0 — see Migrating from 1.x.
See Authentication for the full token flow. Quick reference:
curl -X POST https://auth.bookabletech.com/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"audience": "api.bookabletech.com"
}'curl -X POST https://api.bookabletech.com/webhooks/subscriptions \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"callbackUrl": "https://your-domain.com/webhooks/bookable",
"eventTypes": ["booking.updated", "venue.updated"],
"label": "Order management system"
}'| Field | Required | Notes |
|---|---|---|
callbackUrl | Yes | Absolute HTTPS URL resolving to a public address (else 400 WHK-N-002). Must be unique across your subscriptions (else 409 WHK-N-003) |
eventTypes | No | Any of booking.updated, message.received, venue.updated (else 400 WHK-N-005). Omit or null for every event, including types added later |
label | No | Your own name for the subscription |
A distributor can hold up to 10 subscriptions; an 11th fails with 409 WHK-N-004.
Response (201):
{
"webhookId": "whk_3f2b8c1e5a7d4e9b9c0a1d2e3f4a5b6c",
"callbackUrl": "https://your-domain.com/webhooks/bookable",
"eventTypes": ["booking.updated", "venue.updated"],
"label": "Order management system",
"active": true,
"isDefault": false,
"createdAt": "2026-10-16T09:00:00Z",
"updatedAt": null,
"secretKey": "123e4567-e89b-12d3-a456-426655440000"
}Keep the webhookId — you need it to update, delete, rotate or test the subscription, and it arrives on every delivery in the X-Webhook-Id header. isDefault is a legacy marker for a subscription carried over from the 1.x single-webhook model; it has no effect on delivery.
⚠️ Store the secret key securely
The secretKey is returned only once, at creation (or when rotated). It cannot be read back. Store it in a secrets manager or environment variable — never hardcode it. If you lose it, rotate it.
GET /webhooks/subscriptions returns { "data": [ ... ] }, one WebhookSubscription per entry. Secrets are never included. data is omitted when you have none — treat a missing data as [].
curl https://api.bookabletech.com/webhooks/subscriptions \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"PUT /webhooks/subscriptions/{webhookId} is a partial update: only the fields you send (callbackUrl, eventTypes, label, active) change, the rest stay as they are. It returns the updated subscription. The secret is never changed here — rotate it instead. An unknown webhookId returns 404 WHK-N-001.
# Move to a new URL
curl -X PUT https://api.bookabletech.com/webhooks/subscriptions/$WEBHOOK_ID \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "callbackUrl": "https://your-new-domain.com/webhooks/bookable" }'
# Pause deliveries without deleting
curl -X PUT https://api.bookabletech.com/webhooks/subscriptions/$WEBHOOK_ID \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "active": false }'Tip: before switching a paused subscription back on, test it with POST /webhooks/test?webhookId=… — a targeted test reaches a paused subscription.
POST /webhooks/subscriptions/{webhookId}/secret generates a new secretKey and returns it once (200 { "secretKey": "..." }). The callback URL and every other subscription are untouched.
curl -X POST https://api.bookabletech.com/webhooks/subscriptions/$WEBHOOK_ID/secret \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Deliveries signed with the previous secret (including retries) may still be in flight. Rotate without dropping events:
- Rotate, and add the new secret to your endpoint alongside the old one.
- Accept a request if its signature matches either secret.
- Confirm with
POST /webhooks/test?webhookId=…— the report'ssignatureis computed with the new secret. - After a short window, remove the old secret.
DELETE /webhooks/subscriptions/{webhookId} returns 204. Deliveries to that subscription stop; others are unaffected.
curl -X DELETE https://api.bookabletech.com/webhooks/subscriptions/$WEBHOOK_ID \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Your endpoint must accept POST requests, verify the HMAC-SHA256 signature, process the event, and respond with 204 No Content — within 10 seconds (the shortest wait Bookable applies; see timeouts).
| Header | Always sent | Meaning |
|---|---|---|
X-API-Key | Yes | Lowercase hex HMAC-SHA256 of the raw body, keyed with the subscription's secretKey. See Signature verification |
X-Webhook-Id | Yes | The subscription's webhookId. With several subscriptions, use it to pick the secretKey |
X-Webhook-Test | Test deliveries only | true when the request came from POST /webhooks/test. A real event never carries it |
If you share one URL between environments or systems, treat X-Webhook-Test: true deliveries as tests (log them, don't write business data from them).
Bookable sends a JSON BookingNotification. Check eventType to know what happened:
eventType | Populated field | Trigger |
|---|---|---|
booking.updated | bookings (array) | A booking was created, changed, or cancelled — the lifecycle state is carried in each booking's status field (confirmed, cancelled, …). Also fires when the operator's TMS reconciles actualGuests / actualSpend after the visit, with no change to status |
message.received | messages (array) | The venue operator sent a message on the booking conversation (operator_to_partner only) |
venue.updated | venues (array) | A venue closed — see venue.updated |
webhook.ping | none | A test you requested with POST /webhooks/test. Carries X-Webhook-Test: true |
At most one of bookings, messages or venues is present (none for webhook.ping). The others are left out of the body — treat a missing collection as null.
A subscription with an eventTypes filter only receives those types. webhook.ping can't be filtered — every test ping reaches its target.
There is no separate booking.created/booking.cancelled event — creation and cancellation are both delivered as booking.updated with the relevant status.
Ignore event types you don't know. New ones are added over time and reach every subscription without a filter. Answer 2xx and drop them — an error only produces failed deliveries and retries on your side.
You can receive the same event more than once (see retries). Skip events you already handled:
- Bookings: use booking
id+lastUpdate. Notidalone — one booking gets many updates (created, changed, cancelled), and you must handle each one. - Messages: use message
id. - Venues: no key needed — applying a closure twice changes nothing.
The examples below:
- read the raw body and verify the signature before parsing it
- pick the secret by
X-Webhook-Id, accepting either secret of a pair during rotation - answer
204towebhook.pingand to unknown event types - dedupe before side effects
Secrets are loaded from BOOKABLE_WEBHOOK_SECRETS, a JSON object mapping webhookId to a list of accepted secrets, e.g. {"whk_3f2b…": ["new-secret", "old-secret"]}.
// Node.js / Express
const express = require('express');
const crypto = require('crypto');
const app = express();
// { webhookId: [secret, ...] } — more than one secret only while rotating
const WEBHOOK_SECRETS = JSON.parse(process.env.BOOKABLE_WEBHOOK_SECRETS);
// Delivery is at-least-once, so record every id before applying side effects.
// Durable in production (e.g. a unique insert in your datastore); in-memory Set shown for brevity.
const processedIds = new Set();
function markProcessed(id) {
if (processedIds.has(id)) return false; // already handled — skip
processedIds.add(id); // replace with an insert-if-absent in your datastore
return true;
}
// express.raw keeps the exact bytes Bookable signed
app.post('/webhooks/bookable', express.raw({ type: 'application/json' }), (req, res) => {
try {
const secrets = WEBHOOK_SECRETS[req.get('X-Webhook-Id')] ?? [];
if (!verifySignature(req.get('X-API-Key'), req.body, secrets)) {
return res.status(401).json({ success: false, error: 'unauthorized' });
}
const { eventType, timestamp, bookings, messages, venues } = JSON.parse(req.body.toString('utf8'));
const isTest = req.get('X-Webhook-Test') === 'true';
if (!eventType || !timestamp) {
return res.status(400).json({ success: false, error: 'validation_error' });
}
switch (eventType) {
case 'booking.updated':
// bookings[].status carries the lifecycle state. Skip duplicates using id + lastUpdate.
for (const booking of bookings ?? []) {
if (!markProcessed(`${booking.id}@${booking.lastUpdate}`)) continue;
console.log(`booking.updated: ${booking.id} (${booking.status})${isTest ? ' [test]' : ''}`);
// Handle event...
}
break;
case 'message.received':
// Dedupe on message.id. Reply by emailing message.replyTo, or via the Messaging API.
for (const message of messages ?? []) {
if (!markProcessed(message.id)) continue;
console.log(`message.received: ${message.id} for booking ${message.bookingId}`);
// Handle event...
}
break;
case 'venue.updated':
// A venue closed (active: false). Stop offering it. Safe to handle twice.
for (const venue of venues ?? []) {
console.log(`venue.updated: ${venue.compositeId} active=${venue.active}`);
// Handle event...
}
break;
case 'webhook.ping':
break; // connectivity test — nothing to do
default:
console.warn(`Ignoring unknown event type: ${eventType}`);
}
res.status(204).end();
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ success: false, error: 'internal_error' });
}
});
function verifySignature(signature, rawBody, secrets) {
if (!signature) return false;
const received = Buffer.from(signature, 'utf8');
return secrets.some((secret) => {
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
'utf8'
);
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
});
}POST /webhooks/test sends one test event to your subscriptions now and returns what each endpoint answered. Use it to check a new endpoint, a rotated secret, a paused subscription before re-enabling it, or how you handle each event type.
- Signed with the subscription's
secretKeylike a real event, plusX-Webhook-Test: true. - One attempt, 10 second timeout, no retries — a failure is reported straight back.
- A
200means the test ran. Checkdeliveries[].delivered, not the HTTP status — a refused or timed-out delivery isdelivered: false, not an error. - Up to 10 test calls per minute per distributor; past that,
429RATE-R-001. - A
bookingIdmust be one of your bookings (403PMAP-N-003otherwise) — its events only ever go to you. AvenueIdmust be a venue you distribute (403otherwise).
# Ping every active subscription (same as an empty body)
curl -X POST https://api.bookabletech.com/webhooks/test \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
# Ping one subscription, even if paused
curl -X POST "https://api.bookabletech.com/webhooks/test?webhookId=$WEBHOOK_ID" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"| Event | Production | Sandbox |
|---|---|---|
webhook.ping — no business data; checks reachability, TLS and your signature handling | ✅ | ✅ |
booking.updated, message.received, venue.updated | ❌ 422 WHK-N-006 | ✅ |
Production is limited to webhook.ping on purpose: a sample booking or a simulated venue closure could be mistaken for real data by a system that ignores X-Webhook-Test, and resending a real booking can reach the guest. Test business events in sandbox.
| Request | Recipients |
|---|---|
No webhookId | Every active subscription. Business events respect each one's eventTypes filter — a subscription that filters the event out is listed with skipped: filtered. webhook.ping goes to all of them |
?webhookId=… | That subscription only — even if paused, and even if its filter excludes the event |
| Source | Request | What is sent |
|---|---|---|
| Sample | eventType only | A sample event. Ids are visibly fake (TEST|X0|0…0) |
| Real | + bookingId (your booking) or venueId (a venue you distribute) | The current state, exactly as a real event would carry it. Nothing is written |
| Simulated | + changes (with bookingId), venue or message | Your override. changes is written to the sandbox booking first, standing in for the operator editing it in their TMS. venue and message are never stored or emailed |
Mixing fields that don't apply to the event type (e.g. changes on venue.updated) returns 422 WHK-N-007.
changes accepts any of status (pending, in_progress, confirmed, cancelled), date, time, partySize, duration, compositeId (another product in the same venue group and TMS), productName, notes, reference, source, firstName, lastName, email, phone, actualGuests, actualSpend, preorders and preorderMenus (menus must be real menus of the venue). Send only what changed. changes: {} changes nothing but lastUpdate — what a payment status change looks like in production. Leaving changes out sends the booking as it is, without writing anything.
Common sandbox scenarios:
// Sample booking.updated
{ "eventType": "booking.updated" }
// No-show reported after the visit
{ "eventType": "booking.updated", "bookingId": "29|X9|275cc44dd2e2496fba44857c9257443a|B",
"changes": { "actualGuests": 0, "actualSpend": 0 } }
// Operator cancelled the booking
{ "eventType": "booking.updated", "bookingId": "29|X9|275cc44dd2e2496fba44857c9257443a|B",
"changes": { "status": "cancelled" } }
// Operator moved the booking in their diary
{ "eventType": "booking.updated", "bookingId": "29|X9|275cc44dd2e2496fba44857c9257443a|B",
"changes": { "date": "2026-10-18", "time": "20:00", "partySize": 6 } }
// Operator message about one of your bookings (never stored or emailed)
{ "eventType": "message.received", "bookingId": "29|X9|275cc44dd2e2496fba44857c9257443a|B",
"message": { "subject": "Allergy update", "body": "The guest is now nut-free.", "senderName": "The Ivy" } }
// One of your venues closed (the venue itself is not touched)
{ "eventType": "venue.updated", "venueId": "29|X9|275cc44dd2e2496fba44857c9257443a",
"venue": { "active": false } }To test your message.received handling, prefer this over the Messaging API's sandbox direction override: that one answers 201 even when delivery fails and drops an identical message repeated within the hour.
{
"message": "Test event sent to 1 subscription(s); 1 skipped",
"eventId": "7c6b5a4f-3e2d-4c1b-a09f-8e7d6c5b4a3f",
"eventType": "message.received",
"payload": "{\"eventType\":\"message.received\",\"timestamp\":\"2026-10-14T10:30:00+00:00\",\"messages\":[]}",
"deliveries": [
{
"webhookId": "whk_3f2b8c1e5a7d4e9b9c0a1d2e3f4a5b6c",
"callbackUrl": "https://partner.example.com/webhooks/bookable",
"delivered": false,
"statusCode": 400,
"timedOut": false,
"durationMs": 62,
"responseBody": "{\"error\":\"invalid signature\"}",
"signature": "9f2c1d0e8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e"
},
{
"webhookId": "whk_8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d",
"callbackUrl": "https://portal.example.com/hooks",
"delivered": false,
"skipped": "filtered",
"timedOut": false,
"durationMs": 0
}
]
}| Field | Meaning |
|---|---|
payload | The exact JSON body sent — every subscription receives the same bytes. Omitted when nothing was sent |
deliveries[] | One entry per addressed subscription. Omitted when there are none — treat missing as [] |
delivered | true when your endpoint answered 2xx |
skipped | filtered — the subscription's eventTypes excludes this event; nothing was sent |
statusCode | Status your endpoint returned. null if it never answered |
timedOut | true when it didn't answer within 10 seconds |
responseBody | What your endpoint returned, truncated to 4 KB |
error | Transport error when it never answered (timeout, DNS, connection refused) |
signature | The X-API-Key sent. Recompute HMAC-SHA256 of payload with your secret and compare — a mismatch means you hold the wrong secret |
eventId | Id the deliveries are recorded under in Bookable's audit log — quote it to support |
The full schema for every event is in the Handling Webhook Events API reference. The common shape:
| Field | Type | Notes |
|---|---|---|
eventType | string | booking.updated, message.received, venue.updated, webhook.ping — or a newer type you should ignore |
timestamp | date-time | When the notification was generated (UTC) |
bookings / messages / venues | array | Only the one matching eventType is present; the others are omitted |
The affected booking(s) are in the bookings array.
{
"eventType": "booking.updated",
"timestamp": "2026-06-17T10:30:00Z",
"bookings": [
{
"id": "29|CO|275cc44dd2e2496fba44857c9257443a|B",
"compositeId": "29|CO|275cc44dd2e2496fba44857c9257443a|d99128c546b34b619c4477b712869f2b",
"date": "2026-06-25",
"time": "19:30:00",
"dateTime": "2026-06-25T19:30:00+01:00",
"partySize": 4,
"status": "confirmed",
"reference": "REF-20260617-001",
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com",
"phone": "+1234567890",
"duration": 120,
"createdDate": "2026-06-17T09:15:00Z",
"lastUpdate": "2026-06-17T10:30:00Z"
}
]
}Fields not shown above, sent when the booking has them: company, location (address, city, state, postalCode, country), productName, productType (deprecated, same as productName), notes, source (the partner source that made the booking), preorders and preorderMenus. The full list is in the Handling Webhook Events reference.
reference is the operator's booking reference from their TMS (the operatorBookingId of the Bookings API), not your own reference — match bookings on id. It is an empty string while the TMS has not assigned one yet; firstName, lastName and email are also empty strings when unknown.
company is the company named on the booking, when the reservation is business-related. It is left out otherwise.
date and time are on the venue's trading day: a 00:30 booking at the end of a Saturday evening has Saturday's date but starts on Sunday. dateTime (nullable) is the real start moment, RFC 3339 with the venue's UTC offset — use it for anything time-sensitive (reminders, calendars). If Bookable can't work out the real start, the event is still sent, without dateTime.
A payment status change also fires booking.updated, but payments aren't in the payload — you will occasionally get an update where nothing visible changed except lastUpdate. Treat it as a no-op (or re-read the booking's payments from the Bookings API).
actualGuests and actualSpend are the guest count and spend reconciled by the operator's TMS after the visit. Both are null until the TMS reports them (and stay null on systems that never do). actualGuests: 0 is a real value meaning no-show, distinct from null. A change to either field triggers its own booking.updated notification. On Collins, actualGuests almost always copies the party size — don't read it as measured attendance there.
One or more messages for a single booking are in the messages array. Fires only when the venue operator sends a message (operator_to_partner). Deduplicate on id.
{
"eventType": "message.received",
"timestamp": "2026-06-17T11:05:00Z",
"messages": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"bookingId": "29|CO|6a3e866a4989b3b73d78f011|B",
"body": "We've noted your high-chair request.",
"subject": "Re: Table for Jane Smith",
"senderName": "Abbey Inn",
"senderEmail": "venue@example.com",
"direction": "operator_to_partner",
"replyTo": "booking-op7k2@mail.bookabletech.com",
"attachments": [
{ "filename": "floorplan.pdf", "contentType": "application/pdf" }
],
"sentAt": "2026-06-17T11:05:00Z"
}
]
}Replying to a message: email the replyTo alias from anywhere (attachments supported) and Bookable relays it to the venue operator, or POST /bookings/{bookingId}/messages via the Messaging API. Attachment bytes are exchanged over email, not in the webhook — attachments here is metadata only.
body contains only the operator's text — no email template, signature or quoted replies. If the text can't be cleaned, you get it as it is. Automatic booking confirmations and cancellations are not sent as messages; you get them as booking.updated.
Sent when a venue closes. The closed venues are in the venues array, with active: false.
{
"eventType": "venue.updated",
"timestamp": "2026-09-30T12:05:00Z",
"venues": [
{
"compositeId": "29|CO|275cc44dd2e2496fba44857c9257443a",
"name": "The Ivy Chelsea Garden",
"active": false,
"shutAt": "2026-09-30T12:00:00Z"
}
]
}| Field | Type | Description |
|---|---|---|
compositeId | string | The venue id (like in GET /venues). Not a product compositeId |
name | string | Venue name |
active | boolean | Whether the venue is still bookable. false means it has closed |
shutAt | date-time | null | Closing date. null if unknown |
What to do: stop offering the venue. Calls for it now fail with 404 and VENUE-N-009. You can still cancel (DELETE) existing bookings there.
The venue's availability feed is emptied too, and its index.json has "active": false.
Good to know:
- It goes to every partner distributing the venue (mapped to it) with an active
venue.updatedsubscription — not only partners holding bookings there. If a venue is unknown to you anyway, ignore it. - If your endpoint answers
4xx(other than408/429), the event is not sent again.5xx,408,429and timeouts are retried. - No event is sent when a venue re-opens.
Sent only when you call POST /webhooks/test. No payload beyond eventType and timestamp; carries X-Webhook-Test: true. Verify the signature and answer 204.
{ "eventType": "webhook.ping", "timestamp": "2026-10-14T10:30:00+00:00" }| Status | Description |
|---|---|
pending | Awaiting operator confirmation |
in_progress | Enquiry received and assigned but not yet confirmed |
confirmed | Booking confirmed |
cancelled | Booking cancelled or rejected — including bookings the TMS marks as deleted or lost |
deleted | Listed in the schema, not sent today |
lost | Listed in the schema, not sent today |
Every webhook request includes an X-API-Key header containing a lowercase hex HMAC-SHA256 digest of the raw request body, signed with the secretKey of the subscription it was sent for.
Each request also carries X-Webhook-Id, the subscription's webhookId. If you hold more than one subscription, use it to pick the right secretKey — don't infer it from the URL the request arrived on. With a single subscription you can ignore it.
Verification steps:
- Read the raw request body bytes (before any JSON parsing)
- Look up the secret(s) for
X-Webhook-Id - Compute
HMAC-SHA256(secretKey, rawBody)and hex-encode it (lowercase) - Compare the result to the
X-API-Keyheader using a timing-safe comparison — during a rotation, accept a match on either secret
expected = HMAC-SHA256(key=secretKey, message=raw_request_body).hex()
assert request.headers["X-API-Key"] == expected⚠️ Use the raw body
Always compute the signature on the raw request body, not a re-serialised version of the parsed JSON — key ordering, whitespace and number formatting must be identical. Parse the JSON only after the signature checks out.
To debug a mismatch, call POST /webhooks/test and compare your computed HMAC of payload with the reported signature.
Your endpoint should return appropriate HTTP status codes:
| Code | Meaning |
|---|---|
204 | Success — event processed (also for webhook.ping and event types you ignore) |
400 | Bad request — invalid payload |
401 | Unauthorized — signature invalid |
500 | Server error — processing failed |
Any non-2xx answer, timeout or connection failure counts as a failed delivery.
Retries happen at two levels:
- Immediate retries — a
5xx,408,429, timeout or connection failure is retried on the spot with short exponential backoff (1–2, 2–4 and 4–8 seconds). On429Bookable waits for yourRetry-After, up to 10 seconds. A redirect (3xx) is never followed and counts as a failure. - Redelivery — for
booking.updatedandvenue.updated, if the delivery still fails, the whole event is queued again and redelivered later, up to 5 deliveries in total. After that it is parked for review by Bookable instead of being retried further.message.receivedis not redelivered automatically: after the immediate retries it is recorded as failed, and Bookable support can resend it.
Each redelivery repeats the immediate retries. Subscriptions that already answered 2xx for that event are skipped, so a failing sibling subscription never causes a duplicate on a healthy one.
| Event | 4xx (not 408/429) | Immediate retries |
|---|---|---|
booking.updated | Retried like a 5xx | Up to 3 |
message.received | Recorded as failed, not redelivered | Up to 3 |
venue.updated | Terminal — recorded as rejected, never sent again | 1 |
Test deliveries (POST /webhooks/test) | Reported, never retried | None |
A 4xx on a booking event still costs you redeliveries, and on a message event it loses the message, so don't use one to reject events you simply don't handle — answer 2xx.
| Event | Timeout |
|---|---|
booking.updated, message.received | 45 seconds in total, retries included |
venue.updated | 10 seconds per attempt |
| Test deliveries | 10 seconds (one attempt) |
Answer within 10 seconds to be safe for every event type: acknowledge with 204 and do the work asynchronously.
Register Webhook API 2.0.0 removed the single-webhook model.
| 1.x | 2.0.0 |
|---|---|
GET /webhooks | GET /webhooks/subscriptions (list) or GET /webhooks/subscriptions/{webhookId} |
POST /webhooks | POST /webhooks/subscriptions — returns webhookId too |
PUT /webhooks | PUT /webhooks/subscriptions/{webhookId} — partial update |
DELETE /webhooks | DELETE /webhooks/subscriptions/{webhookId} |
| Secret only at registration | POST /webhooks/subscriptions/{webhookId}/secret to rotate |
POST /webhooks/test → 202, sample booking.updated to your webhook only, 500 on failure | 200 with deliveries[] per subscription; webhook.ping in production; failure is delivered: false |
Your existing webhook was kept as a subscription with isDefault: true. Call GET /webhooks/subscriptions to find its webhookId, then manage it like any other subscription. Your handler should also:
- answer
204towebhook.ping - read
X-Webhook-Idif you add more subscriptions - check
deliveries[].deliveredinstead of the HTTP status when callingPOST /webhooks/test
- HTTPS only — callback URLs must be public HTTPS (
WHK-N-002otherwise) - Verify every request — validate the signature on the raw body before processing
- Respond quickly — return
204immediately and process the event asynchronously if needed - Handle duplicates — retries can deliver the same event more than once; make your handler idempotent
- Ignore what you don't know — answer
2xxto unknown event types and to venues you don't recognise - One subscription per system — give each consumer its own subscription and
labelrather than fanning out from one endpoint - Rotate with overlap — accept old and new secrets briefly after rotating
- Log everything — store raw payloads and
X-Webhook-Idfor debugging and auditing
| Symptom | Check |
|---|---|
| Not receiving events | Run POST /webhooks/test and read deliveries[]. Is the subscription active? Does its eventTypes filter include the event (skipped: filtered)? Is the URL publicly reachable over HTTPS and answering 2xx within 10 seconds? |
Test returns 429 | RATE-R-001: more than 10 test calls in a minute — wait for the window to reset |
Test returns 403 | PMAP-N-003: the bookingId belongs to another partner — test with one of your own bookings. For venueId: the venue isn't one you distribute |
| Signature mismatch | Are you hashing the raw body before JSON parsing? Are you using the secretKey of the subscription named in X-Webhook-Id? Did you rotate it recently? Compare with the signature in the test report |
Test returns 422 | WHK-N-006: only webhook.ping is available in production — test business events in sandbox. WHK-N-007: a field doesn't apply to the chosen eventType |
Subscription calls return 404 | WHK-N-001: the webhookId doesn't exist for your credentials — subscriptions are per environment |
| Can't create a subscription | WHK-N-002 (URL not public HTTPS), WHK-N-003 (URL already subscribed — update that one instead), WHK-N-004 (limit reached — delete one), WHK-N-005 (unknown value in eventTypes) |
| Auth failures | Are your OAuth2 credentials valid? Is audience set to api.bookabletech.com? |
See the error catalog for every WHK code.
Support: hello@bookabletech.com — include the eventId from the test report or the traceId from an error.