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:

EventFires when
booking.createdA 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.updatedAn existing booking changes — details or selections edited, customer information changed, a payment or refund recorded, a waiver signed, a note updated.
booking.cancelledA 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

FieldTypeNotes
idstringUnique ID for this event. The same event sent to multiple endpoints shares one id.
typestringbooking.created, booking.updated, or booking.cancelled. Also sent in the X-BookingTerminal-Event header.
apiVersionstringPayload schema version, currently 2025-06-04.
createdstringRFC 3339 timestamp of when the event was generated.
dashboardIdstringID of the Booking Terminal account (dashboard) the event belongs to.
dataobjectFull booking snapshot. Always present.
previousAttributesobjectAlways 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.

FieldTypeNotes
idstringBooking ID. Stable across all events for the same booking.
numberintegerSequential booking number.
codedNumberstringThe booking reference shown to customers (e.g. in confirmation emails).
customerobjectSee below.
availabilityobjectThe booked time slot. See below.
createdAtstringRFC 3339 timestamp the booking was created. Empty string if unset.
selectionsarrayOne entry per quantity of a price option booked.
rebookedTostring | nullID of the replacement booking if this one was rebooked, else null.
rebookedFromstring | nullID of the original booking if this one is a rebooking, else null.
customFieldAnswersarrayBooking-level custom field answers. Per-selection answers appear inside each selection instead.
bookingTotalintegerTotal price in cents.
netPaidintegerAmount paid so far, in cents.
remainingDueintegerbookingTotal - netPaid, in cents.
notestringThe booking's current note; empty string if none.
statusstringconfirmed, canceled, requires_confirmation, or in_cart. Note the single-l canceled here vs. the double-l event name booking.cancelled.
signedWaiversarrayWaivers signed against this booking.

Nested objects

FieldTypeNotes
customer.stripeCustomerIdstring | nullnull when the customer has no Stripe record.
customer.phoneCountryCodestringE.g. +1.
customer.marketingEmailConsentbooleanWhether the customer opted into marketing email.
availability.start / availability.endstringRFC 3339 (UTC). Empty string if unset.
availability.bookableobjectThe product: id, name, sku, code.
selections[].priceOptionobjectid, sku, name (singular form, e.g. "Adult").
customFieldAnswers[].displayValuestringHuman-readable answer; N/A when the response type can't be rendered.
signedWaivers[].minorCountintegerNumber of minors covered by that waiver.
signedWaivers[].isParticipantbooleanWhether 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 null means the field was previously null (e.g. {"rebookedTo": null} on the booking that was just rebooked).
  • If nothing observable changed, previousAttributes is an empty object ({}).

Verifying signatures

Every request carries these headers:

HeaderValue
Content-Typeapplication/json
X-BookingTerminal-EventThe event type.
X-BookingTerminal-DeliveryUnique delivery ID. Stable across retries of the same delivery.
X-BookingTerminal-Signaturet=<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 POST requests 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.