The Bookable webhook system delivers real-time notifications to your endpoint when booking events occur — such as new bookings, updates, and cancellations.
- Register your callback URL via the API and receive a
secretKey - 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
- Respond with
204 No Contenton success
Webhooks work in both sandbox and production. You can exercise the entire flow — registration, delivery, and signature verification — in sandbox before going live.
| 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 |
The examples below use the production URLs — substitute the sandbox host and your sandbox credentials to test.
In sandbox there is no external reservation system driving events, so your own booking actions generate the webhooks. Once you have registered a callback URL (using your sandbox credentials against https://api-sandbox.bookabletech.com), any booking you create, amend, or cancel through the sandbox API delivers a signed notification to that callback — the same payload and X-API-Key HMAC-SHA256 signature you receive in production, with no external system required.
Each notification is sent with eventType: booking.updated; the booking's current lifecycle state is carried in its status field (for example confirmed or cancelled).
End to end:
- Register your callback URL against the sandbox base URL (Step 1 below) and store the returned
secretKey. - Create, amend, or cancel a booking via the sandbox API.
- Your endpoint receives the
POST— verify theX-API-Keysignature with yoursecretKey(Step 3 below).
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 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"callbackUrl": "https://your-domain.com/webhooks/booking-notification"
}'Response:
{
"secretKey": "123e4567-e89b-12d3-a456-426655440000",
"callbackUrl": "https://your-domain.com/webhooks/booking-notification"
}⚠️ Store the secret key securely
The secretKey is returned only once at registration. Store it in a secrets manager or environment variable — never hardcode it.
curl -X PUT https://api.bookabletech.com/webhooks \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"callbackUrl": "https://your-new-domain.com/webhooks/booking-notification"
}'curl -X DELETE https://api.bookabletech.com/webhooks \
-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.
Bookable POSTs a JSON BookingNotification. The eventType field is the discriminator and takes one of two values:
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, …) |
message.received | messages (array) | The venue operator sent a message on the booking conversation (operator_to_partner only) |
There is no separate booking.created/booking.cancelled event — creation and cancellation are both delivered as booking.updated with the relevant status.
The same event may be delivered more than once (failed deliveries retry — see retries). Make your handler idempotent: deduplicate booking events by the booking id and messages by the message id.
// Node.js / Express
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.BOOKABLE_WEBHOOK_SECRET;
// 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;
}
app.post('/webhooks/booking-notification', (req, res) => {
try {
if (!verifySignature(req.headers['x-api-key'], req.body, WEBHOOK_SECRET)) {
return res.status(401).json({ success: false, error: 'unauthorized' });
}
const { eventType, timestamp, bookings, messages } = req.body;
if (!eventType || !timestamp) {
return res.status(400).json({ success: false, error: 'validation_error' });
}
switch (eventType) {
case 'booking.updated':
// Iterate every affected booking. bookings[].status carries the lifecycle
// state (confirmed, cancelled, ...). Dedupe on booking.id before side effects.
for (const booking of bookings ?? []) {
if (!markProcessed(booking.id)) continue;
console.log(`booking.updated: ${booking.id} (${booking.status})`);
// Handle event...
}
break;
case 'message.received':
// Iterate every message; dedupe on message.id before side effects.
// Reply by emailing message.replyTo, or POST a message 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;
default:
console.warn(`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, body, secret) {
if (!signature) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(body))
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
}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",
"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"
}
]
}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.
| Status | Description |
|---|---|
pending | Awaiting operator confirmation |
in_progress | Enquiry received and assigned but not yet confirmed |
confirmed | Booking confirmed |
cancelled | Booking cancelled or rejected |
deleted | Booking deleted from the system |
lost | Booking was not completed before its scheduled date |
Every webhook request includes an X-API-Key header containing an HMAC-SHA256 hex digest of the raw request body, signed with your secretKey.
Verification steps:
- Read the raw request body as a string (before any JSON parsing)
- Compute
HMAC-SHA256(secretKey, rawBody)and hex-encode it - Compare the result to the
X-API-Keyheader using a timing-safe comparison
⚠️ Use the raw body
Always compute the signature on the raw request body string, not a re-serialised version of the parsed JSON — key ordering and whitespace must be identical.
Your endpoint should return appropriate HTTP status codes:
| Code | Meaning |
|---|---|
204 | Success — event processed |
400 | Bad request — invalid payload |
401 | Unauthorized — signature invalid |
500 | Server error — processing failed |
Failed deliveries are retried with exponential backoff: 1, 2, 4, 8, 16, and 32 minutes. After 6 attempts the event is dropped and logged for review.
- HTTPS only — never register an HTTP callback URL
- Verify every request — always validate the signature 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
- Log everything — store raw payloads for debugging and auditing
| Symptom | Check |
|---|---|
| Not receiving events | Is your callback URL publicly reachable over HTTPS? Does it return 204? |
| Signature mismatch | Are you hashing the raw body before JSON parsing? Is the stored secret correct? |
| Auth failures | Are your OAuth2 credentials valid? Is audience set to api.bookabletech.com? |
Support: hello@bookabletech.com