Webhooks

Signed POSTs to your server when bookings and guests change: the events, the body, verifying the signature and retries.

A webhook sends your server a signed POST when something happens at a business, so a connected app hears about a booking without polling for it. An admin adds one in the dashboard under Developer, on the same plans as the API.

An app can also add its own, with an API key holding webhooks:write: POST /api/v1/webhooks with { "url": "https://…", "events": ["booking.created"] } returns the webhook and its signing secret, and DELETE /api/v1/webhooks/{id} removes it. Each event also needs the key to hold the read scope for its data (reservations:read or appointments:read for bookings, clients:read for guest.created and guest.merged). A key can have three, only it can remove them, and revoking the key removes them all.

Events

EventWhen
booking.createdA booking is made, from any channel: the booking page, staff, an assistant or the API
booking.updatedIts time, table, party size, status, staff member or service changes
booking.cancelledIt is cancelled, by anyone
guest.createdA new guest or client record is added
guest.mergedTwo guest records are merged into one. The body is the record that was kept; changes.id is [removedId, keptId]

A table hold that has not been confirmed is not a booking yet and sends nothing. A change to notes alone does not send booking.updated.

The request

{  "id": "6f0c…",  "type": "booking.created",  "createdAt": "2026-10-12T09:15:02.481Z",  "data": {    "object": { "object": "reservation", "id": "…", "startsAt": "2026-10-20T19:00:00+00", "status": "confirmed", "partySize": 2 },    "changes": { "status": [null, "confirmed"] }  }}

data.object is the booking (reservation or appointment) or the client, with exactly the fields of the matching /api/v1 list, read when the request is sent: it is the current state, not a snapshot from the moment of the event. startsAt and endsAt are the venue's wall clock, as everywhere in this API. changes says what moved, as [before, after] pairs. id is the event's id, the same on every retry.

Headers: Gaplessly-Event-Id, Gaplessly-Event-Type, Gaplessly-Delivery-Id and Gaplessly-Signature.

Verify the signature

Gaplessly-Signature: t=1760260502,v1=5257a8… is an HMAC-SHA256, keyed with the endpoint's signing secret (whsec_…, shown once when the webhook is added), of the timestamp, a full stop, and the raw request body. Compute it over the body exactly as received, before parsing it, compare in constant time, and refuse a timestamp more than five minutes old.

import { createHmac, timingSafeEqual } from 'node:crypto';function verify(rawBody, header, secret) {  const { t, v1 } = Object.fromEntries(header.split(',').map((part) => part.split('=')));  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');  return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));}

Answering, retries and order

Answer any 2xx within eight seconds. Anything else, a timeout or a redirect counts as a failure and is retried with growing gaps, eight attempts over about a day; the log under Developer shows each delivery's status. Use the event id to ignore a repeat. Deliveries can arrive out of order, so trust data.object as it stands rather than replaying events in sequence.

The address must be https on the standard port and reachable from the internet. Send test in the dashboard queues a signed ping event so you can check your receiver before a real booking.

On this page