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. The rest of the page describes each event’s body, and how to send the customer messages yourself.

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

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.

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.

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.

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.

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.

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

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.

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.

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.

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.

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.

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 message is suppressed, using booking.manage_url for 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, set require_booking_verification to false.
  • Subscribe your endpoint to the events you send from. An endpoint receives only the events in its events list: booking.held for the code, and booking.created, booking.rescheduled, booking.cancelled, booking.reminder and payment.refunded for 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.