Build a booking funnel
A booking funnel is the sequence a guest walks: pick a date, pick a time, say
who they are, get a confirmation. Four endpoints carry it —
GET /v1/configuration, GET /v1/availability/months, GET /v1/availability
and POST /v1/reservations — and everything else on this page is about the
answers those four give you when the booking does not go straight through.
First: you need a server
Section titled “First: you need a server”Every credential this API issues is secret, and api.useservice.app grants no
cross-origin access to an origin you control. Service wildcards CORS for
/v1/public/* and for nothing else, and those are the booking widget’s own
unauthenticated endpoints on the app host rather than any part of this API. A
sk_live_… key cannot be made to work from a page you ship to guests — not
behind a build step, not in a service worker, not with a proxy header. See
There is no browser-safe key.
Rebuilding the funnel means rebuilding the funnel and running a server.
The guest’s browser talks to your backend; your backend holds the key and calls Service. If you already run a backend, this is a small addition. If you don’t, it’s a new piece of infrastructure to build and run.
If you want the booking flow on your site with no backend at all, embed the Booking Widget instead. It needs no key, and it is the same funnel.
One key, one restaurant
Section titled “One key, one restaurant”An API key belongs to a single restaurant. There is no group credential and no
restaurant_id parameter: the restaurant is resolved from the key, which is why
GET /v1/configuration is the only way to confirm which restaurant you are
talking to.
A ten-site group is therefore ten keys, minted ten times in ten back offices, and every request you make is about exactly one of them. Your server picks the key before it picks the endpoint. Build the mapping from your own site identifier to a key on day one — retrofitting it once a second restaurant appears means touching every call site.
Scopes for this job
Section titled “Scopes for this job”| Scope | Why the funnel needs it |
|---|---|
reservations:read |
Configuration, sections, and both availability endpoints. |
reservations:write |
The create. |
guests:read |
Only for ?expand=modification_token on POST /v1/reservations — see Finishing the job. A booking’s contact details and notes need it as well, on the bookings the funnel itself took: see Guest data on a booking. |
reservations:write:override |
Only if the funnel is allowed to book past a rule the restaurant set. A public funnel usually is not. |
Errors is the reference for what each scope grants and
what a refusal looks like. API access is a Premium entitlement: a key on a
restaurant without it is valid and still refused, with
plan_required.
Read the policy once
Section titled “Read the policy once”curl "https://api.useservice.app/v1/configuration" \ -H "Authorization: Bearer $SERVICE_API_KEY"This is the bootstrap. Fetch it at start-up, hold it, and revalidate with
If-None-Match rather than re-reading it per guest. It changes when a manager
changes a setting, not per request.
Four fields decide how the rest of the funnel behaves:
timezone— everyservice_dateand everyHH:MMon this API is wall-clock in the restaurant’s zone. Never UTC, never the browser’s. Convert at the edge of your system and keep the restaurant’s day inside it.min_party_size/max_party_size/max_advance_days— the restaurant’s online booking policy. Outside it, the create is refused withparty_size_too_small,party_size_too_largeoradvance_window_exceeded. Inside it, a party size can still have no table: the policy can say 1 to 20 at a restaurant whose tables seat 2 to 8. Build the party-size field fromparty_sizesonGET /v1/availability/months, below, which lists the sizes that can be seated.last_service_dateiscurrent_date + max_advance_days, pre-computed so you can bound a calendar with a string comparison.guest_languages— the languages this restaurant maintains guest-facing text in. It bounds?locale=below, and it bounds what a guest can be written to in.default_cutoff_minutes— the house default for when online booking closes, and not the value enforced for any particular service. The enforced one is per shift and arrives with availability.
Offer the calendar, then the times
Section titled “Offer the calendar, then the times”GET /v1/availability/months?month=2026-10 returns the days with
availability, for every party size at once. It takes no party size and no
section, so one response drives the whole month picker and stays valid while the
guest changes their mind about how many are coming. Each day carries the party
sizes that fit, or the one reason it is not offered. The reasons, in the order
Service checks them, are on
The availability object.
GET /v1/availability?service_date=2026-10-04&party_size=4 answers the
times, for one date and one party size, shift by shift.
curl -G "https://api.useservice.app/v1/availability" \ -H "Authorization: Bearer $SERVICE_API_KEY" \ --data-urlencode "service_date=2026-10-04" \ --data-urlencode "party_size=4" \ --data-urlencode "locale=en"Both show what was sellable at the moment you read them. Neither holds anything. The create re-validates every cap and table fit under a lock, which is why a funnel needs the refusal path further down as much as it needs this one.
Send a start_time taken from slots[], and never compute one. Service
enforces the service’s seating times on the create: a time between two
seatings, or after the last one, is refused with
start_time_off_grid even while the
service is running.
Per shift, six fields change what you draw:
| Field | What the funnel does with it |
|---|---|
slots[] |
The bookable times. available_covers: null means no cap configured, so unlimited — not zero. |
unavailable_slots[] |
Times this service sells that this party cannot take right now. Render them greyed out. Hide them and the page looks broken at peak, when the guest can see the restaurant is open. |
effective_cutoff_minutes |
When online booking closes for this service on this date, resolved through the restaurant’s rule cascade. This is the countdown to show a guest. The configuration value is the house default and may be wrong for the shift in front of you. |
booking_closed |
Online booking for this service on this date is over: its cutoff has passed, or its last seating time has. Its slots are empty because booking for it has ended, not because it filled. Leave the service out, and never label it full. |
display_only |
The service is worth showing and takes no online booking. Render its guest_message, never a booking control. |
requires_approval |
A booking here lands pending and staff decide. Never tell the guest they are booked. |
guarantee is non-null when the slot asks the guest to leave a card. Show the
amount and the cancellation terms before the guest commits — and read
What a rebuilt funnel cannot do before you
design that step.
Answering in the guest’s language
Section titled “Answering in the guest’s language”?locale= sets the language of the restaurant-authored text in the response:
shift name, guest_message, day_message and closure text. It accepts
exactly the languages in guest_languages, and anything else is a 400 naming
locale rather than a quiet fall-back to the primary language.
That parameter is about the text Service hands you. The language the guest
is written to in is a different field, guest.language on the create, and the
two are set independently.
Seating areas
Section titled “Seating areas”curl "https://api.useservice.app/v1/sections" \ -H "Authorization: Bearer $SERVICE_API_KEY"Group the rows by group_id before rendering anything, or the terrace is
offered twice. Which row names a group, and which flag decides whether to offer
it, are on The section object.
show_seating_preference on the configuration says whether the restaurant wants
guests asked at all. It is advisory: section_id is accepted on availability
and on a create whether it is true or false, and nothing is refused for
sending one. It is the restaurant stating a preference about its own booking
flow, and it is the only place that preference exists — so respect it rather
than deciding for them.
The same sec_… id goes to ?section_id= on availability and to section_id
on the create, and any member of a group resolves to the whole group.
Take the booking
Section titled “Take the booking”curl -X POST "https://api.useservice.app/v1/reservations" \ -H "Authorization: Bearer $SERVICE_API_KEY" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Type: application/json" \ -d '{ "service_date": "2026-10-04", "start_time": "20:00", "party_size": 4, "section_id": "sec_7Yd2Qm9", "guest": { "first_name": "Marie", "last_name": "Dupont", "email": "[email protected]", "phone": "+33612345678", "language": "en" }, "guest_notes": "Window table if possible", "metadata": { "order_ref": "PKG-40912" } }'Five things about that call.
Idempotency-Key is required. One value per booking attempt, the same value
on every retry of it. Generate it before the first attempt and keep it for the
whole attempt, including the retry after a refusal. A retry of an attempt that
already succeeded gets that attempt’s response back, marked
Idempotent-Replayed: true. See Idempotency.
A 201 is not a confirmation. Read status. Under manual approval the
booking lands pending; on a slot carrying a card guarantee it lands
awaiting_guarantee and only becomes valid once the guest saves a card. Only
confirmed means the table is booked. Your confirmation screen has three
outcomes to render, not one.
required_guest_fields is the list that is enforced. It is on the
configuration, and a create missing one of those names is refused with
guest_fields_required. The two flags next to it — require_guest_email and
require_guest_phone — are advisory: they describe what the restaurant asks
for on its own booking form, and this API does not enforce them. Use them to
mark the same field required in your form. A create carrying neither an email
nor a phone number is accepted, and it costs the restaurant two things: nobody
can reach that guest if the service is cancelled, and the booking matches no
existing profile, so it creates a new one every time.
Send guest.language. Leave it out and every guest you create is written to
in the restaurant’s primary_language — confirmation, reminder, modification
link, survey — silently, because nothing in the 201 says which language went
out. The field accepts any of the eleven languages Service supports and records
it on the profile, but the message goes out in the first of that language and
primary_language that appears in guest_languages. Read that array to know
what the restaurant can really write in. Whether those messages go out at all is
the guest_notifications field — see
Who writes to the guest.
A field the create does not read is a 400. A misspelt or unsupported
body field is refused with bad_request, param
naming it, rather than ignored. Inside guest the name is dotted, as in
guest.vip. The keys of metadata are yours and never checked.
If you already know the guest, send guest_id instead of a guest object. The
profile is never modified by a create, and contact details sent alongside a
guest_id are stored on that booking only.
When the booking is refused
Section titled “When the booking is refused”Every refusal by a booking rule has one shape: 422, top-level code
booking_rules_violated, and a violations array with one entry per rule
broken. The array is there for one broken rule as much as for six, and the
top-level code never names a rule.
{ "error": { "type": "invalid_request_error", "code": "booking_rules_violated", "violations": [ { "code": "slot_no_longer_available", "message": "…", "param": "party_size", "overridable": true } ] }}So a funnel reads violations, not error.code. A client that switches on the
top-level code works until a booking breaks two rules at once.
Two entries need their own screen rather than a generic banner, and they are easy to confuse:
slot_no_longer_available— a service is running and its covers cap filled between your availability read and your create. That is the race every funnel has. Re-read availability and show the guest what is left.no_service_at_this_time— nothing serves that date and time at all. Re-reading availability and sending a time it publishes is the only remedy; the code cannot be overridden.
Booking-rule refusals documents every code
and what overridable means, and
Overriding a rule covers the override array
and the one-retry rule that goes with it. A funnel open to the public should
usually not hold reservations:write:override at all: the limits it waives are
the restaurant’s own.
Finishing the job
Section titled “Finishing the job”A funnel that stops at the 201 leaves the guest on your confirmation page with
no way back to their own booking. Two fields close that.
Ask for the token on the create:
POST /v1/reservations?expand=modification_tokenmodification_token is the guest’s own capability on that booking. Append it to
public_booking_url from the configuration and you have a working manage link —
view, modify, cancel — that needs no credential of yours:
{public_booking_url}/{modification_token}Three constraints come with it:
- It is returned on the create response only. Reads, lists and webhooks do not carry it, so store it when you receive it.
- It is gated on
guests:read, because it authenticates access to the guest’s stored contact details. A key without that scope gets a403 insufficient_scopewithparam: "expand"— and books nothing, so add the scope before you add the parameter. - Treat it like the guest’s password: it opens everything the guest can do on that page, including the card step on a guaranteed booking. Pass it only to the guest, and do not log it or render it as text. Take a deposit sets out what it opens.
Read public_booking_url from the configuration rather than hard-coding a host.
It is absolute on purpose — a relative path would resolve against whatever
origin your funnel is served from — and it moves if the restaurant’s slug
changes.
Who writes to the guest
Section titled “Who writes to the guest”By default, Service still writes to the guest. A partner booking is
service_managed like any other: the restaurant’s confirmation e-mail and SMS
go out, carrying a modify/cancel link built from this same token. You only lose
that by asking for it — setting guest_notifications: "partner_managed" asserts
that you own guest communication for this booking, and that includes carrying
them the link. Then, and only then, losing the token leaves the guest no route
to their booking but you.
guest_notifications is set on the create and fixed there: a PATCH carrying
it is a 400. Asserting partner_managed suppresses every discretionary guest
message for the life of the booking — confirmation, modification, cancellation,
the reminder, the survey — on both e-mail and SMS. The restaurant’s own staff
alerts are untouched, and you still receive every reservation.* webhook. It is
refused outright on a slot carrying a card guarantee, with
guest_notifications_required_for_guarantee:
see Take a deposit.
What a rebuilt funnel cannot do
Section titled “What a rebuilt funnel cannot do”Two capabilities the embedded widget has do not exist on this API, and both are worth knowing before you promise a flow.
The card step cannot be rebuilt. A slot carrying a guarantee needs the
guest to save a card. Your key reads the quote — mode, amount, cancellation
terms — and never the payment credential behind it, so there is nothing for your
page to mount a card form with. The booking lands awaiting_guarantee, Service
emails the guest a pay-link, and the card step finishes on the hosted page. That
also means those bookings need an email address: a create for a guaranteed slot
without one is refused with
email_required_for_guarantee.
A guest cannot join the waitlist from here. Availability tells you a queue is open for that party size, and the waitlist endpoints are read-only. A guest who wants the queue joins it from the widget or the restaurant joins them from the back office.