Availability and booking, end to end
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”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 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”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.
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 lists the calls.
Reading availability
Section titled “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”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.
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 shows how to take it.
The publishable key also needs a Turnstile token
Section titled “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). 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”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”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 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”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 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.