Create a booking
const url = 'https://api.keptslot.com/v1/bookings';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"service_id":"3a7b1e90-2c4d-4f81-9b02-7e5c1a6d8f44","starts_at":"2026-09-01T09:00:00+01:00","customer":{"name":"Sam Okafor","email":"sam@example.com","phone":"+447700900123"}}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url https://api.keptslot.com/v1/bookings \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "service_id": "3a7b1e90-2c4d-4f81-9b02-7e5c1a6d8f44", "starts_at": "2026-09-01T09:00:00+01:00", "customer": { "name": "Sam Okafor", "email": "sam@example.com", "phone": "+447700900123" } }'This endpoint books an appointment. Both key types can call it, because the publishable key drives the public booking widget.
Read the status on the response to decide what happens next. If the
status is pending, the customer must enter a 6-digit code, and you
send that code to POST /v1/bookings/{id}/verify. Do not decide this
from require_verification on the bootstrap response.
access_token is on this response only. It is the customer manage
link. Store it now, because no later response repeats it.
If staff_id is absent, the API picks an available calendar.
Three ceilings can refuse this endpoint, told apart by error.type
because a client that cannot tell them apart cannot act on any of
them correctly – see the 429 responses below. Called with a
publishable key, the per-minute budget is 60 requests a minute
shared by the workspace’s whole booking page and 10 a minute for one
caller address within that; a secret key is not subject to either
(see SecretKey’s own ceiling instead).
Authorizations
Section titled “ Authorizations ”Parameters
Section titled “ Parameters ”Header Parameters
Section titled “ Header Parameters ”Makes a retry safe. The first request stores its response. A repeat with the same key and the same body gives that stored response again, with the same status code.
If you send the same key with a different body, the endpoint refuses the request with a 422.
Only a 2xx response is stored. Two requests that arrive at the same time do not replay: the loser gets its own error.
A Cloudflare Turnstile token. It is required when the request uses a publishable key, and it is ignored when the request uses a secret key.
A publishable-key request that carries no token the API can verify
is refused with a 403 and the type turnstile_required.
The API issues no site key of its own, so a frontend you built
yourself cannot obtain a token. Create the booking from your
server with a secret key, or turn on “Custom booking frontend”
(self_hosted_booking_frontend on PATCH /v1/workspace) for the
workspace. See the booking page guide.
Request Body required
Section titled “ Request Body required ”object
The calendar to book. If it is absent, the API picks an available calendar.
An RFC3339 timestamp. It must be an open slot.
object
E.164 format. It is optional for every workspace. If it is absent, the customer is notified by email only.
Answers to the custom questions of the booking form. It is optional. A caller that does not use questions omits it.
object
Extras to add to the visit. It is optional. Each id must be
an extra offered with service_id, listed once, or the
endpoint refuses the request with a 422 on extra_ids.
The visit lasts as long as the service plus every extra,
and is booked on a calendar that performs all of them. The
extras are kept in menu order, whatever order you send.
With an Idempotency-Key, send the same order on a retry:
a different order is a different body.
Your own record on the booking: string keys and string
values, up to 20 keys, a key up to 40 characters and a
value up to 500. It is optional. It comes back as
metadata on every booking response and in every webhook
event, so you can match a booking to your own records
without a table of ids.
Secret key only. A publishable-key request that carries it
is refused with a 422 on metadata, as is one over the
caps or with a non-string value. It cannot be changed
after the booking is created.
object
Example
{ "service_id": "3a7b1e90-2c4d-4f81-9b02-7e5c1a6d8f44", "starts_at": "2026-09-01T09:00:00+01:00", "customer": { "name": "Sam Okafor", "email": "sam@example.com", "phone": "+447700900123" }}Responses
Section titled “ Responses ”The booking is created.
object
When the booking last changed. Send it back as updated_since on
GET /v1/bookings to read everything that changed after it.
The customer’s manage link token. It is present only on the response that creates the booking. Store it then. No later response repeats it.
When this booking stops holding its slot. It is present only while the booking is on a hold, and absent otherwise.
Show this time rather than counting a fixed number of minutes from now. The window differs between a booking waiting on a code and one waiting on a card, and it is longer for a card because a 3DS redirect goes to the customer’s bank and back.
Present only when the service takes a payment, and only on the response that creates the booking. The key is absent for a service that takes no payment. It is never null, so a client can test for the key itself.
Use these fields to mount Stripe’s Payment Element on the connected account and confirm the payment. The client secret works once, for this booking.
object
What this booking costs, in the smallest unit of the currency. It is a snapshot taken when the booking was made. Changing the service’s price later never changes it.
The business’s Stripe account. Pass it as stripeAccount when you
construct Stripe.js.
The Stripe publishable key to construct Stripe.js with. It is not
your Keptslot pk_ key.
The extras booked with the service, in the order the business lists them. Empty when there are none.
One extra on a booking, as it was when the booking was made. A later change to the service does not change it.
object
The price of the whole visit when it was booked: the service plus every extra. It is null when none of them had a price.
The price of the service alone when it was booked, without its extras. It is null when the service had no price.
The record sent with POST /v1/bookings. It is {} when none was
sent, never null.
object
Example generated
{ "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "staff_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "service_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "customer_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "starts_at": "2026-04-15T12:00:00Z", "ends_at": "2026-04-15T12:00:00Z", "status": "example", "updated_at": "2026-04-15T12:00:00Z", "access_token": "example", "hold_expires_at": "2026-04-15T12:00:00Z", "payment": { "client_secret": "example", "amount_cents": 1, "currency": "example", "connected_account_id": "example", "stripe_publishable_key": "example" }, "extras": [ { "service_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example", "duration_minutes": 1, "price_cents": 1 } ], "price_cents": 1, "service_price_cents": 1, "metadata": { "additionalProperty": "example" }}The key is missing, malformed or revoked.
object
object
A stable machine-readable code. Match on this.
A sentence for a person. Do not match on this.
The field that caused the refusal, if one field did.
The plan limit that refused the request. It is present only on a 402, and only for a secret key or an account key. See the errors guide.
The current count for that dimension. It is present only on a 402, and only for an account key. It is absent when the refusal has no count to report. Absent and zero are different answers.
The cap for that dimension. It is present only on a 402, and only for an account key. A cap of zero is a real value.
Example generated
{ "error": { "type": "example", "message": "example", "param": "example", "dimension": "example", "used": 1, "limit": 1 }}A plan limit refused the request. The fields in the body change with the key type. See the errors guide.
object
object
A stable machine-readable code. Match on this.
A sentence for a person. Do not match on this.
The field that caused the refusal, if one field did.
The plan limit that refused the request. It is present only on a 402, and only for a secret key or an account key. See the errors guide.
The current count for that dimension. It is present only on a 402, and only for an account key. It is absent when the refusal has no count to report. Absent and zero are different answers.
The cap for that dimension. It is present only on a 402, and only for an account key. A cap of zero is a real value.
Example generated
{ "error": { "type": "example", "message": "example", "param": "example", "dimension": "example", "used": 1, "limit": 1 }}Two different refusals share this status, and they are told apart
by error.type.
origin_not_allowed is the origin list of the publishable key.
See the errors guide.
turnstile_required means the request used a publishable key and
carried no Turnstile token the API could verify. One type covers a
missing token, an invalid one, a token solved on the wrong
hostname, and Cloudflare being unreachable, and the response does
not tell them apart. See X-Turnstile-Token above.
object
object
A stable machine-readable code. Match on this.
A sentence for a person. Do not match on this.
The field that caused the refusal, if one field did.
The plan limit that refused the request. It is present only on a 402, and only for a secret key or an account key. See the errors guide.
The current count for that dimension. It is present only on a 402, and only for an account key. It is absent when the refusal has no count to report. Absent and zero are different answers.
The cap for that dimension. It is present only on a 402, and only for an account key. A cap of zero is a real value.
Example generated
{ "error": { "type": "example", "message": "example", "param": "example", "dimension": "example", "used": 1, "limit": 1 }}Refusals here are told apart by error.type.
slot_unavailable means the time is no longer available.
bookings_paused means the business has paused online bookings.
No booking is made, with either key type.
object
object
A stable machine-readable code. Match on this.
A sentence for a person. Do not match on this.
The field that caused the refusal, if one field did.
The plan limit that refused the request. It is present only on a 402, and only for a secret key or an account key. See the errors guide.
The current count for that dimension. It is present only on a 402, and only for an account key. It is absent when the refusal has no count to report. Absent and zero are different answers.
The cap for that dimension. It is present only on a 402, and only for an account key. A cap of zero is a real value.
Example generated
{ "error": { "type": "example", "message": "example", "param": "example", "dimension": "example", "used": 1, "limit": 1 }}The request body or the query is not valid.
object
object
A stable machine-readable code. Match on this.
A sentence for a person. Do not match on this.
The field that caused the refusal, if one field did.
The plan limit that refused the request. It is present only on a 402, and only for a secret key or an account key. See the errors guide.
The current count for that dimension. It is present only on a 402, and only for an account key. It is absent when the refusal has no count to report. Absent and zero are different answers.
The cap for that dimension. It is present only on a 402, and only for an account key. A cap of zero is a real value.
Example generated
{ "error": { "type": "example", "message": "example", "param": "example", "dimension": "example", "used": 1, "limit": 1 }}THREE different refusals share this status, told apart by
error.type. Do NOT treat them the same way – they carry
different promises about when, or whether, retrying helps, and
they are checked in this order (a request meeting more than one
is told about the first it meets):
-
rate_limitedis the token-bucket request limiter (60/min capacity for the workspace’s publishable key, shared by its whole booking page; 10/min for one caller address within that). The budget refills CONTINUOUSLY, a fraction of a token every second, rather than resetting all at once at a calendar-minute boundary, and a REFUSED request costs nothing – only an admitted one spends a token. Retrying beforeRetry-Afterhas elapsed just gets refused again; retrying at or after it succeeds. It carriesRetry-After(seconds until the bucket has refilled enough for one more request) andRateLimit-Remaining– seeRateLimited. -
daily_booking_limitmeans this caller address has already written 15 bookings at this workspace during the workspace’s own local day. It is per (workspace, caller address), not account-wide, and it does NOT mean the business is full – every other customer, and this one tomorrow, can still book. It clears at the workspace’s local midnight and carriesRetry-Afterset to the time remaining until then (computed from the real zoned instant, so it is correct on the two days a year the workspace’s timezone shifts). It carries noRateLimit-Remaining, no count and no limit: the ceiling is an abuse backstop, not an allowance for sale. -
booking_volume_refusedmeans this account has created an implausible number of bookings in its current billing period. It is a platform safety limit, not a plan allowance: no plan raises it, no plan is exempt from it, and it is not something an upgrade resolves. Retrying does not help. The limit resets when the account’s billing period does, which may be weeks away, so a client that retries on a backoff will simply keep being refused. There is deliberately noRetry-After, no number in the body, and nodimension/used/limit— an account reaching this has a runaway integration, and the fix is to stop it. If you believe you have reached this legitimately, contact support.
Access-Control-Expose-Headers names Retry-After and
RateLimit-Remaining on every response from this endpoint, so a
browser-based integration can read either header when it is
present – it is simply absent from a booking_volume_refused
response, per its own description above.
object
object
A stable machine-readable code. Match on this.
A sentence for a person. Do not match on this.
The field that caused the refusal, if one field did.
The plan limit that refused the request. It is present only on a 402, and only for a secret key or an account key. See the errors guide.
The current count for that dimension. It is present only on a 402, and only for an account key. It is absent when the refusal has no count to report. Absent and zero are different answers.
The cap for that dimension. It is present only on a 402, and only for an account key. A cap of zero is a real value.
Example generated
{ "error": { "type": "example", "message": "example", "param": "example", "dimension": "example", "used": 1, "limit": 1 }}Headers
Section titled “ Headers ”Seconds until this refusal clears. Present on rate_limited
and daily_booking_limit. Absent on
booking_volume_refused – see its description.
Requests left in the per-minute bucket. Present, and always
0, on rate_limited only.
A fault on our side. Do not retry the same request in a loop. The
type field is internal_error.
object
object
A stable machine-readable code. Match on this.
A sentence for a person. Do not match on this.
The field that caused the refusal, if one field did.
The plan limit that refused the request. It is present only on a 402, and only for a secret key or an account key. See the errors guide.
The current count for that dimension. It is present only on a 402, and only for an account key. It is absent when the refusal has no count to report. Absent and zero are different answers.
The cap for that dimension. It is present only on a 402, and only for an account key. A cap of zero is a real value.
Example generated
{ "error": { "type": "example", "message": "example", "param": "example", "dimension": "example", "used": 1, "limit": 1 }}Two writes contended and one was rolled back. The request did nothing.
Retry it. The type field is transient_failure.
object
object
A stable machine-readable code. Match on this.
A sentence for a person. Do not match on this.
The field that caused the refusal, if one field did.
The plan limit that refused the request. It is present only on a 402, and only for a secret key or an account key. See the errors guide.
The current count for that dimension. It is present only on a 402, and only for an account key. It is absent when the refusal has no count to report. Absent and zero are different answers.
The cap for that dimension. It is present only on a 402, and only for an account key. A cap of zero is a real value.
Example generated
{ "error": { "type": "example", "message": "example", "param": "example", "dimension": "example", "used": 1, "limit": 1 }}