Skip to content

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.

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.

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.

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

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.

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 '{
"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.

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

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.

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.

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.