Hold a slot
A hold claims a slot without booking it. The table is occupied from the moment the call returns: a hold counts against availability exactly as a booking does. It has no guest attached.
That is the shape of a flow where the table is one item among several. A dinner-and-show package sells a concert ticket, a table and a hotel room in one basket; the ticket is confirmed by one system, the table by this one, and the guest pays once at the end. Without a hold, the table is either booked before the guest has paid — and has to be cancelled when they abandon the basket — or booked after, by which point the 20:00 you quoted is gone.
A hold is not a reservation
Section titled “A hold is not a reservation”It does not appear in GET /v1/reservations, it has no guest, and no
confirmation is sent to anyone. It lives on /v1/holds until one of four
things happens: you convert it into a booking, you release it, the restaurant
releases it from its back office, or it runs out of time. The restaurant’s
release reaches you as a hold.released you did not cause, and a conversion
after it is a 404 — see the events below.
A hold and the reservation it becomes are the same row, and their ids say so:
hold_AbC converts into resv_AbC. The hold id stops appearing in
GET /v1/holds once it converts, and still resolves on GET /v1/holds/{id}
with status: "converted" naming the booking — so nothing you stored becomes
unreachable.
Holds need reservations:write; reading them needs reservations:read. There
is deliberately no separate holds scope: a key that may book may hold. See
Scopes.
Two kinds, and the default is not the one you want here
Section titled “Two kinds, and the default is not the one you want here”{ "service_date": "2026-10-04", "start_time": "20:00", "party_size": 4, "kind": "commitment" }Send kind explicitly. Omit it and you get basket, a hold of minutes —
correct for a checkout, wrong for a package that settles tomorrow.
basket |
commitment |
|
|---|---|---|
| Shape of the flow | A guest is at a checkout right now | Capacity you have already sold and are waiting to be paid for |
| Longest granted | 30 minutes | 48 hours |
| At the deadline | The table goes back on sale immediately | It keeps the table and waits to be resolved |
A basket releases itself the instant it expires, so an abandoned basket costs
the restaurant nothing. A commitment does not release at its deadline: it
holds the table until a sweep resolves it, because releasing capacity somebody
has already bought would double-sell it. Release every commitment you do not
convert.
The deadline is granted, not requested
Section titled “The deadline is granted, not requested”expires_at is optional, and the server decides. A requested deadline is
clamped rather than refused: ask for 45 minutes on a basket and you are
granted thirty, and the response says so. The shortest hold of either kind is
five minutes. The range is the same at every restaurant.
A request that sends no expires_at gets ten minutes for a basket and the
longest hold, 48 hours, for a commitment.
Read expires_at off the response; never assume your own number. Those
limits are a product decision and can move. Clamping means they can move without
breaking your integration. A value that is not an RFC 3339 timestamp is
a 400 naming expires_at — a deadline Service could not read is not quietly
replaced with a default, because you would then run your basket timer against a
number you never received.
The requested expires_at is deliberately not part of what the
Idempotency-Key identifies, so a client that recomputes “now + 10 minutes” on
every attempt still gets its replay rather than a 409.
Take the hold
Section titled “Take the hold”curl -X POST "https://api.useservice.app/v1/holds" \ -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, "kind": "commitment", "expires_at": "2026-10-02T18:00:00+02:00", "section_id": "sec_7Yd2Qm9", "metadata": { "package_ref": "PKG-40912" } }'Idempotency-Key is required, and for a reason of its own beyond the usual one:
a duplicate hold silently takes a second table, and the id you lost is the only
way to name it — you cannot release what you never received. Retry with the
same key and you get the first hold back, id included, with the header
Idempotent-Replayed: true.
The rules are checked here, not at conversion. Capacity, cutoff, blocks and
the section are all evaluated when the hold is taken. A hold buys you those
checks; the conversion does not repeat them. So a refusal arrives on this
call, in the same shape a create uses: 422 booking_rules_violated with a
violations array. Read the array, not the top-level code — see
Booking-rule refusals. override belongs
on this call too, for the same reason.
The seating time is one of those checks. A hold’s start_time has to be a time
GET /v1/availability offers, and 20:07 at a restaurant that seats every half
hour is refused with
start_time_off_grid.
Convert it
Section titled “Convert it”The conversion is a create that names the hold. It lives on
POST /v1/reservations, not on the holds collection, because it produces a
reservation and runs the whole booking path — guest resolution, the guarantee
fork, the confirmation email.
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 '{ "hold_id": "hold_AbC3xK9", "guest": { "first_name": "Marie", "last_name": "Dupont", "email": "[email protected]" } }'The slot comes from the hold. Omit service_date, start_time, party_size
and section_id, or restate them exactly — a value that disagrees is a 400,
because a hold
cannot be moved.
The conversion is a separate write and needs its own Idempotency-Key, not the
one the hold was taken with.
Everything about the guest still applies. A conversion onto a slot carrying a
card guarantee lands awaiting_guarantee exactly as a fresh booking would, so
read status on the response rather than assuming the conversion confirmed
anything.
metadata set on the hold is carried onto the booking unless the conversion
sends its own.
The hold’s excluded_from_shift_limits is carried onto the booking too. It says
whether the hold counts against the service’s covers
caps, and a hold where it
is true needs reservations:write:override to convert. A key
without that scope is refused 403 insufficient_scope with
param: "excluded_from_shift_limits", and can send
excluded_from_shift_limits: false to convert the hold as an ordinary booking.
Read the field off the hold before you convert one another of the restaurant’s
integrations took.
The hold is consumed atomically, so a hold_id that is unknown, already
converted, released or expired is a 404. The remedy is to book the slot fresh
without it, which re-runs every capacity check the hold had bought.
Release what you do not convert
Section titled “Release what you do not convert”curl -X DELETE "https://api.useservice.app/v1/holds/hold_AbC3xK9" \ -H "Authorization: Bearer $SERVICE_API_KEY"This is the call a well-behaved integration makes on every abandoned basket, and
it is not optional for a commitment hold, which does not release itself.
DELETE is idempotent: releasing a hold that is already released or has already
expired returns the same terminal object with a 200, so a retry that actually
succeeded needs no special case. A hold that has already become a booking is the
one refusal — 409
hold_already_converted, naming the
reservation. Cancel that booking instead.
Changing the time
Section titled “Changing the time”A hold keeps the slot it was taken for. When the guest wants a different time, take a new hold and release this one — see Change a held time.
Four events, and only one of them means a booking exists
Section titled “Four events, and only one of them means a booking exists”| Event | What happened |
|---|---|
hold.created |
A slot was held. The table is occupied and nobody has booked it. |
hold.converted |
The hold became the reservation it names. |
hold.released |
The hold was handed back before its deadline — by you, or by the restaurant from its back office. |
hold.expired |
The hold ran out of time. |
hold.converted is the only signal that the hold turned into a booking. It
fires alongside reservation.created, and the two describe the same party —
count the conversion as the hold being released, or you will count the covers
twice. The event catalog carries each payload.
Two things that surprise people
Section titled “Two things that surprise people”Holds are scoped to the restaurant, not to your key. GET /v1/holds
returns every hold the restaurant has, whichever integration took it, and that
includes each hold’s metadata. This is the same tenancy boundary reservations
use — an integration that can see the restaurant’s bookings can see what is
holding its tables — and it is deliberate. The consequence for you is about what
you write: put reconciliation ids in metadata, not commercial terms, and
nothing you would not show another of the restaurant’s integrations.
There is no cap on how many holds you may have open. The write rate limit
caps how fast you may ask, not how much you may hold, and a commitment hold
does not release at read time. Your holds consume the restaurant’s availability,
so a run of holds you never resolve reads to the restaurant as a full service
that nobody booked. Release on abandonment, set the shortest deadline your flow
can honour, and reconcile on hold.expired.
Reading holds back
Section titled “Reading holds back”GET /v1/holds lists live, released and expired holds, newest first, and
supports status and date filters plus the usual
cursor pagination. Converted holds are absent from
the list on purpose: they are reservations now. Asking for
?status=converted is a 400.
GET /v1/holds/{id} resolves any hold, including a converted one, which answers
status: "converted" and names the booking in reservation. That is the call
that turns a stored hold id back into something useful after the fact.