Skip to content
Keptslot
Start free

Create a booking

POST
/v1/bookings
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).

Idempotency-Key
string

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.

X-Turnstile-Token
string

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.

Media type application/json
object
service_id
required
string format: uuid
staff_id

The calendar to book. If it is absent, the API picks an available calendar.

string | null format: uuid
starts_at
required

An RFC3339 timestamp. It must be an open slot.

string format: date-time
customer
required
object
name
required
string
email
required
string
phone

E.164 format. It is optional for every workspace. If it is absent, the customer is notified by email only.

string
answers

Answers to the custom questions of the booking form. It is optional. A caller that does not use questions omits it.

Array<object>
object
question_id
required
string format: uuid
value
required
string
extra_ids

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.

Array<string>
<= 10 items unique items
metadata

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
<= 20 properties
key
additional properties
string
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"
}
}

The booking is created.

Media type application/json
object
id
required
string format: uuid
staff_id
required
string format: uuid
service_id
required
string format: uuid
customer_id
required
string format: uuid
starts_at
required
string format: date-time
ends_at
required
string format: date-time
status
required
string
updated_at
required

When the booking last changed. Send it back as updated_since on GET /v1/bookings to read everything that changed after it.

string format: date-time
access_token

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.

string
hold_expires_at

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.

string format: date-time
payment

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
client_secret
required
string
amount_cents
required

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.

integer
currency
required
string
connected_account_id
required

The business’s Stripe account. Pass it as stripeAccount when you construct Stripe.js.

string
stripe_publishable_key
required

The Stripe publishable key to construct Stripe.js with. It is not your Keptslot pk_ key.

string
extras
required

The extras booked with the service, in the order the business lists them. Empty when there are none.

Array<object>

One extra on a booking, as it was when the booking was made. A later change to the service does not change it.

object
service_id
required
string format: uuid
name
required
string
duration_minutes
required
integer
price_cents
required
integer | null
price_cents
required

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.

integer | null
service_price_cents
required

The price of the service alone when it was booked, without its extras. It is null when the service had no price.

integer | null
metadata
required

The record sent with POST /v1/bookings. It is {} when none was sent, never null.

object
key
additional properties
string
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.

Media type application/json
object
error
required
object
type
required

A stable machine-readable code. Match on this.

string
message
required

A sentence for a person. Do not match on this.

string
param

The field that caused the refusal, if one field did.

string
dimension

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.

string
used

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.

integer format: int64
limit

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.

integer format: int64
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.

Media type application/json
object
error
required
object
type
required

A stable machine-readable code. Match on this.

string
message
required

A sentence for a person. Do not match on this.

string
param

The field that caused the refusal, if one field did.

string
dimension

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.

string
used

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.

integer format: int64
limit

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.

integer format: int64
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.

Media type application/json
object
error
required
object
type
required

A stable machine-readable code. Match on this.

string
message
required

A sentence for a person. Do not match on this.

string
param

The field that caused the refusal, if one field did.

string
dimension

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.

string
used

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.

integer format: int64
limit

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.

integer format: int64
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.

Media type application/json
object
error
required
object
type
required

A stable machine-readable code. Match on this.

string
message
required

A sentence for a person. Do not match on this.

string
param

The field that caused the refusal, if one field did.

string
dimension

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.

string
used

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.

integer format: int64
limit

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.

integer format: int64
Example generated
{
"error": {
"type": "example",
"message": "example",
"param": "example",
"dimension": "example",
"used": 1,
"limit": 1
}
}

The request body or the query is not valid.

Media type application/json
object
error
required
object
type
required

A stable machine-readable code. Match on this.

string
message
required

A sentence for a person. Do not match on this.

string
param

The field that caused the refusal, if one field did.

string
dimension

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.

string
used

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.

integer format: int64
limit

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.

integer format: int64
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):

  1. rate_limited is 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 before Retry-After has elapsed just gets refused again; retrying at or after it succeeds. It carries Retry-After (seconds until the bucket has refilled enough for one more request) and RateLimit-Remaining – see RateLimited.

  2. daily_booking_limit means 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 carries Retry-After set 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 no RateLimit-Remaining, no count and no limit: the ceiling is an abuse backstop, not an allowance for sale.

  3. booking_volume_refused means 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 no Retry-After, no number in the body, and no dimension/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.

Media type application/json
object
error
required
object
type
required

A stable machine-readable code. Match on this.

string
message
required

A sentence for a person. Do not match on this.

string
param

The field that caused the refusal, if one field did.

string
dimension

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.

string
used

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.

integer format: int64
limit

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.

integer format: int64
Example generated
{
"error": {
"type": "example",
"message": "example",
"param": "example",
"dimension": "example",
"used": 1,
"limit": 1
}
}
Retry-After
integer

Seconds until this refusal clears. Present on rate_limited and daily_booking_limit. Absent on booking_volume_refused – see its description.

RateLimit-Remaining
integer

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.

Media type application/json
object
error
required
object
type
required

A stable machine-readable code. Match on this.

string
message
required

A sentence for a person. Do not match on this.

string
param

The field that caused the refusal, if one field did.

string
dimension

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.

string
used

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.

integer format: int64
limit

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.

integer format: int64
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.

Media type application/json
object
error
required
object
type
required

A stable machine-readable code. Match on this.

string
message
required

A sentence for a person. Do not match on this.

string
param

The field that caused the refusal, if one field did.

string
dimension

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.

string
used

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.

integer format: int64
limit

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.

integer format: int64
Example generated
{
"error": {
"type": "example",
"message": "example",
"param": "example",
"dimension": "example",
"used": 1,
"limit": 1
}
}