Skip to content
Last updated

Webhook Guide

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.

Integration flow

  1. Create a webhook subscription (your callback URL) and receive its webhookId and secretKey
  2. Store the secret key securely — you'll use it to verify every incoming request
  3. Implement an HTTPS endpoint that accepts POST requests and verifies the HMAC-SHA256 signature
  4. Test it with POST /webhooks/test and read the delivery report
  5. Respond with 204 No Content to every event you accept
Your Webhook EndpointBookableYour AppYour Webhook EndpointBookableYour AppPhase 1: SubscribeStore secretKey securelyPhase 2: TestPhase 3: Event Deliveryalt[Valid][Invalid]loop[For each event]Failed deliveries retry with exponential backoffPOST /webhooks/subscriptions {callbackUrl, eventTypes?}201 { webhookId, secretKey }POST /webhooks/testPOST {callbackUrl}X-Webhook-Test: true204 No Content200 { deliveries: [{ delivered: true, ... }] }Sign raw body with the subscription's secretKeyPOST {callbackUrl}X-API-Key: signatureX-Webhook-Id: webhookIdPick secret by X-Webhook-Id, verify signature204 No Content401 Unauthorized

Environments

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.

SandboxProduction
Base URLhttps://api-sandbox.bookabletech.comhttps://api.bookabletech.com
Auth endpointhttps://auth-sandbox.bookabletech.com/oauth/tokenhttps://auth.bookabletech.com/oauth/token
POST /webhooks/testwebhook.ping, booking.updated, message.received, venue.updatedwebhook.ping only

The examples below use the production URLs — substitute the sandbox host and your sandbox credentials to test.

Testing in sandbox

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.updated to your callback. Same payload and X-API-Key signature as production. The booking's lifecycle state is in its status field (for example confirmed or cancelled).
  • 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:

  1. Create a subscription against the sandbox base URL (Step 1) and store the returned secretKey.
  2. Call POST /webhooks/test, or create, amend, or cancel a booking via the sandbox API.
  3. Your endpoint receives the POST — verify the X-API-Key signature with your secretKey (see Signature verification).

Step 1: Create a subscription

A distributor can hold several webhook subscriptions. Each has its own:

  • callbackUrl — public HTTPS URL the events are POSTed to
  • secretKey — signs that subscription's deliveries
  • eventTypes — optional filter (omit for every event)
  • label — optional name, to tell them apart
  • active — false pauses 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.

MethodPathPurpose
GET/webhooks/subscriptionsList your subscriptions (secrets never returned)
POST/webhooks/subscriptionsCreate 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}/secretRotate the signing secret (new one shown once)
POST/webhooks/testSend 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.

Obtain an access token

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"
  }'

Create the subscription

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"
  }'
FieldRequiredNotes
callbackUrlYesAbsolute HTTPS URL resolving to a public address (else 400 WHK-N-002). Must be unique across your subscriptions (else 409 WHK-N-003)
eventTypesNoAny of booking.updated, message.received, venue.updated (else 400 WHK-N-005). Omit or null for every event, including types added later
labelNoYour 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.

List your subscriptions

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"

Update or pause a subscription

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.

Rotate a secret

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:

  1. Rotate, and add the new secret to your endpoint alongside the old one.
  2. Accept a request if its signature matches either secret.
  3. Confirm with POST /webhooks/test?webhookId=… — the report's signature is computed with the new secret.
  4. After a short window, remove the old secret.

Delete a subscription

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"

Step 2: Implement your webhook endpoint

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

Request headers

HeaderAlways sentMeaning
X-API-KeyYesLowercase hex HMAC-SHA256 of the raw body, keyed with the subscription's secretKey. See Signature verification
X-Webhook-IdYesThe subscription's webhookId. With several subscriptions, use it to pick the secretKey
X-Webhook-TestTest deliveries onlytrue 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).

Supported event types

Bookable sends a JSON BookingNotification. Check eventType to know what happened:

eventTypePopulated fieldTrigger
booking.updatedbookings (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.receivedmessages (array)The venue operator sent a message on the booking conversation (operator_to_partner only)
venue.updatedvenues (array)A venue closed — see venue.updated
webhook.pingnoneA 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.

Delivery is at-least-once

You can receive the same event more than once (see retries). Skip events you already handled:

  • Bookings: use booking id + lastUpdate. Not id alone — 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.

Full endpoint implementation

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 204 to webhook.ping and 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);
  });
}

Step 3: Test your endpoint

Sending a test event

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 secretKey like a real event, plus X-Webhook-Test: true.
  • One attempt, 10 second timeout, no retries — a failure is reported straight back.
  • A 200 means the test ran. Check deliveries[].delivered, not the HTTP status — a refused or timed-out delivery is delivered: false, not an error.
  • Up to 10 test calls per minute per distributor; past that, 429 RATE-R-001.
  • A bookingId must be one of your bookings (403 PMAP-N-003 otherwise) — its events only ever go to you. A venueId must be a venue you distribute (403 otherwise).
# 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"

What can be sent where

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

Who receives it

RequestRecipients
No webhookIdEvery 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

Where the payload comes from (sandbox)

SourceRequestWhat is sent
SampleeventType onlyA 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 messageYour 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.

Reading the report

{
  "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
    }
  ]
}
FieldMeaning
payloadThe 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 []
deliveredtrue when your endpoint answered 2xx
skippedfiltered — the subscription's eventTypes excludes this event; nothing was sent
statusCodeStatus your endpoint returned. null if it never answered
timedOuttrue when it didn't answer within 10 seconds
responseBodyWhat your endpoint returned, truncated to 4 KB
errorTransport error when it never answered (timeout, DNS, connection refused)
signatureThe X-API-Key sent. Recompute HMAC-SHA256 of payload with your secret and compare — a mismatch means you hold the wrong secret
eventIdId the deliveries are recorded under in Bookable's audit log — quote it to support

Webhook payload

The full schema for every event is in the Handling Webhook Events API reference. The common shape:

FieldTypeNotes
eventTypestringbooking.updated, message.received, venue.updated, webhook.ping — or a newer type you should ignore
timestampdate-timeWhen the notification was generated (UTC)
bookings / messages / venuesarrayOnly the one matching eventType is present; the others are omitted

booking.updated

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.

message.received

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.

venue.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"
    }
  ]
}
FieldTypeDescription
compositeIdstringThe venue id (like in GET /venues). Not a product compositeId
namestringVenue name
activebooleanWhether the venue is still bookable. false means it has closed
shutAtdate-time | nullClosing 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.updated subscription — not only partners holding bookings there. If a venue is unknown to you anyway, ignore it.
  • If your endpoint answers 4xx (other than 408 / 429), the event is not sent again. 5xx, 408, 429 and timeouts are retried.
  • No event is sent when a venue re-opens.

webhook.ping

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

Booking status values

StatusDescription
pendingAwaiting operator confirmation
in_progressEnquiry received and assigned but not yet confirmed
confirmedBooking confirmed
cancelledBooking cancelled or rejected — including bookings the TMS marks as deleted or lost
deletedListed in the schema, not sent today
lostListed in the schema, not sent today

Signature verification

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:

  1. Read the raw request body bytes (before any JSON parsing)
  2. Look up the secret(s) for X-Webhook-Id
  3. Compute HMAC-SHA256(secretKey, rawBody) and hex-encode it (lowercase)
  4. Compare the result to the X-API-Key header 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.


Error handling & retries

Your endpoint should return appropriate HTTP status codes:

CodeMeaning
204Success — event processed (also for webhook.ping and event types you ignore)
400Bad request — invalid payload
401Unauthorized — signature invalid
500Server error — processing failed

Any non-2xx answer, timeout or connection failure counts as a failed delivery.

How retries work

Retries happen at two levels:

  1. 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). On 429 Bookable waits for your Retry-After, up to 10 seconds. A redirect (3xx) is never followed and counts as a failure.
  2. Redelivery — for booking.updated and venue.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.received is 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.

Event4xx (not 408/429)Immediate retries
booking.updatedRetried like a 5xxUp to 3
message.receivedRecorded as failed, not redeliveredUp to 3
venue.updatedTerminal — recorded as rejected, never sent again1
Test deliveries (POST /webhooks/test)Reported, never retriedNone

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.

Timeouts

EventTimeout
booking.updated, message.received45 seconds in total, retries included
venue.updated10 seconds per attempt
Test deliveries10 seconds (one attempt)

Answer within 10 seconds to be safe for every event type: acknowledge with 204 and do the work asynchronously.


Migrating from 1.x

Register Webhook API 2.0.0 removed the single-webhook model.

1.x2.0.0
GET /webhooksGET /webhooks/subscriptions (list) or GET /webhooks/subscriptions/{webhookId}
POST /webhooksPOST /webhooks/subscriptions — returns webhookId too
PUT /webhooksPUT /webhooks/subscriptions/{webhookId} — partial update
DELETE /webhooksDELETE /webhooks/subscriptions/{webhookId}
Secret only at registrationPOST /webhooks/subscriptions/{webhookId}/secret to rotate
POST /webhooks/test → 202, sample booking.updated to your webhook only, 500 on failure200 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 204 to webhook.ping
  • read X-Webhook-Id if you add more subscriptions
  • check deliveries[].delivered instead of the HTTP status when calling POST /webhooks/test

Best practices

  1. HTTPS only — callback URLs must be public HTTPS (WHK-N-002 otherwise)
  2. Verify every request — validate the signature on the raw body before processing
  3. Respond quickly — return 204 immediately and process the event asynchronously if needed
  4. Handle duplicates — retries can deliver the same event more than once; make your handler idempotent
  5. Ignore what you don't know — answer 2xx to unknown event types and to venues you don't recognise
  6. One subscription per system — give each consumer its own subscription and label rather than fanning out from one endpoint
  7. Rotate with overlap — accept old and new secrets briefly after rotating
  8. Log everything — store raw payloads and X-Webhook-Id for debugging and auditing

Troubleshooting

SymptomCheck
Not receiving eventsRun 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 429RATE-R-001: more than 10 test calls in a minute — wait for the window to reset
Test returns 403PMAP-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 mismatchAre 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 422WHK-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 404WHK-N-001: the webhookId doesn't exist for your credentials — subscriptions are per environment
Can't create a subscriptionWHK-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 failuresAre 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.