Skip to content
Keptslot
Start free

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.

Terminal window
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.

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.

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.

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.

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.

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.

{
"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.