/book/{slug} calls these exact endpoints, and shares its config
loader and its answer validator with them. Anything the hosted page does is legal here.
If the only requirement is different styling, don’t build against this API. The
hosted page already picks up the tenant’s branding, and it can be mounted on
book.{customerdomain} with a single CNAME — no code in their repo. Build against the
API when the flow has to be different.Before you start
Three things are provisioned by us, per site. Ask for all three before writing code.Tenant slug
The
{slug} in every URL. There is no endpoint to discover it.Turnstile site key
Plus your domain added to that widget’s allowlist.
Origin allowlist
Your site’s origin, added to the tenant’s
allowed_origins.turnstile_failed. You must render the widget with the site key we give you, and we must
add your domain to that widget’s allowlist — otherwise the widget refuses to render on
your domain and you never get a token at all.
Cross-origin submits need the allowlist. Both GET endpoints send
Access-Control-Allow-Origin: * and work from anywhere. The POST compares your
Origin against the tenant’s configured list and returns 403 origin_not_allowed if it
isn’t there. (This is browser hygiene, not authentication — the abuse controls are the
real gate.)
The whole flow
This is the entire integration.Endpoints
Full schemas, parameters, and response shapes live in the API reference — generated from the Zod schemas the routes actually enforce, so it cannot drift from the running code.
The two
GET endpoints are unauthenticated and send Access-Control-Allow-Origin: *, so
the playground on those pages will happily run against a real slug. The POST playground
is read-only — it can’t mint a Turnstile token, and firing it would write a real booking.
Questions
Questions belong to a service, not to the tenant. Each entry inservices[] carries
its own form_fields, and there is no tenant-wide list. If the customer switches service,
re-render from the new service’s list and drop answers the new service doesn’t ask —
submitting a question id this service doesn’t ask is a hard 400 invalid_form_data.
form_data is keyed by the question’s id. Five types:
Optional answers left blank are simply omitted — don’t send
"".
The server returns only the first validation failure, as a single string
(
"<question-id>: Choose one of the listed options."). To show per-field messages,
mirror the table above client-side. Validating before you submit is worth the effort
anyway: a server-side rejection burns the customer’s single-use Turnstile token, so
they have to solve a second challenge.form_fields and your
raw form state; send values as form_data and render errors under each input.
fields rather than your form state is what keeps a stale answer — from a
customer who switched service — out of the payload.
What to show afterwards
Branch onbooking.status from the 201. A newly created booking is always one of these
two, and the acknowledgment string is already worded correctly for each if you’d rather
just print it. Handle the rest of the union as “we couldn’t confirm this” — a retried
Idempotency-Key replays the stored booking, which the
operator may have cancelled in the meantime.
- confirmed
- pending
Instant-booking tenant. The customer is booked, and a confirmation email is on its
way. “You’re booked — check your email for the details.”
Errors
Every error has the same shape:
Neither
429 sends a Retry-After header, so back off on a fixed delay.
Not in this API
There is no public read-back, cancel, or reschedule. The201 is the only receipt a
website ever gets, and a booking cannot be looked up again once the page is closed.
Confirming, rescheduling, cancelling, and the full booking history all live in the DPC
dashboard. Point operators there; don’t build a “manage my booking” page against this API.
Server-to-server credentials — a Turnstile-free authenticated flow for non-browser
clients — are not available yet.