Skip to main content
Three public, unauthenticated endpoints that let a website take a booking on a tenant’s behalf. The flow is deliberately one-way — show services, show open times, submit, say thank you. Everything after that (confirming, rescheduling, cancelling, and the booking record itself) happens in the DPC dashboard, where the operator works.
Our own hosted page at /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.
You cannot bring your own Turnstile key. Every booking is verified against one server-side secret, so a token minted by a different site key is rejected with 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 in services[] 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: Every field also carries placeholder, help_text, min and max. All four are nullable; min / max are non-null only for number (a JSON number) and date (a "YYYY-MM-DD" string), and can be forwarded to an input’s attributes unchanged. Optional answers left blank are simply omitted — don’t send "" or [].
Treat type as an open set. New types are added additively, and a client that hard-switches on the list above will break the next time one appears. Fall back to a plain text input for anything you don’t recognize rather than skipping the question — a question you silently drop is one the customer is never asked, and if it’s required the submission then fails server-side with nothing on screen to explain it.
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.
This mirrors the server’s rules. Pass it the selected service’s form_fields and your raw form state; send values as form_data and render errors under each input.
Iterating 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 on booking.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.
Instant-booking tenant. The customer is booked. “You’re booked — we’ve sent you the details.”
Don’t name a channel in your success copy. The customer’s confirmation goes out on whatever they gave you — email if they supplied an address, SMS if they ticked sms_consent on a tenant with a registered number, both, or neither. “Check your email” is wrong for a customer who gave a phone and no email, which is now a perfectly ordinary booking. Word it channel-neutrally, or key the copy off what you collected.
Don’t promise a second message on a pending booking. The customer is contacted exactly once, at submit time. When the operator later confirms the booking in the dashboard, nothing is sent — the operator follows up themselves. Copy like “we’ll let you know once it’s confirmed” is a promise the platform does not currently keep.

Errors

Every error has the same shape:
The ones a submit form should actually handle: 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. The 201 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.