Receive and verify a webhook
A webhook tells your server about a booking your server did not make. This guide registers an endpoint and verifies one delivery. It takes five steps.
1. Register the endpoint
Section titled “1. Register the endpoint”curl -X POST https://api.keptslot.com/v1/webhook-endpoints \ -H "Authorization: Bearer sk_4bT1nQ7xLd9sVzK0pR2eYw8hC6jM3gF5uA1oS7iN0dE" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/keptslot", "events": ["booking.created", "booking.cancelled", "booking.rescheduled"] }'There are three event types, and the list above is all of them:
booking.created, booking.cancelled and booking.rescheduled.
2. Store the secret now
Section titled “2. Store the secret now”The 201 response carries secret:
{ "id": "9d3c7a15-4e82-4b60-a1f7-c05e2d8b3946", "url": "https://example.com/hooks/keptslot", "events": ["booking.created", "booking.cancelled", "booking.rescheduled"], "active": true, "secret": "whsec_TmV2ZXJTaG93bkFnYWluMTIzNDU2Nzg5MGFiY2Rl"}CAUTION: This is the only response that ever carries secret. No later call
can show it to you. Store it now. If you lose it,
call POST /v1/webhook-endpoints/{id}/rotate-secret and store the new one.
3. Read the three headers
Section titled “3. Read the three headers”Every delivery is a POST with a JSON body and three headers.
| Header | What it carries |
|---|---|
Booking-Signature |
t=<unix seconds>,v1=<hex HMAC> |
Booking-Event |
The event type, such as booking.created |
Booking-Delivery |
The delivery id. It is stable across retries. |
The signature header is not prefixed with X-. The name is
Booking-Signature exactly.
4. Verify the signature
Section titled “4. Verify the signature”The signed message is the timestamp, a period, and the raw request body:
<t>.<raw body bytes>The MAC is HMAC-SHA256 with your endpoint secret, in lowercase hex.
CAUTION: Verify against the raw body bytes. If your framework parses the JSON
and you re-serialize it, the bytes change and every signature fails. In
Express, use express.raw.
import crypto from 'node:crypto'import express from 'express'
const app = express()const SECRET = process.env.KEPTSLOT_WEBHOOK_SECRET
function verify(secret, header, rawBody) { let t = null let v1 = null for (const part of header.split(',')) { const [k, v] = part.trim().split('=') if (k === 't') t = v if (k === 'v1') v1 = v } if (t === null || v1 === null) return false if (!/^\d+$/.test(t)) return false
const mac = crypto.createHmac('sha256', secret) mac.update(`${t}.`) mac.update(rawBody) const want = mac.digest()
let got try { got = Buffer.from(v1, 'hex') } catch { return false } if (got.length !== want.length) return false return crypto.timingSafeEqual(got, want)}
app.post('/hooks/keptslot', express.raw({ type: 'application/json' }), (req, res) => { const header = req.get('Booking-Signature') ?? '' if (!verify(SECRET, header, req.body)) { res.sendStatus(400) return }
const event = JSON.parse(req.body.toString('utf8')) handle(req.get('Booking-Delivery'), event)
res.sendStatus(200)})
app.listen(3000)Compare the two MACs in constant time, as timingSafeEqual does above. A
comparison that stops at the first wrong byte tells an attacker how much of a
forged signature was correct.
We do not enforce a timestamp tolerance. How much clock skew and replay window
to accept is your policy, so read t and apply your own limit.
5. Answer 2xx, and deduplicate
Section titled “5. Answer 2xx, and deduplicate”Any 2xx marks the delivery a success. Anything else is a failure, and a 3xx
is a failure too: we record the redirect and never follow it.
Answer fast. One attempt is cut off after 10 seconds. Do the work after you answer, not before.
Booking-Delivery is stable across every retry of the same event, so it is the
key to deduplicate on. Store it and ignore a delivery id you already handled.
The retry schedule
Section titled “The retry schedule”A delivery gets 5 attempts. The waits between them are 1 minute, 30 minutes, 2 hours and 6 hours.
If 3 deliveries exhaust their attempts in a row, we deactivate the endpoint. A success resets that count, so a flaky receiver recovers on its own and only a dead URL is deactivated.
Read the delivery history with
GET /v1/webhook-endpoints/{id}/deliveries. This list omits next_cursor
entirely on its last page. Treat an absent next_cursor as the end of the
list; do not wait for the field to be present and null.
The payload
Section titled “The payload”{ "id": "c8a4f1d6-3b97-4e25-8f10-6d2b9c7a5e34", "type": "booking.created", "created_at": "2026-08-30T14:05:12Z", "data": { "booking": { "id": "b41d9f2a-6c58-4a13-9d77-2e8f0c3b5a91", "status": "confirmed", "starts_at": "2026-09-01T09:00:00+01:00", "ends_at": "2026-09-01T09:45:00+01:00", "service": { "id": "3a7b1e90-2c4d-4f81-9b02-7e5c1a6d8f44", "name": "Cut and finish" }, "staff": { "id": "6f1c0b52-9a3e-4f77-8d21-0b5a2c9e4d10", "name": "Ada" }, "customer": { "id": "d2f4a6c8-1b3e-4d5f-8a70-9c1e2b4d6f80", "name": "Sam Okafor", "email": "sam@example.com", "phone": "+447700900123" }, "created_via": "sk", "rescheduled_from_id": null } }}id is the same value as the Booking-Delivery header.
created_via is sk or pk, and nothing else. It says which key type made
the booking: pk is a browser, and that is the customer booking themselves.
data.previous_starts_at is present on booking.rescheduled and on that event
only. It is absent from the other two, not null.
The payload carries no secret. There is no access token in it and no verification code. This body is stored, shown back to the business, and sent to a third-party server.