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. The rest of the page describes each event’s body, and how to send the customer messages yourself.
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", "booking.no_show", "payment.succeeded", "payment.refunded"] }'There are twelve event types:
| Event | When it fires |
|---|---|
booking.created |
A booking is confirmed: created confirmed, or a hold confirmed by its code, its payment or the business. |
booking.held |
A booking is created as a hold, waiting on a verification code or a deposit. Only a publishable key creates holds. |
booking.expired |
A hold lapses unconfirmed. |
booking.rescheduled |
A booking moves to a new time or calendar. |
booking.cancelled |
A booking is cancelled. |
booking.no_show |
The business marks a booking as a no-show. |
booking.reminder |
The customer’s reminder is due. |
booking.staff_reminder |
The staff reminder is due. |
payment.succeeded |
The charge for a booking succeeds. |
payment.refunded |
The refund total on a payment goes up. |
payment.disputed |
A dispute opens on the charge. |
payment.dispute_closed |
A dispute on the charge closes. |
An endpoint receives only the events in its events list. Subscribe to each
one you handle. To change the list, send the whole new list with
PATCH /v1/webhook-endpoints/{id}. The payment events only ever fire for a
business that takes payments through Keptslot.
booking.no_show fires when the business marks a booking as a no-show. It
does not fire when the mark is cleared; every event carries no_show_at, so
the next one you receive for that booking shows the current state.
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", "booking.no_show", "payment.succeeded", "payment.refunded"], "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 four headers
Section titled “3. Read the four headers”Every delivery is a POST with a JSON body and four 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. |
Booking-Workspace |
The workspace slug. One receiver serving several workspaces reads it to pick the signing secret before parsing the body. |
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.
Deliveries can arrive out of order
Section titled “Deliveries can arrive out of order”Each delivery has its own retries, so a later event can arrive first. A
booking.created whose first attempt failed can arrive after the
booking.cancelled for the same booking. Before you act on an event, compare
its created_at with the newest event you have handled for that booking, or
read the booking with GET /v1/bookings/{id}. Do one of the two before you
send the customer anything.
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. A deactivated endpoint receives no events until you
set active back to true with PATCH /v1/webhook-endpoints/{id}.
An outage longer than the schedule loses deliveries. To catch up, call
GET /v1/bookings?updated_since=... with the updated_at of the last booking
you processed; it lists everything that changed since, in pages.
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-30T15:05:12.483920117+01:00", "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", "extras": [ { "id": "9e2a7c40-1d6b-4b83-a5f2-3c8d0e1b7a64", "name": "Beard trim", "duration_minutes": 15, "price_cents": 1000 } ], "price_cents": 5500, "metadata": { "order_id": "ord_8812" }, "currency": "gbp", "manage_url": "https://book.keptslot.com/b/Xq2v9LrT0aW7mN4cJ8sK1pYdE5hG3fB6", "answers": [ { "label": "Anything we should know?", "value": "Sensitive scalp" } ], "no_show_at": null }, "message": "sent" }}id is the same value as the Booking-Delivery header. created_at is when
the event fired. Every time in the body is RFC 3339 in the workspace’s
timezone, and created_at and hold.expires_at can carry fractional seconds.
booking.extras lists the extras on the visit, in the order the business lists
them, each with its own minutes and price as booked. It is [] when there are
none. booking.service is the main service only. booking.price_cents is the
price of the whole visit, the service plus every extra, and null when none of
them has a price. booking.currency is the workspace’s currency, lower-case
ISO 4217.
booking.no_show_at is always present: null until the business marks the
booking as a no-show, then the time of the mark.
booking.metadata is the record sent with POST /v1/bookings, string keys
and values, on every event. It is {} when none was sent, never null.
booking.manage_url is the customer’s link to cancel or move the booking, the
same link our emails carry. It opens your page when the workspace has a
manage_url_template, and the Keptslot booking page when it does not. It is on
every event, and it is "" unless the booking is pending or confirmed.
booking.answers holds the customer’s answers to the booking form’s
questions, each as a label and a value, in the form’s order. It is []
when there are none, never null.
booking.cancelled_by is present on booking.cancelled and on that event
only. It is customer when the customer cancelled from their manage link, and
operator when the business cancelled, from the dashboard or with a key.
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 and data.previous_staff_id are present on
booking.rescheduled and on that event only. They are absent from the other
events, not null. They are the time and calendar the booking moved from. A
booking keeps its id across a move, so the booking.rescheduled event
carries the same booking.id as the booking.created before it.
What the body carries
Section titled “What the body carries”The body carries the customer’s manage URL, which holds their access token,
and on booking.held it carries the verification code. It never carries an
API key or a signing secret. The body is stored, shown back to the business in
the dashboard, listed by GET /v1/webhook-endpoints/{id}/deliveries, and sent
to your server. A stored code stops working when its hold lapses.
The message field
Section titled “The message field”data.message says what became of the customer message that matches the
event: sent means we sent it, suppressed means the customer messages switch
stopped it and you send your own, and none means no message applies and
nobody sends one.
| Event | The message it reports on |
|---|---|
booking.created |
The confirmation |
booking.held |
The verification code, or on a deposit hold the payment pending message |
booking.rescheduled |
The reschedule notice |
booking.cancelled |
The cancellation notice |
booking.reminder |
The customer’s reminder |
booking.staff_reminder |
The staff reminder |
payment.refunded |
The refund notice |
The other five events, booking.no_show, booking.expired,
payment.succeeded, payment.disputed and payment.dispute_closed, match no
customer message and have no message key.
Send your own message when message is suppressed, and only then. When we
skip a message, the event says none. A booking created with a start time
that has already passed gets no confirmation, so its booking.created carries
message: none. The same goes for a move to a past time. payment.refunded
reports on the refund notice on the first refund of a payment, and carries
none on later increases of the total.
Reminders
Section titled “Reminders”booking.reminder fires when the customer’s reminder is due, which is
reminder_offset_minutes before the start. booking.staff_reminder fires when
the staff reminder is due, staff_reminder_offset_minutes before the start.
Each fires once per booking, and once more after each move, for the moved
booking’s own reminder. If the reminder fails on a passing error and is
retried, the event fires when the retry succeeds.
A reminder that finds the booking moved, cancelled or already started fires nothing. A booking made less than the offset before its start has no reminder and fires nothing.
Both events carry data.sent, the channels the reminder went out on, beside
message:
"message": "sent","sent": ["email", "sms"]sent is on these two events only. It lists email and sms in that order,
and it is [] when nothing went out, never null. message: sent with
sent: [] means we tried and nothing was delivered: the address bounced, the
number could not take a text, or the message could not be built. When a
reminder went out on any channel, message is sent, even if a setting
changed while a retry was pending.
On booking.staff_reminder, message is suppressed when
staff_reminders_enabled is false, and none when no member is linked to the
booking’s calendar, so there is nobody to remind. Both carry sent: [].
booking.held fires when a booking is created as a hold. Only a booking made
with a publishable key can be one. It waits on a deposit when its service
takes one, and otherwise on a code when require_booking_verification is
true. The event carries data.hold, in one of two shapes:
"hold": { "kind": "code", "expires_at": "2026-09-01T08:10:04.512807+01:00", "verification_code": "482915"}"hold": { "kind": "payment", "expires_at": "2026-09-01T08:20:04.512807+01:00", "amount_cents": 2500, "currency": "gbp"}A code hold waits on the 6-digit code and lasts ten minutes. A payment
hold waits on the deposit and lasts twenty. The keys that do not apply are
absent, not null. expires_at is the booking’s hold_expires_at.
booking.status is pending.
To confirm a code hold, send the code to POST /v1/bookings/{id}/verify. If
you send it from your server, use a secret key. A publishable key is limited
to 10 requests a minute for one caller address, and every request from your
server comes from the same address.
When the hold confirms, booking.created fires. A booking made with a secret
key is confirmed at once and never fires booking.held.
booking.expired fires once per hold that lapses unconfirmed, when the
booking’s status changes to expired. That happens at a sweep that runs every
minute, so up to about a minute after hold_expires_at. It happens at once
when a new booking or a move takes the lapsed hold’s slot, and at once on the
fifth wrong verification code.
Payments
Section titled “Payments”data.payment is present on the four payment events and on those only. It is
absent from the booking events, not null:
"payment": { "id": "7c2e9a41-5d3b-4f08-9e16-2a8b0c4d6f73", "status": "succeeded", "amount_cents": 4500, "amount_refunded_cents": 0, "currency": "cad", "refund_reason": null}amount_refunded_cents is the running total at the time of the event, not the
delta. payment.succeeded fires once, when the charge succeeds. It fires at
that moment even if the booking is never confirmed: a customer who pays after
their hold has lapsed is charged and refunded in full, so you receive a
payment.succeeded and then a payment.refunded for a booking that never sent
booking.created. Treat a payment event for an unknown booking as exactly
that.
payment.refunded fires once per increase of the refund total, whether the
refund came from Keptslot or from the business refunding in Stripe. A partial
refund followed by the rest is two events.
refund_reason says why Keptslot refunded the payment:
business_cancelled: the business cancelled the booking.not_honoured: the booking could not be kept. The payment landed after the hold had lapsed, or after the booking had ended.null: Keptslot has not refunded the payment. Either there is no refund, or the business refunded it in Stripe.
Disputes
Section titled “Disputes”payment.disputed fires once when a dispute opens on the charge.
payment.dispute_closed fires once when it closes. Both carry data.dispute
beside data.payment:
"dispute": { "id": "dp_1Q8rTf2Evea4D9eUx3Lm0aBc", "amount_cents": 4500, "currency": "cad", "reason": "fraudulent", "respond_by": "2026-09-12T19:59:59-04:00", "outcome": "lost"}id, reason and outcome are Stripe’s own values. respond_by is the
deadline for the business’s evidence, or null when Stripe gives none.
outcome is on payment.dispute_closed only, and is one of won, lost,
warning_closed and prevented.
A dispute that reaches us already closed fires only payment.dispute_closed,
so do not wait for the pair. A charge can carry more than one dispute, and the
same dispute’s event can arrive more than once under a new delivery id, so key
your records on data.dispute.id. data.payment.status stays succeeded
through a dispute, whatever its outcome.
Send the customer messages yourself
Section titled “Send the customer messages yourself”Set customer_messages to false with PATCH /v1/workspace, or turn off
Customer messages in the Advanced section of Developer → API keys,
and Keptslot sends the workspace’s customers nothing: no confirmation,
verification code, payment pending message, reminder, or reschedule,
cancellation or refund notice, by email or by SMS. Staff messages keep their
own settings, staff_alerts and staff_reminders_enabled.
The events fire for every workspace, with the switch on or off. With it off:
- Every customer message is yours to send. Send one for each event whose
messageissuppressed, usingbooking.manage_urlfor the customer’s link. - The verification code reaches you only in
booking.held. A code hold lasts ten minutes. If the first delivery fails, the retry comes a minute later, and the one after that comes thirty minutes later, when the hold has lapsed. An endpoint we deactivate, after 3 deliveries in a row exhaust their attempts, receives no codes, and no booking that waits on one can be confirmed. To take bookings with no code, setrequire_booking_verificationto false. - Subscribe your endpoint to the events you send from. An endpoint
receives only the events in its
eventslist:booking.heldfor the code, andbooking.created,booking.rescheduled,booking.cancelled,booking.reminderandpayment.refundedfor the rest.
While the switch is off, booking verification is on and no active endpoint
subscribes to booking.held, the dashboard shows a warning beside the switch.
The switch is read when each message is decided. A reminder is decided when it
is due, so turning the switch off stops reminders already scheduled. The other
messages are decided when the change they describe happens.
staff_reminders_enabled is read the same way as the reminder: turning it off
stops staff reminders already scheduled.