Skip to content

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.

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.

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.

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.

Terminal window
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 — every service_date and every HH:MM on 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 with party_size_too_small, party_size_too_large or advance_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 from party_sizes on GET /v1/availability/months, below, which lists the sizes that can be seated. last_service_date is current_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.

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.

Terminal window
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.

?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.

Terminal window
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.

Terminal window
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.

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.

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_token

modification_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 a 403 insufficient_scope with param: "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.

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.

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.