Webhooks guide
Event types, signature verification (Node, Python, PHP, C#, Go), retries and catching up on missed events.
Webhooks POST to your server when something happens. Every event has the same envelope:
{
"id": "evt_981",
"type": "calendar.updated",
"created_at": "2027-06-01T10:00:00.000Z",
"livemode": true,
"api_version": "2026-11-01",
"property_id": "5b1c1f1e-…",
"property_seq": 14,
"data": { "closed": [{ "start": "2027-07-10", "end": "2027-07-14" }], "opened": [], "source": "agency" }
}property_seqnumbers a property's events 1, 2, 3…. If you see a gap you missed an event; catch up withGET /v1/events.- An agency's booking reaches the other subscribers only as
calendar.updated(source: "agency"); who made it, the guest or any reference is never shared. - For your own bookings you get
reservation.*andhold.*events, which go to you alone.
Adding an endpoint
The address must be a public https URL. Local and private-network addresses (10.x, 192.168.x, localhost) are refused; use a tunnel (ngrok, Cloudflare Tunnel) to try locally. Any 2xx answer is a success; reply within 10 seconds and do the work in the background.
Verify the signature
Signatures follow the Standard Webhooks specification. Each request carries webhook-id, webhook-timestamp (seconds) and webhook-signature. The signature is the HMAC-SHA256 of webhook-id.webhook-timestamp.body with your secret (the base64-decoded part after whsec_). Verify against the raw body; never re-serialise the JSON. Reject timestamps older than 5 minutes.
import crypto from 'node:crypto';
export function verify(secret, headers, rawBody) {
const id = headers['webhook-id'];
const ts = Number(headers['webhook-timestamp']);
if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = 'v1,' + crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
// the header may hold several space-separated signatures (during secret rotation)
return headers['webhook-signature'].split(' ').some((sig) => {
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
// Express: app.post('/hooks/bookingpool', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verify(process.env.BP_WHSEC, req.headers, req.body.toString())) return res.sendStatus(400);
// const event = JSON.parse(req.body); /* queue it, then: */ res.sendStatus(200);
// });To check it works, use the "Send test" button on the Developer page or POST /v1/webhook-endpoints/{id}/test: a signed ping event is sent at once.
Retries
Any non-2xx answer, timeout or connection error counts as a failure and is retried after 5 s, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and 24 h (about three days). If it still fails the delivery becomes FAILED; resend it by hand from the Developer page or with POST /v1/webhook-deliveries/{id}/retry. If an endpoint keeps failing we e-mail you and disable it.
The same event can arrive more than once (when the network loses your answer) and order is not guaranteed. Ignore repeats by event id and order by property_seq.
Rotating the secret
rotate-secret gives a new secret; the old one stays valid for 24 more hours and during that time webhook-signature carries two signatures (space-separated). The code above already handles that: switch to the new secret, then wait.
Catching up
If your server was down for a while, continue with GET /v1/events?after=… using the id of the last event you processed. The log has the same events as your webhooks, in order and with the same visibility rules.