Webhook developer guide
Booking Terminal sends webhooks as signed HTTP POST requests when bookings are created, updated, or canceled. This guide covers everything you need to receive, verify, and process them.
Setup
The account owner creates the webhook endpoint in the Booking Terminal
dashboard (Settings → Webhooks), entering your URL and choosing which
event types to send — there's a step-by-step in the
operator guide. You need two things from them: the
endpoint URL they registered must be one you control, served over HTTPS
and reachable from the public internet (requests to private or internal
network addresses are refused, so localhost won't work — use a tunnel
during development), and the signing secret, a 64-character hex string
shown once when the endpoint is created. There is no way to retrieve the
secret again or rotate it in place; if it's lost or compromised, delete the
endpoint and create a new one.
Events
There are three event types:
| Event | Fires when |
|---|---|
booking.created | A booking is created — by staff in the dashboard, by a customer checking out through the booking widget, via a connected OTA, or as the new booking in a rebooking. |
booking.updated | An existing booking changes — details or selections edited, customer information changed, a payment or refund recorded, a waiver signed, a note updated. |
booking.cancelled | A booking is canceled, from the dashboard, an OTA, or as part of a rebooking. |
There is no wildcard or catch-all event type. To receive everything, subscribe the endpoint to all three. (Zapier's "New, Updated, or Canceled Booking" trigger is exactly that — a subscription to all three.)
One user action can emit several events. Rebooking is the main example: the
original booking emits booking.cancelled and booking.updated, and the
replacement booking emits booking.created. Each event is delivered
separately.
Payloads
Every event has the same envelope and the same data shape — a full
snapshot of the booking at the time of the event, not a partial diff. A
booking.updated event looks like this:
{
"id": "1f0c2a7e-9d34-4c8b-b1a2-6f5e8d9c0b3a",
"type": "booking.updated",
"apiVersion": "2025-06-04",
"created": "2026-06-12T15:04:05Z",
"dashboardId": "8a23c917-55d1-4f6e-9b0c-2d4e6f8a0c1e",
"data": {
"id": "3b9d6c1f-2e80-47ab-8f3d-9c5b7a1e4d2f",
"number": 1042,
"codedNumber": "QK7M2",
"customer": {
"id": "7c4e2f9a-1b3d-48c6-a5e7-0f2d4b6c8e1a",
"fullName": "Dana Rivera",
"email": "dana@example.com",
"phoneNumber": "5551234567",
"phoneCountryCode": "+1",
"stripeCustomerId": "cus_QxT3aBcDeFgHiJ",
"marketingEmailConsent": true
},
"availability": {
"id": "5d8f3a2c-6e90-41b7-9c4a-8b1e3f5d7a9c",
"name": "Sunset Kayak Tour",
"start": "2026-06-20T22:00:00Z",
"end": "2026-06-21T00:00:00Z",
"bookable": {
"id": "2a6c8e0f-4b9d-43e1-a7c5-9f1b3d5e7a0c",
"name": "Sunset Kayak Tour",
"sku": "KAYAK-SUNSET",
"code": "SKT"
}
},
"createdAt": "2026-06-10T13:22:48Z",
"selections": [
{
"id": "9e1b3d5f-7a2c-46e8-b0d4-6c8a0e2f4b6d",
"priceOption": {
"id": "4f8a0c2e-6b1d-49f3-8e5a-1c3b5d7f9a2c",
"sku": "ADULT",
"name": "Adult"
},
"customFieldAnswers": []
}
],
"rebookedTo": null,
"rebookedFrom": null,
"customFieldAnswers": [
{
"id": "6c0e2a4f-8d3b-41c7-9f5e-3a7b9d1f5c8e",
"name": "How did you hear about us?",
"displayValue": "Instagram"
}
],
"bookingTotal": 15000,
"netPaid": 15000,
"remainingDue": 0,
"note": "",
"status": "confirmed",
"signedWaivers": [
{
"id": "0b4d6f8a-2c5e-47a9-b1d3-5f7a9c1e3b6d",
"signerFirstName": "Dana",
"signerLastName": "Rivera",
"signerEmail": "dana@example.com",
"signerDob": "1990-04-17",
"signerPhone": "5551234567",
"signerZip": "11211",
"marketingConsent": true,
"signedAt": "2026-06-11T18:45:12Z",
"isParticipant": true,
"minorCount": 1
}
]
},
"previousAttributes": {
"netPaid": 7500,
"remainingDue": 7500
}
}booking.created and booking.cancelled payloads are identical except that
previousAttributes is never included and type differs.
Envelope fields
| Field | Type | Notes |
|---|---|---|
id | string | Unique ID for this event. The same event sent to multiple endpoints shares one id. |
type | string | booking.created, booking.updated, or booking.cancelled. Also sent in the X-BookingTerminal-Event header. |
apiVersion | string | Payload schema version, currently 2025-06-04. |
created | string | RFC 3339 timestamp of when the event was generated. |
dashboardId | string | ID of the Booking Terminal account (dashboard) the event belongs to. |
data | object | Full booking snapshot. Always present. |
previousAttributes | object | Always present on booking.updated (an empty object {} if nothing changed); omitted entirely on other event types (never null). See below. |
data (booking) fields
All keys are always present. Fields whose value can be null are noted;
everything else uses an empty string, 0, false, or [] when there's
nothing to report.
| Field | Type | Notes |
|---|---|---|
id | string | Booking ID. Stable across all events for the same booking. |
number | integer | Sequential booking number. |
codedNumber | string | The booking reference shown to customers (e.g. in confirmation emails). |
customer | object | See below. |
availability | object | The booked time slot. See below. |
createdAt | string | RFC 3339 timestamp the booking was created. Empty string if unset. |
selections | array | One entry per quantity of a price option booked. |
rebookedTo | string | null | ID of the replacement booking if this one was rebooked, else null. |
rebookedFrom | string | null | ID of the original booking if this one is a rebooking, else null. |
customFieldAnswers | array | Booking-level custom field answers. Per-selection answers appear inside each selection instead. |
bookingTotal | integer | Total price in cents. |
netPaid | integer | Amount paid so far, in cents. |
remainingDue | integer | bookingTotal - netPaid, in cents. |
note | string | The booking's current note; empty string if none. |
status | string | confirmed, canceled, requires_confirmation, or in_cart. Note the single-l canceled here vs. the double-l event name booking.cancelled. |
signedWaivers | array | Waivers signed against this booking. |
Nested objects
| Field | Type | Notes |
|---|---|---|
customer.stripeCustomerId | string | null | null when the customer has no Stripe record. |
customer.phoneCountryCode | string | E.g. +1. |
customer.marketingEmailConsent | boolean | Whether the customer opted into marketing email. |
availability.start / availability.end | string | RFC 3339 (UTC). Empty string if unset. |
availability.bookable | object | The product: id, name, sku, code. |
selections[].priceOption | object | id, sku, name (singular form, e.g. "Adult"). |
customFieldAnswers[].displayValue | string | Human-readable answer; N/A when the response type can't be rendered. |
signedWaivers[].minorCount | integer | Number of minors covered by that waiver. |
signedWaivers[].isParticipant | boolean | Whether the signer is themselves a participant. |
previousAttributes
On booking.updated, previousAttributes holds the previous values of
the fields that changed, using the same keys and shapes as data:
- Nested objects contain only the keys that changed (e.g.
{"customer": {"email": "old@example.com"}}). - Arrays are all-or-nothing: if anything inside an array changed, the entire previous array is included.
- A value of
nullmeans the field was previouslynull(e.g.{"rebookedTo": null}on the booking that was just rebooked). - If nothing observable changed,
previousAttributesis an empty object ({}).
Verifying signatures
Every request carries these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-BookingTerminal-Event | The event type. |
X-BookingTerminal-Delivery | Unique delivery ID. Stable across retries of the same delivery. |
X-BookingTerminal-Signature | t=<unix timestamp>,v1=<signature> |
The signature is HMAC-SHA256, hex-encoded, computed with your signing
secret (as an ASCII string) over the string {timestamp}.{raw request body}
— the Unix timestamp from t=, a literal dot, then the exact bytes of the
body. Each delivery attempt is signed freshly at send time, so you can
safely reject stale timestamps (a tolerance of 5 minutes is reasonable) to
prevent replays.
Compute the HMAC over the raw request bytes, not re-serialized JSON. Parsing and re-stringifying the body will reorder or reformat keys and the signature will not match. Configure your framework to give you the raw body for this route.
Delivery behavior
- Deliveries are HTTP
POSTrequests with a JSON body. - Success is any 2xx response. Anything else — including redirects, which we do not follow — counts as a failure. Respond within 15 seconds or the attempt times out.
- Failed deliveries are retried automatically with increasing delays for roughly a day before being marked failed. Don't build against a specific retry count or schedule — it may change.
- Return 2xx as fast as possible and do your real work asynchronously (queue the payload, then process). Slow handlers risk timing out and being redelivered.
- Endpoints must be HTTPS, and we refuse to deliver to private or internal network addresses — use a tunnel (e.g. ngrok) to test locally.
- Every delivery attempt, including the response status and a truncated response body, is visible in the dashboard under Settings → Webhooks → your endpoint, which is the fastest way to debug a failing receiver.
