This is the full developer documentation for Keptslot API # Keptslot API > REST over HTTPS for availability, bookings, calendars and webhooks. 59 endpoints, eight guides, and a downloadable OpenAPI description. REST over HTTPS. JSON in, JSON out, ordinary status codes, and one API key on every call. Nothing here is generated from a description of the API. The description is generated from this API. ## One call, start to finish [Section titled “One call, start to finish”](#one-call-start-to-finish) Ask what is free. The response is grouped by calendar, in the workspace’s own timezone, because the appointment happens at the business. ```bash curl -G https://api.keptslot.com/v1/availability \ -H "Authorization: Bearer sk_4bT1nQ7xLd9sVzK0pR2eYw8hC6jM3gF5uA1oS7iN0dE" \ --data-urlencode "service_id=4aef5183-5995-44a7-846e-cae58ff60369" \ --data-urlencode "from=2026-09-16T00:00:00-06:00" \ --data-urlencode "to=2026-09-17T00:00:00-06:00" ``` ```json { "data": [ { "staff_id": "4211e9e1-4df0-4859-956d-95d45faae1c9", "slots": [ { "start": "2026-09-16T09:00:00-06:00", "end": "2026-09-16T09:45:00-06:00" }, { "start": "2026-09-16T09:15:00-06:00", "end": "2026-09-16T10:00:00-06:00" } ] } ] } ``` Those two starts are 15 minutes apart and each one runs 45 minutes, so they overlap. The list is every start time a customer can choose, not a row of separate appointments. Post one of those `start` values back to `/v1/bookings` and the appointment exists. [The first-call guide](/docs/guides/first-call/) walks the whole thing in seven steps, from an empty workspace to a confirmed booking, including the 6-digit code some workspaces ask a customer for. ## The guides [Section titled “The guides”](#the-guides) * [Build with AI](/docs/build-with-ai/): paste the booking page guide into your AI coding tool, or point it at `llms.txt`. * [Get a key and make your first call](/docs/guides/first-call/): seven steps, from an empty workspace to a confirmed booking. * [Glossary](/docs/guides/glossary/): eight nouns, one meaning each. Read this one first: `staff` means a bookable **calendar**, not a person with a login. * [Keys](/docs/guides/keys/): account against workspace, publishable against secret, and what the origin check does not do. * [Availability and booking, end to end](/docs/guides/booking-flow/): the browser path and the server path, the pending hold, and moving a booking that already exists. * [Build a booking page](/docs/guides/booking-page/): a booking form on a site you build, from setup to taking a deposit. * [Receive and verify a webhook](/docs/guides/webhooks/): the `Booking-Signature` header in full, and how to answer without processing an event twice. * [Errors](/docs/guides/errors/): every error type, and the one whose body changes shape with the key you used. * [Rate limits and quotas](/docs/guides/limits/): a token bucket that refills continuously, the two headers a 429 carries, and why a refused request costs you nothing. ## The reference [Section titled “The reference”](#the-reference) 59 endpoints, in nine groups. * [Availability](/docs/api/operations/tags/availability/): 1 endpoint * [Bookings](/docs/api/operations/tags/bookings/): 8 endpoints * [Staff](/docs/api/operations/tags/staff/): 11 endpoints * [Services](/docs/api/operations/tags/services/): 14 endpoints * [Questions](/docs/api/operations/tags/questions/): 7 endpoints * [Webhooks](/docs/api/operations/tags/webhooks/): 6 endpoints * [Customer links](/docs/api/operations/tags/customer-links/): 5 endpoints * [Account](/docs/api/operations/tags/account/): 4 endpoints * [Workspace](/docs/api/operations/tags/workspace/): 3 endpoints ## Take the description with you [Section titled “Take the description with you”](#take-the-description-with-you) The whole API as one OpenAPI 3.1 file. Import it into Postman or Insomnia, or generate a client from it. [Download openapi.yaml](/openapi.yaml) # Build with AI > Give an AI coding tool the docs instead of letting it guess the API. A prompt to paste, and plain-text docs for tools that read the web. An AI coding tool writes a better integration when it reads these docs first. There are two ways to give them to it. ## Paste a prompt [Section titled “Paste a prompt”](#paste-a-prompt) Most integrations are a booking form on a website. This prompt points your tool at that guide and the API description, and lists the mistakes that cost a booking. ## Point a tool at the docs [Section titled “Point a tool at the docs”](#point-a-tool-at-the-docs) Every docs page has a plain Markdown copy: add `.md` to its address. The booking page guide is at [`/docs/guides/booking-page.md`](/docs/guides/booking-page.md). These files cover everything else: * [`llms.txt`](/llms.txt) The starting point. It describes the API and links to the other files. * [`llms-full.txt`](/llms-full.txt) The overview and every guide in one file. * [`llms-small.txt`](/llms-small.txt) The same, shortened for a smaller context window. * [`openapi.yaml`](/openapi.yaml) Every endpoint with its request and response shapes. The endpoint reference pages are built from it and have no Markdown copy. # Availability and booking, end to end > How a slot becomes an appointment. The browser path, the server path, the pending hold, and moving a booking that already exists. There are two ways to reach the same booking, and which one you are building decides almost everything else. ## The two paths [Section titled “The two paths”](#the-two-paths) **From your server**, with a secret key, you read availability and create the booking directly. Nothing else is involved. This is the shape of an integration: a CRM that books on a customer’s behalf, an internal tool, a migration script. **From a browser**, with a publishable key, you are building something customer-facing: a booking form on a website. [Build a booking page](/docs/guides/booking-page/) covers that case from setup to deposit, and this page describes the calls both paths share. The endpoints are identical. Only the credential and the origin differ. One thing is not the same: **a booking made with a secret key never takes a deposit**, whatever the service charges. It is confirmed straight away. ## The browser path starts with a slug [Section titled “The browser path starts with a slug”](#the-browser-path-starts-with-a-slug) `GET /v1/public/workspaces/{slug}` takes no key. It returns, in one response, everything a booking page needs to render its first screen: the workspace and its timezone, the bookable services, the calendars, and the custom questions the form asks. It also returns the workspace’s `publishable_key`. It works from your site once your site’s origin is on that key’s allowed list; see [the keys guide](/docs/guides/keys/). One field on that response deserves a warning. `require_verification` tells you that a 6-digit code is *likely* to follow, and it is useful for warning the customer in advance. **It is not how you decide what screen comes next.** A business can change the setting between the bootstrap call and the booking. The `status` on the create response is the authority; see below. The response also carries `online_bookings_paused`. While it is `true`, the workspace is not taking online bookings: availability, creating a booking and rescheduling answer `409` with the type `bookings_paused`, and cancelling still works. Check it before you show times. The [errors guide](/docs/guides/errors/#bookings_paused) lists the calls. ## Reading availability [Section titled “Reading availability”](#reading-availability) `GET /v1/availability` takes a service, a `from` and a `to`. The range must be 62 days or less. The response groups slots by calendar, because “who is free” is a real question for a business with more than one chair. If your interface does not let the customer pick a person, flatten the groups and pick one for them. You can also omit `staff_id` on the create call and let the API choose. Two things about availability are easy to get wrong: **Every timestamp is in the workspace’s timezone**, not the caller’s and not UTC. The appointment happens at the business. A customer in another country sees their 9am, and that is a display concern for your client, not a thing to convert on the way in. **Availability is advisory, not a reservation.** It reflects the moment you asked. Two customers looking at the same screen see the same slot, and the first one to `POST` gets it. The second gets a `409` with `slot_unavailable`, and that is a normal outcome to design for, not an error state. A `pending` hold does count as busy while it lives, so it does not show up as an open slot. The gap that produces a `409` is simply the time between your read and your write. ## Creating the booking [Section titled “Creating the booking”](#creating-the-booking) `POST /v1/bookings` takes a service, a start time and a customer. `staff_id` is optional. Omit it and the API picks a calendar that is free at that time: the one whose visit is cheapest, then the one with the fewest bookings on that date, then one at random. Send an `Idempotency-Key`. A booking is the one thing in this API you must not accidentally do twice, and a timed-out request that actually succeeded is the ordinary way that happens. See the [rate limits and quotas guide](/docs/guides/limits/). The response carries two fields that exist nowhere else. **`access_token`** is the customer’s manage link. It appears on this response and on no later one. If you do not store it here, the customer’s cancel and reschedule links are gone. They still exist in the email we send, but you cannot rebuild them. **`status`** is `confirmed` or `pending`, and it is how you decide what happens next. Not `require_verification`, not a setting you cached at bootstrap: this field, on this response. A `pending` booking with a `payment` object is waiting for a deposit, not a code. [Build a booking page](/docs/guides/booking-page/) shows how to take it. ## The publishable key also needs a Turnstile token [Section titled “The publishable key also needs a Turnstile token”](#the-publishable-key-also-needs-a-turnstile-token) A booking created with the publishable key must carry a Cloudflare Turnstile token, sent as the `X-Turnstile-Token` header. If the API cannot verify one, `POST /v1/bookings` answers `403` with `turnstile_required`. It is the same code whether the token is missing, invalid, solved on the wrong hostname, or Cloudflare itself is unreachable. The response does not distinguish those cases, deliberately, so do not build a client that tries to. A secret key is unaffected: a booking created from your own server with `sk_` needs no token at all. This API does not issue Turnstile site keys to integrators, so a form you build cannot produce a token. For it, the workspace turns on **“Custom booking frontend”** in the dashboard. That turns the requirement off for the whole workspace, so anyone holding that workspace’s booking link can create bookings by script. The rate limits still apply, and so does a daily limit: each caller can only make so many bookings a day, and going over that answers `daily_booking_limit` (see the [rate limits guide](/docs/guides/limits/)). Your own form hits both too. A booking created from a server with a secret key needs no token, and takes no deposit. ## The pending hold [Section titled “The pending hold”](#the-pending-hold) A `pending` booking occupies the slot. With no `payment` object, the customer has been emailed a 6-digit code and has not entered it yet. Send the code to `POST /v1/bookings/{id}/verify`. On success the booking becomes `confirmed`. Every failure of that call returns the same error, `verification_failed`, for a wrong code, an expired hold, too many attempts, and a booking id that does not exist. They are collapsed on purpose. Six digits is a small space, and a response that distinguished “wrong code” from “no such booking” would be an oracle for guessing it. A hold that nobody verifies lapses on its own and frees the slot. Your client does not have to clean up after an abandoned form. ## Moving a booking that already exists [Section titled “Moving a booking that already exists”](#moving-a-booking-that-already-exists) `POST /v1/bookings/{id}/reschedule` moves a booking to a new time. To show the customer what they can move *to*, call availability with `exclude_booking_id` set to the booking being moved. Without it, the booking’s own slot reads as busy, and the one time the customer is most likely to want (half an hour later, same day) is missing from the list for no visible reason. Send `exclude_booking_id` with a value or leave it out entirely. An empty value is refused with a `422`; empty and absent are different things here. ## The customer’s own view [Section titled “The customer’s own view”](#the-customers-own-view) The token in `access_token` addresses `/v1/public/bookings/{token}`, which is the manage link. It returns the booking, the business, and two booleans: `can_cancel` and `can_reschedule`. Use those to enable buttons. Each one is true exactly when that call would succeed, and they are not copies of each other. A pending booking inside the notice window can be cancelled but not moved. **`can_reschedule` does not account for a pause.** The same response carries `online_bookings_paused`. While that is `true`, rescheduling answers `409` `bookings_paused` even when `can_reschedule` is `true`, so offer Reschedule only when `can_reschedule` is true and `online_bookings_paused` is false. Cancelling is not affected. An unknown token, an expired token and the token of an already-cancelled booking all return the same `404`. Cancelling clears the token, so a cancelled booking resolves to no row, exactly like a token that never existed. ## What you hear about afterwards [Section titled “What you hear about afterwards”](#what-you-hear-about-afterwards) Your server sometimes needs to know about bookings it did not make. The customer cancelled from their email, or an operator moved it in the dashboard. That is what [webhooks](/docs/guides/webhooks/) are for. When a delivery names a booking, `GET /v1/bookings/{id}` returns it. If your receiver was down, `GET /v1/bookings?updated_since=...` lists every booking changed at or after that time, in pages. Every booking carries `updated_at`; keep the latest one you processed and send it back as `updated_since` to resume. # Build a booking page > A booking form on a site you build, from setup to taking a deposit. This guide builds a booking form into a website: the customer picks a service and a time, books, and pays a deposit if the service takes one. Everything runs in the customer’s browser. It uses the publishable key. For bookings made from your own server, such as a CRM or an internal tool, see [the server path](/docs/guides/booking-flow/). ## Set up the workspace first [Section titled “Set up the workspace first”](#set-up-the-workspace-first) Three settings in the dashboard, all under **Developer → API keys**. **1. Add your site to the Booking page key.** Every workspace has a publishable key named **Booking page**. Choose **Edit allowed origins** in its menu and add every address your form runs on, for example `https://www.northsidebarbers.ca` and `http://localhost:4321` for development. The key itself stays the same. **2. Turn on “Custom booking frontend”**, in the **Advanced** section of the same page. Without it, every booking your form makes answers `403` with `turnstile_required`. When it is on, bookings made with this workspace’s publishable keys need no Turnstile token. The [rate limits and the daily booking limit](/docs/guides/limits/) still apply. **3. For deposits:** the workspace must be on Pro, with Stripe connected under **Payments**, and the service must have a deposit set. A deposit service on a workspace that cannot take payments is refused with `422` on `payment`. ## Read the key from the workspace response [Section titled “Read the key from the workspace response”](#read-the-key-from-the-workspace-response) `GET /v1/public/workspaces/{slug}` takes no key. Send none: with one, the browser refuses the request. Its response carries the Booking page key from step 1 as `workspace.publishable_key`. Send that key on every other call: ```js const API = 'https://api.keptslot.com' const { workspace, services, staff, questions } = await ( await fetch(`${API}/v1/public/workspaces/your-slug`) ).json() const auth = { Authorization: `Bearer ${workspace.publishable_key}` } ``` The same response carries everything else the form needs: services, calendars, the questions the form must ask, and the business’s refund policy. If `workspace.online_bookings_paused` is `true`, the business is not taking online bookings. Availability, creating a booking and rescheduling answer `409` with `bookings_paused`. Show that to the customer instead of the form. If a call answers `403` with `origin_not_allowed`, the address your form runs on is not on the key’s list. If the Booking page key was revoked, the response carries the oldest publishable key still active instead; add your site’s address to that one. ## Extras [Section titled “Extras”](#extras) A service in the workspace response carries `standalone`, `extra_scope` and `extra_ids`. `extra_ids` lists the extras the customer can add to that service, and it is empty when there are none. List the services with `standalone: true` as the choices. A service with `standalone: false` is only ever an extra, and the response includes it so that you can show its name. After the customer picks a service, show its extras. Pass the ones they choose to the availability call, then to the quote call, then to the booking. ```js const extraIds = chosen.map((e) => e.id).join(',') const params = new URLSearchParams({ service_id: service.id, from, to }) if (extraIds) params.set('extra_ids', extraIds) const slots = await (await fetch(`${API}/v1/availability?${params}`, { headers: auth })).json() ``` Leave `extra_ids` out when nothing is chosen. An empty value answers `422`. `GET /v1/quote` takes `service_id` and `extra_ids` and returns the figures for the visit: ```js const quoteParams = new URLSearchParams({ service_id: service.id }) if (extraIds) quoteParams.set('extra_ids', extraIds) const quote = await (await fetch(`${API}/v1/quote?${quoteParams}`, { headers: auth })).json() // quote.duration_minutes, quote.price_cents, quote.payment_amount_cents ``` Call it each time the customer changes their extras. Show `price_cents` as the total and `payment_amount_cents` as the amount due now. `payment_amount_cents` is `null` when nothing is due at booking, and also when the workspace cannot take a payment. The service’s own deposit does not describe a visit with extras. When the customer is moving an existing booking, pass `exclude_booking_id` to availability and nothing else. The length and the extras come from the booking. Sending `extra_ids` with it answers `422`. ## Prices by calendar [Section titled “Prices by calendar”](#prices-by-calendar) A calendar can charge its own price for a service. Each calendar in the workspace response carries `prices`, a list of `{ service_id, price_cents }` for the services it charges its own price for. A service it performs that is not in the list costs the service’s `price_cents`. The list is empty when the calendar has no prices of its own. `GET /v1/quote` also takes `staff_ids`, as comma-separated ids. Send the calendar the customer chose to get that calendar’s figures: ```js const quoteParams = new URLSearchParams({ service_id: service.id }) if (extraIds) quoteParams.set('extra_ids', extraIds) if (chosenStaff) quoteParams.set('staff_ids', chosenStaff.id) const quote = await (await fetch(`${API}/v1/quote?${quoteParams}`, { headers: auth })).json() // quote.from, quote.price_cents, quote.payment_amount_cents ``` Each id must be a calendar that performs the service and every chosen extra, or the call answers `422` on `staff_ids`. Leave `staff_ids` out to price the visit across every calendar that performs it. When the calendars priced do not all charge the same, `from` is `true`, and `price_cents` and `payment_amount_cents` are the cheapest calendar’s. Show them as a starting price, for example “from $80”. The response’s own `staff_ids` lists every calendar that performs the visit, whatever you sent. A booking with no `staff_id` goes to the cheapest of the calendars open at that time. Among calendars at the same price, it goes to the one with the fewest bookings on that date, then to one at random. The booking’s `price_cents` and any deposit are that calendar’s. ## Book [Section titled “Book”](#book) The calls are the same as in [the booking flow guide](/docs/guides/booking-flow/): read availability, then create the booking with an `Idempotency-Key`. Send the same `extra_ids` that you gave to availability and to the quote, as an array. ```js const res = await fetch(`${API}/v1/bookings`, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify({ service_id: service.id, extra_ids: chosen.map((e) => e.id), starts_at: slot.start, customer: { name, email, phone }, answers, }), }) const booking = await res.json() ``` Keep `booking.access_token`. No later response carries it, and cancelling and rescheduling need it. The response lists the booked `extras`, each with its `name`, `duration_minutes` and `price_cents`, and carries `price_cents` for the whole visit. On the manage read, `booking.duration_minutes` is the length that was booked, extras included. `booking.service.duration_minutes` is the service’s length on the menu today. What happens next depends on the response: * `status` is `confirmed`: the customer is booked. * `status` is `pending` and there is **no** `payment` object: the customer has been emailed a 6-digit code. Send it to `POST /v1/bookings/{id}/verify`. With customer messages off, nothing is emailed, and the code reaches your server in the [`booking.held` webhook](/docs/guides/webhooks/#holds). * `status` is `pending` and there **is** a `payment` object: take the deposit. A booking that takes a deposit never asks for a code. The payment takes its place. ## Take the deposit [Section titled “Take the deposit”](#take-the-deposit) The `payment` object has everything Stripe.js needs. `stripe_publishable_key` is a Stripe key, not your Keptslot `pk_` key, and `connected_account_id` is the business’s Stripe account. ```html
``` ```js const { payment } = booking const stripe = Stripe(payment.stripe_publishable_key, { stripeAccount: payment.connected_account_id }) const elements = stripe.elements({ clientSecret: payment.client_secret }) elements.create('payment').mount('#payment') document.getElementById('pay').onclick = async () => { const result = await stripe.confirmPayment({ elements, confirmParams: { return_url: `${location.origin}/booked?token=${booking.access_token}` }, redirect: 'if_required', }) if (result.error) { // Show result.error.message. The slot stays held, and the customer can try again. return } // result.paymentIntent.status is 'succeeded': show the confirmation. } ``` **The booking is confirmed by Keptslot, not by your page.** Stripe tells us when the payment succeeds, and we confirm the booking, usually within a second or two. Your page can show the confirmation as soon as `confirmPayment` reports `succeeded`. **Some payments leave the page.** A bank that asks the customer to approve the payment in its own app, and some payment methods, send the customer away and back to your `return_url`. Stripe adds `redirect_status` to it. When the customer arrives there, read the booking **once**: ```js const params = new URLSearchParams(location.search) const res = await fetch(`${API}/v1/public/bookings/${params.get('token')}`) const { booking } = await res.json() // booking.status and booking.payment.status say where things stand. ``` Only `booking.status` of `confirmed` means the customer is booked. If Stripe has taken the payment and we have not confirmed the booking yet, `booking.payment.status` is already `succeeded` while `booking.status` is still `pending`, usually for a second or two. Do not call it in a loop. **The slot is held while the customer pays, and not forever.** Show `hold_expires_at` from the booking response as a deadline, not a countdown you compute yourself. If a payment completes after the hold has ended, the booking is not confirmed and the deposit is refunded automatically. So when `confirmPayment` succeeds close to that time, read the booking once before you tell the customer they are booked. A booking whose hold has ended answers `404`. **A refresh does not lose the payment.** While the deposit is still owed, `GET /v1/public/bookings/{token}` returns a fresh `client_secret`, `connected_account_id` and `stripe_publishable_key` in its `payment` object. Mount the form again with them. ## Cancelling and rescheduling [Section titled “Cancelling and rescheduling”](#cancelling-and-rescheduling) The token addresses the booking’s public routes: * `GET /v1/public/bookings/{token}` returns the booking, with `can_cancel` and `can_reschedule`. * `POST /v1/public/bookings/{token}/cancel` and `POST /v1/public/bookings/{token}/reschedule` do what they say. `can_reschedule` does not account for a pause. While `online_bookings_paused` on the same response is `true`, rescheduling answers `409` `bookings_paused`, so offer Reschedule only when `can_reschedule` is true and the pause is off. Cancelling still works. A cancel after the business’s cancellation cutoff is refused with `too_late_to_cancel`, and `can_cancel` says so in advance. A customer who cancels does not get the deposit back automatically. The business’s refund policy decides that, and `refund_policy` on the workspace response is its wording, to show as written. After a cancel, the token answers `404`. The emails we send the customer carry a manage link, and so does the calendar file for the booking. By default the link opens the business’s Keptslot booking page. To send customers to your page instead, set **Manage link address** in the **Advanced** section of **Developer → API keys**. Write the full address with `{token}` where the booking’s token goes, for example `https://www.northsidebarbers.ca/manage/{token}`. The address must start with `https://` and contain `{token}` once, outside the domain. Leave it empty to send customers to the Keptslot page again. Your page gets the token in that address and does the rest with the routes above. [The customer’s own view](/docs/guides/booking-flow/#the-customers-own-view) covers what they return. ## Bookings from a server take no payment [Section titled “Bookings from a server take no payment”](#bookings-from-a-server-take-no-payment) A booking created with a secret key never takes a deposit, whatever the service charges. It is confirmed straight away, with no hold. # Errors > The error envelope, every error type the API returns, and why a 402 body changes shape with the key you used. Every error uses one envelope. The status code says what class of problem it is. The `type` field says which problem it is, and `type` is the field to branch on. Do not match on `message`. That text is written for a person, and it changes. ```json { "error": { "type": "validation_failed", "message": "The request was not valid.", "param": "starts_at" } } ``` `param` is present only on a `422` that names one field. Every other key in the body is described under [402](#402--a-plan-limit-refused-the-request) below. ## The error types [Section titled “The error types”](#the-error-types) `type` is stable. A new type can appear, so treat an unknown `type` as the class its status code names. | Status | `type` | What happened | | ------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | 401 | `unauthorized` | The key is missing, or it is not valid. | | 402 | `plan_limit_exceeded` | A plan cap stopped the request. | | 403 | `secret_key_required` | The route needs a secret key. You sent a publishable key. | | 403 | `origin_not_allowed` | The `Origin` header is not on the allowed list of this publishable key. | | 403 | `turnstile_required` | The publishable-key request did not carry a Turnstile token the API could verify. | | 403 | `forbidden` | The credential is valid. The action is not permitted. | | 404 | `not_found` | No such record, or the record is not on this workspace. | | 409 | `slot_unavailable` | Somebody took that time first. | | 409 | `bookings_paused` | The workspace has paused online bookings. See [`bookings_paused`](#bookings_paused) below. | | 409 | `conflict` | That resource already exists. | | 409 | `booking_not_pending` | The booking is not awaiting confirmation. | | 409 | `no_show_not_applicable` | Only a past confirmed booking can be marked as a no-show. | | 422 | `validation_failed` | The request is malformed. `param` names the field. | | 422 | `verification_failed` | The 6-digit code was refused. | | 422 | `too_late_to_cancel` | The cancellation window is closed. | | 422 | `outside_working_hours` | The time is outside the working hours of that calendar. | | 422 | `idempotency_mismatch` | The `Idempotency-Key` was used before, with a different body. | | 429 | `rate_limited` | Too many requests. See the [rate limits guide](/docs/guides/limits/). | | 429 | `daily_booking_limit` | This caller has written its allowance of bookings at this workspace today. See the [rate limits guide](/docs/guides/limits/). | | 429 | `booking_volume_refused` | The account’s platform-safety volume ceiling was met. Retrying does not help. See the [rate limits guide](/docs/guides/limits/). | | 500 | `internal_error` | A fault on our side. | | 503 | `transient_failure` | Two writes contended. Retry the request. | Retry a `503`. Retry a `429` typed `rate_limited` or `daily_booking_limit` after the `Retry-After` it carries. The [rate limits guide](/docs/guides/limits/) has the detail. **Do not retry a `429` typed `booking_volume_refused`**: it does not clear until the account’s billing period rolls over. Do not retry any other `4xx`: the same request gets the same answer. ## 402: a plan limit refused the request [Section titled “402: a plan limit refused the request”](#402-a-plan-limit-refused-the-request) A `402` means a plan cap stopped the request. **The fields in the body depend on the key you used.** | Key | The body contains | | ------------------- | ----------------------------------------------- | | Publishable (`pk_`) | `type`, `message` | | Secret (`sk_`) | `type`, `message`, `dimension` | | Account (`ak_`) | `type`, `message`, `dimension`, `limit`, `used` | This is deliberate. A publishable key runs in a browser, where a stranger can read the response. Such a caller must not learn the plan state of the business. A secret key gets the dimension and no numbers. Caps are counted at the **account**, and a secret key reaches one workspace. The numbers tell a key issued for one workspace about its siblings. `dimension` is one of two wire values: | Value | The cap | | ------------ | -------------------------------------------------------- | | `staff` | Active calendars. The dashboard calls these “calendars”. | | `workspaces` | Active workspaces on the account. | ### A 402 with no `dimension` [Section titled “A 402 with no dimension”](#a-402-with-no-dimension) `dimension` can be absent, and `used` and `limit` with it. A `402` with no `dimension` names no cap, so there is nothing to count against and no number to show. Show the message and stop. ```json { "error": { "type": "plan_limit_exceeded", "message": "This plan limit has been reached.", "dimension": "staff", "limit": 5, "used": 5 } } ``` ## `bookings_paused` [Section titled “bookings\_paused”](#bookings_paused) A `409`. The workspace has paused online bookings, and `online_bookings_paused` is `true` on its workspace settings. These calls answer it, for publishable and secret keys alike: * `GET /v1/availability` * `POST /v1/bookings` * `POST /v1/bookings/{id}/reschedule` * `POST /v1/public/bookings/{token}/reschedule` Cancelling a booking still works, through `/v1` and through the manage link. So do verifying and confirming a booking that already exists, `GET /v1/quote`, listing bookings, and the two public `GET` calls for the workspace and a booking. The dashboard is not affected. Availability answers `bookings_paused` instead of an empty list, so a paused workspace is not mistaken for one with no free times. Do not retry: the same request gets the same answer until the workspace turns the pause off. Read `online_bookings_paused` on `GET /v1/public/workspaces/{slug}` to know before you call. ## Two 403 types that read alike [Section titled “Two 403 types that read alike”](#two-403-types-that-read-alike) `secret_key_required` and `origin_not_allowed` both come from a publishable key, and they need different fixes. `secret_key_required` means the route is a server route. Send a secret key from your server. `origin_not_allowed` means the browser sent an `Origin` header that this key does not list. Add the origin to the key, or use the hosted booking page. CAUTION: The origin list is not a security boundary. `GET /v1/public/workspaces/{slug}` gives the publishable key to anybody who holds the slug, by design. The list stops a third party from embedding your key in their own site. It does not stop a script. ## `turnstile_required` [Section titled “turnstile\_required”](#turnstile_required) Also a publishable-key `403`, and also collapsed on purpose: it covers a missing token, an invalid one, a token solved on the wrong hostname, and Cloudflare being unreachable, with nothing in the response to tell them apart. If you create bookings from your server with a secret key, this cannot reach you. If you create them from a browser frontend you built yourself, see the [booking flow guide](/docs/guides/booking-flow/) for the two ways to satisfy it. There is no site key of your own to configure here. ## Notes on two more [Section titled “Notes on two more”](#notes-on-two-more) A `404` also covers “this record belongs to another workspace”. We do not answer `403` there, because a `403` confirms that the record exists. `verification_failed` collapses four causes: a wrong code, an expired hold, too many attempts, and an unknown booking. They are not told apart on purpose. A response that told them apart is an oracle for a 6-digit code. # Get a key and make your first call > Create a secret key, give the workspace something to book, and make a booking end to end. This guide makes one booking with `curl`. The base URL is `https://api.keptslot.com`. Read the [glossary](/docs/guides/glossary/) first if you have not. One noun here does not mean what the dashboard calls it: `staff` is a bookable **calendar**, not a person with a login. ## What the workspace needs first [Section titled “What the workspace needs first”](#what-the-workspace-needs-first) The API can only offer a time when three things exist: * One calendar. * Weekly hours on that calendar. * One active service, assigned to that calendar. A new workspace has none of them. Step 3 tells you which case you are in. Step 4 builds all three. ## 1. Create a secret key [Section titled “1. Create a secret key”](#1-create-a-secret-key) 1. Open the dashboard and select your workspace. 2. Go to **Developer → API keys**. 3. Create a key of type **Secret**. CAUTION: The full key value is shown one time. Store it now. A later read gives `null`, and no support call can recover it. If you lose the key, revoke it and create another one. A secret key starts with `sk_`. Keep it on your server. Do not put it in a browser. For the difference between the key types, read the [keys guide](/docs/guides/keys/). The samples below read the key from your shell, so that it stays out of your command history: ```bash export KEPTSLOT_KEY="sk_your_secret_key_here" ``` ## 2. Make sure that the key works [Section titled “2. Make sure that the key works”](#2-make-sure-that-the-key-works) Every call sends the key as a bearer token. ```bash curl https://api.keptslot.com/v1/workspace \ -H "Authorization: Bearer $KEPTSLOT_KEY" ``` A `200` returns the workspace settings. This sample is cut short. The real response carries about thirty fields, and these are the ones that matter here: ```json { "slug": "northside-barbers", "name": "Northside Barbers", "timezone": "America/Edmonton", "currency": "CAD", "slot_interval_minutes": 15, "require_booking_verification": false } ``` If the response is a `401`, the key is wrong or revoked. If the response is a `403` with `secret_key_required`, you sent a publishable key. Note the `timezone`. Every time in this API is an RFC3339 timestamp, and the responses use the timezone of the workspace. The appointment happens at the business, so the offset is the business’s and not the customer’s. Note `slot_interval_minutes` too. It is the step between start times, not the length of an appointment. Step 5 shows what that does. ## 3. Ask what the workspace can book [Section titled “3. Ask what the workspace can book”](#3-ask-what-the-workspace-can-book) A booking is always for exactly one service. Get the service id first. ```bash curl https://api.keptslot.com/v1/services \ -H "Authorization: Bearer $KEPTSLOT_KEY" ``` ```json { "data": [ { "id": "4aef5183-5995-44a7-846e-cae58ff60369", "name": "Cut and finish", "duration_minutes": 45, "price_cents": 4000, "active": true } ] } ``` A service carries more fields than this, including the buffers, the lead time and the payment mode. **By default this endpoint shows only what a customer can book.** A deactivated service is not in this list, so every service here has `active: true`. To get the deactivated services too, send `include_inactive=true` and read `active`. `POST /v1/services/{id}/activate` makes a deactivated service bookable again. What you do next depends on the list: * The list has at least one service. Go to step 5 and use that `id`. * The list is `{"data": []}`. Do step 4 first. ## 4. Give the workspace something to book [Section titled “4. Give the workspace something to book”](#4-give-the-workspace-something-to-book) Do this step only if step 3 returned an empty list. It makes four calls. Keep the id that each one returns. **4a. Create a calendar.** ```bash curl -X POST https://api.keptslot.com/v1/staff \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Chair 1"}' ``` ```json { "id": "4211e9e1-4df0-4859-956d-95d45faae1c9", "name": "Chair 1", "active": true, "title": null, "bio": null } ``` **4b. Give the calendar weekly hours.** Without hours it offers no times. Each block is one weekday and two clock times. `weekday` is 0 for Sunday through 6 for Saturday. The two times are minutes from midnight, so 09:00 is `540` and 17:00 is `1020`. ```bash curl -X PUT https://api.keptslot.com/v1/staff/4211e9e1-4df0-4859-956d-95d45faae1c9/working-hours \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{ "working_hours": [ { "weekday": 1, "start_minute": 540, "end_minute": 1020 }, { "weekday": 2, "start_minute": 540, "end_minute": 1020 }, { "weekday": 3, "start_minute": 540, "end_minute": 1020 }, { "weekday": 4, "start_minute": 540, "end_minute": 1020 }, { "weekday": 5, "start_minute": 540, "end_minute": 1020 } ] }' ``` The response echoes what you sent. CAUTION: This call replaces the whole week. A block that you leave out is erased. Send every block that you want to keep, every time. **4c. Create a service.** ```bash curl -X POST https://api.keptslot.com/v1/services \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Cut and finish","duration_minutes":45,"price_cents":4000}' ``` The response carries the new `id`, and a default for every field that you did not send: no buffers, no lead time, 60 days of advance booking, and `payment_mode: "none"`. **4d. Assign the service to the calendar.** A service with no calendar offers no times, the same way a calendar with no hours does. ```bash curl -X PUT https://api.keptslot.com/v1/services/4aef5183-5995-44a7-846e-cae58ff60369/staff \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{"staff_ids":["4211e9e1-4df0-4859-956d-95d45faae1c9"]}' ``` This call also replaces rather than adds. Send every calendar that performs this service. ## 5. Read the open slots [Section titled “5. Read the open slots”](#5-read-the-open-slots) Ask for one service and one date range. The range must be 62 days or less. A longer range answers `422` with `validation_failed` on `to`. ```bash curl -G https://api.keptslot.com/v1/availability \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ --data-urlencode "service_id=4aef5183-5995-44a7-846e-cae58ff60369" \ --data-urlencode "from=2026-09-16T00:00:00-06:00" \ --data-urlencode "to=2026-09-19T00:00:00-06:00" ``` The slots are grouped by calendar: ```json { "data": [ { "staff_id": "4211e9e1-4df0-4859-956d-95d45faae1c9", "slots": [ { "start": "2026-09-16T09:00:00-06:00", "end": "2026-09-16T09:45:00-06:00" }, { "start": "2026-09-16T09:15:00-06:00", "end": "2026-09-16T10:00:00-06:00" }, { "start": "2026-09-16T09:30:00-06:00", "end": "2026-09-16T10:15:00-06:00" } ] } ] } ``` **These slots overlap, and that is correct.** Each `end` is its `start` plus the duration of the service, which is 45 minutes here. The starts are 15 minutes apart, because `slot_interval_minutes` is 15. So the list is every start time that the customer can choose. It is not a row of separate appointments. One booking removes the others that overlap it. An empty `data` array means that the range has no open slots. It is not an error. If you have just done step 4 and the array is still empty, the range holds no working day. The sample hours above are Monday to Friday. ## 6. Create the booking [Section titled “6. Create the booking”](#6-create-the-booking) Send one `start` value from step 5. If you leave out `staff_id`, the API picks an open calendar for you. ```bash curl -X POST https://api.keptslot.com/v1/bookings \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \ -d '{ "service_id": "4aef5183-5995-44a7-846e-cae58ff60369", "starts_at": "2026-09-16T09:00:00-06:00", "customer": { "name": "Sam Okafor", "email": "sam@example.com", "phone": "+14035550123" } }' ``` ```json { "id": "0182ed3e-a328-448e-9490-4309e1e03394", "staff_id": "4211e9e1-4df0-4859-956d-95d45faae1c9", "service_id": "4aef5183-5995-44a7-846e-cae58ff60369", "customer_id": "a6b5ee04-dfd6-4d02-8a2d-67925c0d1923", "starts_at": "2026-09-16T09:00:00-06:00", "ends_at": "2026-09-16T09:45:00-06:00", "status": "confirmed", "access_token": "Iuch7AB1mUlgHlGdRTy-sdny2OJVowbVFl8hAM2eS8U" } ``` Two fields decide what happens next. `access_token` is on this response and on no other. It is the customer’s manage link. Store it now. `status` is `confirmed` or `pending`. If the status is `confirmed`, you are done. If the status is `pending`, go to step 7. Send an `Idempotency-Key` on every create, as the sample does. A retry with the same key and the same body returns the stored response instead of a second booking. Read the [rate limits guide](/docs/guides/limits/) for what a repeat returns. ## 7. Verify a pending booking [Section titled “7. Verify a pending booking”](#7-verify-a-pending-booking) A `pending` booking holds the slot and is not confirmed. The customer gets a 6-digit code by email. Send that code back. ```bash curl -X POST https://api.keptslot.com/v1/bookings/0182ed3e-a328-448e-9490-4309e1e03394/verify \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{ "code": "482915" }' ``` ```json { "status": "confirmed" } ``` Every refusal here returns the same error. A wrong code, an expired hold, too many attempts and an unknown booking are not told apart. The response is deliberately not an oracle for a 6-digit code. Decide this step from the `status` on the create response. Do not decide it from `require_booking_verification` on the workspace response. A business can change that setting at any time. ## Optional: fill in the booking page [Section titled “Optional: fill in the booking page”](#optional-fill-in-the-booking-page) The steps above are enough to take a booking. The calls below set up the rest of what a customer sees on the booking page, except photos. Photos stay in the dashboard. ### A calendar’s title and bio [Section titled “A calendar’s title and bio”](#a-calendars-title-and-bio) The booking page shows a title and a short bio with the calendar’s name. ```bash curl -X PATCH https://api.keptslot.com/v1/staff/4211e9e1-4df0-4859-956d-95d45faae1c9 \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{"title":"Senior stylist","bio":"Twelve years of cutting and colour."}' ``` ```json { "id": "4211e9e1-4df0-4859-956d-95d45faae1c9", "name": "Chair 1", "active": true, "title": "Senior stylist", "bio": "Twelve years of cutting and colour." } ``` Every field is optional. A field that you leave out keeps its value. Send `""` to clear `title` or `bio`. `POST /v1/staff` also accepts `title` and `bio`. ### Service categories [Section titled “Service categories”](#service-categories) A category is a heading on the booking page’s list of services. Create one: ```bash curl -X POST https://api.keptslot.com/v1/service-categories \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Hair"}' ``` ```json { "id": "83ad1bfe-8111-4f84-9322-86c39fa6f596", "name": "Hair", "position": 0 } ``` Then put a service in it: ```bash curl -X PUT https://api.keptslot.com/v1/services/4aef5183-5995-44a7-846e-cae58ff60369/category \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{"category_id":"83ad1bfe-8111-4f84-9322-86c39fa6f596"}' ``` The response is the service, with the new `category_id`. Send `{"category_id":null}` to take the service out of its category. `PUT /v1/services/{id}` does not change the category. `POST /v1/services` accepts `category_id`. `PUT /v1/service-categories/order` sets the order. Send every category of the workspace, each one time. A list with a category missing is rejected with `422`. When you delete a category, its services stay and have no category. ### Booking form questions [Section titled “Booking form questions”](#booking-form-questions) A question asks the customer for more than a name and contact details. Create one: ```bash curl -X POST https://api.keptslot.com/v1/questions \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{ "label": "Hair length", "type": "single_select", "options": ["Short", "Medium", "Long"], "required": true, "applies_to": "selected_services" }' ``` ```json { "id": "ec303d23-3064-4fa3-8cd4-77770a2b0eb0", "label": "Hair length", "help_text": null, "type": "single_select", "options": ["Short", "Medium", "Long"], "required": true, "applies_to": "selected_services", "service_ids": [], "position": 0, "active": true } ``` With `applies_to: "selected_services"`, the question is asked only for the services that you set next. With `"all_services"`, it is asked for every service. ```bash curl -X PUT https://api.keptslot.com/v1/questions/ec303d23-3064-4fa3-8cd4-77770a2b0eb0/services \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{"service_ids":["4aef5183-5995-44a7-846e-cae58ff60369"]}' ``` ```json { "service_ids": ["4aef5183-5995-44a7-846e-cae58ff60369"] } ``` This call replaces the whole set. A service id that is not in this workspace is dropped, and the call still returns `200`. The response lists only what was saved. Four more rules: * `PUT /v1/questions/{id}` replaces the whole question. Send every field. A `required` that you leave out becomes `false`. * `PUT /v1/questions/order` accepts a partial list, but a question that you do not list keeps its position number, and a tie sorts by creation order. To get an exact order, send every question. * A workspace has at most 20 active questions. Archive one with `POST /v1/questions/{id}/archive` to make room. `POST /v1/questions/{id}/restore` is rejected with `422` when 20 are already active. * Answers that customers already gave keep the label and the answer from when they answered. Editing or archiving the question does not change them. ### Extras [Section titled “Extras”](#extras) An extra is a service that a customer adds to another service in the same visit. It is an ordinary service with two more fields: * `standalone` says whether the service can be booked on its own. Left out, it is `true`. * `extra_scope` says which services it can be added to: `none`, `all` or `selected`. `all` means every other active service that can be booked on its own. `selected` means only the services in `offered_with_ids`. Left out, it is `none`. A beard trim that can be booked alone and added to any service: ```bash curl -X POST https://api.keptslot.com/v1/services \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Beard trim","duration_minutes":15,"price_cents":1500, "standalone":true,"extra_scope":"all"}' ``` A gloss treatment that exists only as an extra to a colour service: ```bash curl -X POST https://api.keptslot.com/v1/services \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Gloss treatment","duration_minutes":15,"price_cents":3000, "standalone":false,"extra_scope":"selected", "offered_with_ids":["b7d0c1f4-52a8-4c0e-9a13-6e41f2d85c70"]}' ``` `offered_with_ids` is required when `extra_scope` is `selected`, and it is ignored under `all` and `none`. Every service read returns it. A service that is not booked on its own must have `extra_scope` of `all` or `selected`, and only a service that is not booked on its own may take 0 minutes. `PUT /v1/services/{id}` replaces the whole service. A replace that leaves out `standalone` and `extra_scope` turns an extra back into a plain service. To book a visit with extras, send the ids to `GET /v1/availability` as `extra_ids=id1,id2`, and as an `extra_ids` array to `POST /v1/bookings`. Each slot is as long as the service plus its extras, and only calendars that perform all of them are listed. The booking response then carries `extras` and `price_cents`, the price of the whole visit. At most 10 extras go on a visit, each one time. ### A calendar’s own price [Section titled “A calendar’s own price”](#a-calendars-own-price) A calendar can charge its own price for a service. Bookings on that calendar are then priced at it instead of the service’s `price_cents`. Set it with `PUT /v1/services/{id}/staff/{staff_id}`: ```bash curl -X PUT https://api.keptslot.com/v1/services/4aef5183-5995-44a7-846e-cae58ff60369/staff/4211e9e1-4df0-4859-956d-95d45faae1c9 \ -H "Authorization: Bearer $KEPTSLOT_KEY" \ -H "Content-Type: application/json" \ -d '{"price_cents":5500}' ``` ```json { "id": "4211e9e1-4df0-4859-956d-95d45faae1c9", "name": "Chair 1", "active": true, "price_cents": 5500 } ``` Send `{"price_cents":null}` to clear it. The calendar then charges the service’s price. The calendar must already be assigned to the service, or the call answers `404`. A price is rejected with `422` on `price_cents` in four cases: * The service has no price. Set the service’s `price_cents` first. * The price is negative. * The price is above 2147483647. * The price is zero and the service’s `payment_mode` is `full` or `deposit_percent`. Two changes to the service reach these prices. A `PUT /v1/services/{id}` that clears `price_cents` clears every calendar’s price for that service. A `PUT /v1/services/{id}` that sets `payment_mode` to `full` or `deposit_percent` is rejected with `422` on `price_cents` while a calendar is priced at zero for that service. An extra is a service too, so a calendar can have its own price for an extra. A booking is priced for the calendar that gets it, the extras included. ### Services and calendars that already exist [Section titled “Services and calendars that already exist”](#services-and-calendars-that-already-exist) * `GET /v1/services/{id}/staff` lists every calendar assigned to a service, deactivated ones included with `active` false. Each entry carries `price_cents`, the calendar’s own price, which is `null` when the calendar charges the service’s price. To add or remove one calendar, send the ids in this list back to `PUT /v1/services/{id}/staff` with that change. Calendars that stay in the list keep their prices. A list that leaves out a deactivated calendar removes its assignment. * `PUT /v1/services/order` sets the order of the services. Send every service of the workspace, each one time, and include the deactivated ones: get the ids from `GET /v1/services?include_inactive=true`. A list with a service missing is rejected with `422`. * `POST /v1/staff/{id}/activate` makes a deactivated calendar take bookings again. It counts against the plan’s calendars, as a new calendar does, so at the limit it is rejected with `402` and `plan_limit_exceeded`. ## What to read next [Section titled “What to read next”](#what-to-read-next) * [Build a booking page](/docs/guides/booking-page/): the same flow from a browser on your own site. * [Receive and verify a webhook](/docs/guides/webhooks/): how to hear about a booking you did not make. * [Errors](/docs/guides/errors/): every error type, and the one error whose body changes with your key. # Glossary > One meaning per word. Read this before the endpoint reference. One noun in this API does not mean what the dashboard calls it. The API uses eight nouns. Each one has one meaning. Two of them collide with the words the dashboard puts on screen, and the collision is on purpose rather than an oversight, so it is written down here instead of being papered over. ## staff [Section titled “staff”](#staff) In the API, `staff` is a **bookable calendar**. It is not a person with a login. A calendar can belong to a person, and usually does. It does not have to. A room, a chair or a piece of equipment is also a calendar, because the thing being booked is the slot, not the human. A person who signs in to the dashboard is a **member**. A member can be linked to one calendar, or to none. The dashboard labels this “Calendars”. The API noun is `staff`: the route is `GET /v1/staff`, the plan dimension is `staff`, and neither changes. If you are mapping our vocabulary onto yours, map it here and once. ## account [Section titled “account”](#account) The billing entity. An account holds the plan, the payment method and every cap. It can own more than one workspace. Every cap in this product is counted at the account, not at the workspace. Two workspaces on one account share one booking allowance. ## workspace [Section titled “workspace”](#workspace) One business, or one location of one business. A workspace holds services, calendars, working hours, bookings and customers. It has a slug, and the slug is what a customer’s booking page is addressed by. An API key belongs to a workspace. An account key reaches all of them. ## service [Section titled “service”](#service) What the workspace sells: a thing with a name, a duration and a price. A booking is always for exactly one service. Which calendars can deliver which service is a per-service list. See `GET /v1/services/{id}/staff`. ## seat [Section titled “seat”](#seat) One active calendar, counted against the account’s plan. Deactivating a calendar frees its seat; deleting is not required, and `DELETE /v1/staff/{id}` deactivates rather than deletes. “Seat” is a billing word and never appears in a request or a response. The wire value for this cap is `staff`. See the [errors guide](/docs/guides/errors/). ## booking [Section titled “booking”](#booking) One appointment: a service, a calendar, a customer and a time range. A booking is `pending`, `confirmed` or `cancelled`. `pending` means the slot is held and the customer has not yet entered their 6-digit code. A pending hold occupies the slot, and it lapses on its own if nobody verifies it. ## customer [Section titled “customer”](#customer) The person the appointment is for. A customer is a record on the workspace, identified by email, and is created by the booking that first names them. A customer never signs in. The manage link in their email carries a token, and that token is the only credential on the `/v1/public` routes. ## calendar [Section titled “calendar”](#calendar) The same thing as `staff`. Listed here because the dashboard says “calendar” everywhere the API says `staff`, and one of the two words is going to be the one you searched for. # Keys > Three credentials, two scopes, and what each one can actually reach. There are three kinds of credential, and a fourth route group that takes none at all. The differences are worth ten minutes now, because picking the wrong one produces a `403` whose fix is not obvious from the message. Every key travels the same way, as a bearer token: ```plaintext Authorization: Bearer sk_4bT1nQ7xLd9sVzK0pR2eYw8hC6jM3gF5uA1oS7iN0dE ``` ## The two scopes [Section titled “The two scopes”](#the-two-scopes) **A workspace key belongs to one workspace.** It is created in the dashboard under Developer → API keys, or minted through the account API. Everything it can see is that one business: its services, its calendars, its bookings. **An account key belongs to the account**, which is the billing entity that owns one or more workspaces. The two scopes are separated at the credential itself: an account key can never satisfy a workspace route, and a workspace key can never satisfy an account route. The account key exists for one job: creating a workspace by API, and then minting the first key for it. ## The three key types [Section titled “The three key types”](#the-three-key-types) | Prefix | Scope | Where it runs | What it reaches | | ------ | --------- | ------------- | --------------------------------------------- | | `pk_` | Workspace | A browser | Three endpoints. See below. | | `sk_` | Workspace | Your server | Everything under `/v1`, except `/v1/account`. | | `ak_` | Account | Your server | `/v1/account` only. | An account key does **not** unlock workspace routes. If you hold `ak_` and want to read a workspace’s bookings, mint an `sk_` for that workspace and use it. That is deliberate: the account key’s surface is four routes and no more. ## The publishable key is public on purpose [Section titled “The publishable key is public on purpose”](#the-publishable-key-is-public-on-purpose) A publishable key is not a secret, and treating it as one will confuse you. `GET /v1/public/workspaces/{slug}` hands the workspace’s `pk_` to anybody who knows the slug. That is by design, because it is how a booking page bootstraps itself with no server of its own. What makes that safe is not secrecy. It is that a `pk_` reaches exactly three endpoints: * `GET /v1/availability` * `POST /v1/bookings` * `POST /v1/bookings/{id}/verify` Everywhere else it gets a `403` with `secret_key_required`. It cannot list your bookings, read a customer, or change a price. ### What the origin check does and does not do [Section titled “What the origin check does and does not do”](#what-the-origin-check-does-and-does-not-do) A publishable key carries a list of allowed origins. When a request arrives with an `Origin` header, that header must be the workspace’s own booking page or an entry on the key’s list. If it is not, the request is refused with `origin_not_allowed`. The list is set under **Developer → API keys**, when the key is created or later with **Edit allowed origins** in the key’s menu. Changing it keeps the same key value, and the new list applies from the next request. The key `GET /v1/public/workspaces/{slug}` returns is the one named **Booking page**. Add your site’s origins to it, and your site can use the key that response hands it. If that key was revoked, the response returns the oldest publishable key still active. Two things follow, and the second one surprises people: **It is an anti-abuse control, not a security boundary.** It stops a third party from lifting your `pk_` and embedding it in *their* site, because a browser stamps their real origin on the request and browsers do not let page JavaScript forge that header. **A request that sends no `Origin` header at all is not origin-checked.** The guarantee is the browser’s, and it ends at the browser. `curl` sets `Origin` with one flag, or omits it entirely. So a publishable key does work from a server, in the narrow sense that the three endpoints above will answer it. But that is not what the key is for, and it buys you nothing over a secret key except a smaller surface and a rate limit. The controls that do not depend on client honesty are the three-endpoint surface, the [rate limits](/docs/guides/limits/), and revocation. ## The secret key [Section titled “The secret key”](#the-secret-key) `sk_` is the ordinary server credential. It reaches every workspace route. It is not origin-checked, and it has its own [rate limit](/docs/guides/limits/), shared by every secret key the workspace holds. A booking it creates never takes a deposit. The full value is shown once, on the response that creates it. No later read can return it. A publishable key *can* be read back later and a secret key cannot. The publishable one is meant to be public. The secret one is not. Keep `sk_` on a server. A secret key in browser JavaScript is the whole workspace, handed to anybody who opens developer tools. ## The routes with no key [Section titled “The routes with no key”](#the-routes-with-no-key) `/v1/public/*` takes no `Authorization` header. The token in the URL is the only credential: * `GET /v1/public/workspaces/{slug}`: the slug is the credential, and the response is deliberately public. * `GET|POST /v1/public/bookings/{token}` and its `cancel` and `reschedule` routes: this is the manage link the customer receives by email. The token comes from `access_token` on the response that created the booking, and that is the only response that ever carries it. There is no login on this surface, and there never will be. A customer of a Keptslot business is not a Keptslot user. ## Losing a key [Section titled “Losing a key”](#losing-a-key) There is no recovery path for a secret key or a webhook signing secret. Both are shown once and cannot be read back. Revoke and re-create. For a webhook, call the rotate endpoint instead. This is worth saying plainly because the failure is silent and late: everything keeps working until the day you redeploy from a machine that does not have the value. # Rate limits and quotas > A token bucket that refills continuously, the headers a 429 carries, and the daily and account-level ceilings that sit beside it. Three different things can refuse your request for reasons of volume, and they behave nothing alike. A **rate limit** is about requests per minute. It answers `429`, it is typed `rate_limited`, and it clears within seconds of you slowing down. A **daily booking ceiling** and an **account volume ceiling** are both about bookings actually written, not requests made. They also answer `429`, typed `daily_booking_limit` and `booking_volume_refused`, but neither clears the way a rate limit does. One waits for the workspace’s local midnight, the other for the account’s billing period to roll over. Both are covered on this page, below the rate-limit budgets. A **plan quota** is a different thing again: it is about what the account bought: active calendars and workspaces. It answers `402`, and it clears when the plan changes or the account is back under the cap. Quotas are covered in the [errors guide](/docs/guides/errors/); the rest of this page is the three `429`s. ## It’s a token bucket, and it refills continuously [Section titled “It’s a token bucket, and it refills continuously”](#its-a-token-bucket-and-it-refills-continuously) This is the fact to design against, and it is the one most likely to be assumed wrong if you have built against a fixed-window limiter before. Each (endpoint, caller) pair has its own bucket that starts full and refills continuously as time passes. You get a little more capacity every moment, not a lump sum credited at the top of a calendar minute. There is no minute boundary anywhere in this design, and nothing resets “at :00” for everybody at once. Two consequences fall out of that: **Waiting even a few seconds gives you back some budget**, not none. If you are refused, the bucket is refilling the whole time you wait, and there is no boundary to wait for. **The response tells you exactly how long to wait.** A refusal that can clear carries `Retry-After`, in seconds, computed from how full the bucket actually is right now. Read it rather than guessing. See below. ## A refused request costs you nothing [Section titled “A refused request costs you nothing”](#a-refused-request-costs-you-nothing) A request that is turned away with a `429` from the rate limiter does **not** consume a token. Only a request that is actually admitted spends budget. This is deliberate, and it is the opposite of a design where a refusal is itself counted: retrying here does not dig you a deeper hole. It still is not free in the sense that matters, because a tight retry loop keeps asking a bucket that has not refilled yet. So read `Retry-After` rather than hammering the endpoint, but a burst of refusals never leaves you worse off than a burst of silence would have. ## The budgets [Section titled “The budgets”](#the-budgets) Rate limits key on two axes, and both can apply to the same request: * **The workspace**: the whole booking page’s shared capacity, regardless of who is calling. * **The caller address**: one visitor’s own share of that capacity, so no single caller can consume a whole workspace’s budget alone. IPv6 addresses are truncated to their `/64` for this, since that is commonly what one visitor is delegated and the low bits rotate; IPv4 addresses are kept whole. | Endpoint | Per workspace | Per caller address | | ------------------------------- | ------------- | ------------------ | | `GET /v1/availability` | 300/min | 60/min | | `GET /v1/quote` | 300/min | 60/min | | `POST /v1/bookings` | 60/min | 10/min | | `POST /v1/bookings/{id}/verify` | 60/min | 10/min | `GET /v1/quote` has its own bucket, so a customer choosing extras does not spend the availability budget. Both axes are checked, and either can be the one that refuses a given request. A workspace whose whole page is busy can refuse you even though you personally are well under 10/min, and vice versa. The keyless customer routes under `/v1/public` get **30 requests per minute per caller address**, shared across all of them. Those routes are the bootstrap call, and the manage link’s cancel and reschedule. They carry no key, so an address is the only subject available. A **secret key** is rate-limited too, just on a different axis: **3000 requests a minute**, shared by every secret key the workspace holds (minting a second key does not grant fresh capacity). This is an abuse backstop against a leaked key or a runaway script, not metering. No legitimate integration ever approaches it. An **account key** gets **600 requests a minute**, shared by the account’s total request rate against the four `/v1/account` routes, across every account key and every workspace the account holds, for the same reason as the secret-key ceiling above. ## The headers a 429 carries [Section titled “The headers a 429 carries”](#the-headers-a-429-carries) A `429` from the rate limiter is a JSON body plus two headers: ```json { "error": { "type": "rate_limited", "message": "Too many requests." } } ``` `Retry-After` is seconds until the bucket has refilled enough to admit one more request. `RateLimit-Remaining` is requests left in the bucket, always `0` on a response that carries it, since a refusal only happens once it reaches zero. Both are on `Access-Control-Expose-Headers`, so a browser-based integration running on a publishable key can read them from JavaScript the same way it reads `type` from the body. Neither header is required to act correctly, because `type` alone is enough. But they save you from guessing. `daily_booking_limit` also carries `Retry-After` (seconds until the workspace’s local midnight), but never `RateLimit-Remaining`. See below. `booking_volume_refused` carries neither. Do not assume every `429` from this API has both headers; check which `type` you got. ## The daily booking ceiling [Section titled “The daily booking ceiling”](#the-daily-booking-ceiling) `daily_booking_limit` is not the rate limiter. It is a durable check on bookings actually **written**, not requests made, and it survives a restart. The token buckets above do not. It applies only to `POST /v1/bookings` called with a **publishable key**. A secret key is a workspace’s own server, every one of its calls arrives from one address, and fifteen a day would cap the business’s own integration rather than stop abuse. One caller address is allowed **15 bookings at one workspace per workspace local day**. Once it has written its 15th, `POST /v1/bookings` refuses the 16th with a `429` typed `daily_booking_limit` until the workspace’s local midnight, no matter how much rate-limit budget that caller still has. A caller can meet both ceilings in the same request. When that happens, the per-minute limiter is checked first and wins, so the response is `rate_limited`, since that one clears again within seconds and `daily_booking_limit` does not: ```json { "error": { "type": "daily_booking_limit", "message": "This device has made too many bookings with this business today. Please try again tomorrow, or contact the business directly." } } ``` `Retry-After` is set to the real time remaining until the workspace’s local midnight, not a flat 24 hours, so it stays correct on the two days a year the workspace’s timezone shifts. There is no `RateLimit-Remaining`, no count and no limit in the body: this is an abuse backstop, not an allowance to design a progress bar against. It is keyed on **(workspace, caller address)**, not the account and not the booking’s customer email. A shared address (an office, a kiosk) hits its own ceiling independent of who it is booking for, and a customer switching networks gets a fresh one. It does not mean the business is full: every other caller, and this same one tomorrow, can still book. ## The account volume ceiling [Section titled “The account volume ceiling”](#the-account-volume-ceiling) `booking_volume_refused` is different again, and it is the one case on this page where the advice is not “wait and retry.” It is a platform safety limit on an **account’s** total bookings in its current billing period, across every workspace and every caller the account has, not one device at one workspace. No plan raises it and no plan is exempt from it; it is not something an upgrade resolves. ```json { "error": { "type": "booking_volume_refused", "message": "Too many bookings. Please try again later." } } ``` **Retrying does not help.** The ceiling clears only when the account’s billing period rolls over, which may be weeks away, so a client that retries on a backoff will simply keep being refused. There is no `Retry-After`, no count and no limit anywhere in the body, not even for a secret key or an account key, which do see numbers on a plan-quota `402`. An account reaching this has a runaway integration; the fix is to stop it. If you believe you have reached this legitimately, contact support. `daily_booking_limit` and `booking_volume_refused` can both apply to the same request; when they do, `daily_booking_limit` is reported first, since it is the narrower and more specific of the two. ## Idempotency is the other half of retrying safely [Section titled “Idempotency is the other half of retrying safely”](#idempotency-is-the-other-half-of-retrying-safely) `POST /v1/bookings` accepts an `Idempotency-Key` header. It is the mechanism that makes a retry safe, and it is worth using on every create rather than only in your error path. Send a fresh unique value per booking attempt. A UUID is ideal: ```plaintext Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7 ``` **A repeat with the same key and the same body replays the stored response**, including its original status code. You get the same booking id back, and no second appointment is created. This is what saves you when a request times out after the booking was actually made. **A repeat with the same key and a *different* body is refused** with a `422` and `idempotency_mismatch`. The stored response is never replayed in that case: reusing a key with different content is always a client bug, and replaying would hide it. Two details worth knowing: * **Only a `2xx` is stored.** A failed create is not remembered, so you are free to retry it cleanly with the same key. * **Simultaneous requests do not replay.** Two identical creates arriving at the same instant cannot both take the slot, but the loser gets its own `409` rather than a copy of the winner’s response. Replay is guaranteed for the sequential case, which is the one a timeout produces. ## Retrying, in one paragraph [Section titled “Retrying, in one paragraph”](#retrying-in-one-paragraph) Retry a `429` typed `rate_limited` after the `Retry-After` it carries. A few seconds is often enough, since the bucket refills continuously and a refusal never cost you anything. Retry a `429` typed `daily_booking_limit` only after its own `Retry-After`, which in practice means tomorrow, at that one workspace, from that one caller. **Do not retry a `429` typed `booking_volume_refused`**. It will not clear until the account’s billing period rolls over. Retry a `503` after a short backoff. A `503` means two writes contended, and the request is expected to succeed. Retry a create with the same `Idempotency-Key` you used the first time. Do not retry any other `4xx`: the same request will get the same answer. # Receive and verify a webhook > Register an endpoint, verify the Booking-Signature header, handle retries without double-processing, and read each of the twelve events. 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”](#1-register-the-endpoint) ```bash 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”](#2-store-the-secret-now) The `201` response carries `secret`: ```json { "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”](#3-read-the-four-headers) Every delivery is a `POST` with a JSON body and four headers. | Header | What it carries | | ------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `Booking-Signature` | `t=,v1=` | | `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”](#4-verify-the-signature) The signed message is the timestamp, a period, and the **raw** request body: ```plaintext . ``` 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`. ```js 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”](#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”](#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”](#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”](#the-payload) ```json { "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”](#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”](#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”](#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`: ```json "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: []`. ## Holds [Section titled “Holds”](#holds) `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: ```json "hold": { "kind": "code", "expires_at": "2026-09-01T08:10:04.512807+01:00", "verification_code": "482915" } ``` ```json "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”](#payments) `data.payment` is present on the four payment events and on those only. It is absent from the booking events, not null: ```json "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”](#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`: ```json "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”](#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 `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.