Hold a slot
const url = 'https://api.useservice.app/v1/holds';const options = { method: 'POST', headers: { 'Idempotency-Key': 'example', Authorization: 'Bearer <token>', 'Content-Type': 'application/json' }, body: '{"service_date":"2026-10-16","start_time":"19:30","party_size":2}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url https://api.useservice.app/v1/holds \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: example' \ --data '{ "service_date": "2026-10-16", "start_time": "19:30", "party_size": 2 }'Claims a slot without booking it, so you can assemble the rest of a package, take a
payment, or wait for a prepayment to clear while the table stays yours. The table is
occupied from the moment this returns — a hold counts against availability exactly as a
booking does. Requires the reservations:write scope; there is no separate hold scope.
A hold is not a reservation. It has no guest and it does not appear in
GET /v1/reservations. Convert it with POST /v1/reservations and a hold_id, give it
back with DELETE /v1/holds/{id}, or let it expire.
Read expires_at from the response; never assume your own. You may request a
deadline and the server decides, clamping rather than refusing. A basket hold lasts
between 5 and 30 minutes, and 10 when you ask for nothing — the same everywhere, at every
restaurant. Ask for what your checkout needs, up to thirty; expires_at says what any
one hold actually got. A commitment hold is granted at most 48 hours.
basket and commitment differ at the deadline — by minutes, not by outcome. Both
kinds lapse at expires_at and neither needs anybody to resolve it. What differs is when
the table goes back on sale. basket (the default) is for assembling a package: the table
reopens the instant expires_at passes, and status reads expired from that same
instant, so an abandoned basket costs the restaurant nothing. commitment is for capacity
you have already sold and are waiting to be paid for: it keeps its table past the deadline
until a sweep picks it up — the sweep runs every five minutes — and until that runs the
hold still reads held and still occupies. Releasing it at read time would put a table
somebody has already bought back on sale for anyone. Ask for commitment only when that
is what you mean, and treat its deadline as “the table goes within about five minutes”,
not as a moment you can hold a reader to.
A hold and the reservation it becomes are the same booking: hold_AbC converts into
resv_AbC. Once it converts, the hold leaves the GET /v1/holds list (the booking is in
GET /v1/reservations), but GET /v1/holds/{id} still resolves it, with
status: "converted" and the booking’s id in reservation.
The rules are checked here, not at conversion. Capacity, cutoff, blocks and the
section are all evaluated when the hold is taken — that is what the hold buys — and the
conversion does not re-check them. So override belongs on THIS call: sixty ordinary
covers into a service with forty left is a 422 booking_rules_violated here, carrying
slot_no_longer_available in violations, and override: ["slot_no_longer_available"]
is how you place it deliberately. A hold taken with
excluded_from_shift_limits: true is not charged to those caps at all, so they do not
refuse it and no override is needed for them — the cutoff, the blocks, the section and
the table still apply. Refusals on this endpoint take the same shape as on
POST /v1/reservations: always that top-level code and a violations array, one broken
rule or six — read the array, never the code.
Idempotency-Key is REQUIRED, for the same reason it is on the create and one of its
own: a duplicate hold silently takes a second table, and because the id you lost is the
only way to name it, you cannot DELETE what you did not receive. Retrying with the
same key replays the original response. The requested expires_at is deliberately NOT
part of what the key identifies, so a client that recomputes “now + 10 minutes” on every
attempt still gets its replay.
A field this operation does not accept is a 400 naming it in param, never a silent
no-op. The keys inside metadata are yours and are never checked. Fields go in the JSON
body: one sent in the query string is a 400 naming it too.
Holds draw on the WRITE rate budget: 20 in a burst, 1 per second sustained, 2,000 a day, per restaurant across all of its keys.
Authorizations
Section intitulée « Authorizations »Parameters
Section intitulée « Parameters »Header Parameters
Section intitulée « Header Parameters »Unique per hold attempt; re-send the same value to retry safely. At most 255 characters, and no control characters: a longer or unprintable key is a 400 idempotency_key_invalid``, not a silently truncated one. Keys are scoped to your restaurant — another restaurant’s key of the same value is a different key — and are remembered for 72 hours, after which the same value starts a new request.
Pin this request to a dated version of the contract, e.g. 2026-09-01. Omit it and the version pinned on the API key applies. A version we do not publish is a 400 bad_request with param: "Service-Version". The version that was applied comes back on the Service-Version response header, always.
Request Bodyrequired
Section intitulée « Request Bodyrequired »object
The service day of the held seating, YYYY-MM-DD in the restaurant’s timezone. A seating after midnight keeps the date its service began on.
HH:MM on the restaurant’s clock: a slot time GET /v1/availability offered for this service_date.
How many guests the held seating is for.
Defaults to basket. commitment holds the table past its deadline until it is resolved — ask for it only for capacity you have sold.
The deadline you would like. Clamped, never refused; the response carries the one that was granted.
Hold in one area (sec_…). Resolves to the whole section group, exactly as the availability filter does.
Your own reconciliation data. Echoed here and carried onto the reservation when the hold converts.
object
Booking rules you accept breaking, for this hold only. Requires the
reservations:write:override scope. Each value names a rule, and sending it waives that
rule whether or not the refusal reported it — so you can send one ahead of the round trip.
guarantee_required is the rule “this slot asks the guest for a card”. You are only
REFUSED with it when the card cannot be asked for — no address the pay-link can reach —
but waiving it always means the same thing: the booking is made with no card guarantee at
all, and the guest is never asked for one.
Do not charge the resulting booking to the shift’s covers caps. Requires the reservations:write:override scope.
Example
{ "service_date": "2026-10-16", "start_time": "19:30", "party_size": 2}Responses
Section intitulée « Responses »Slot held
A claim on a slot that occupies capacity without being a booking. It holds a real table, so it counts against availability for as long as it lives.
object
The object type: always hold.
Opaque hold id (hold_…). A hold and the reservation it becomes are the same row and share this id’s suffix, so hold_AbC converts into resv_AbC.
basket releases the table the instant expires_at passes; commitment keeps it until the sweep that runs every five minutes picks the hold up. Both lapse on their own; only the minutes between the deadline and the table reopening differ.
Lifecycle of the hold itself, not of a reservation.
held: live and occupying a table. The only state a conversion works from.expired: the deadline passed and the table is back on sale. On abaskethold this is reported from the momentexpires_atpasses, because that kind reopens its table at read time. On acommitmenthold the table is held past the deadline until the five-minute sweep resolves it, so the hold readsheldfor up to five minutes afterexpires_atandexpiredfrom then on. Either way it is the same lapse, andhold.expiredannounces it.released: handed back deliberately — you calledDELETE /v1/holds/{id}, or the restaurant released it from its own board.converted: it became the booking named inreservation.
How many guests the held seating is for.
The service day of the held seating, YYYY-MM-DD in the restaurant’s timezone. A seating after midnight keeps the date its service began on.
Start of the SEATING the hold covers — not the hold deadline. ISO 8601 with the restaurant’s UTC offset.
End of the seating the hold covers, in the same format. null when no seating duration applies.
When the hold lapses. Authoritative: the server sets it, clamping whatever was requested. Null once the hold is over.
When the hold was released or lapsed. Null while it is live, and null once it has converted — a converted hold did not end, it became the booking named in reservation.
The booking this hold became (resv_…), or null.
The tables the hold occupies, each with its seating area.
object
The object type: always table.
The table’s id, tbl_…. Opaque and stable.
The table’s name on the restaurant’s floor plan.
The smallest party the table is meant for. Always a number, never null.
The largest party the table seats on its own — a larger party needs tables joined together. Always a number, never null, and never below min_capacity.
The seating area the table is in.
object
The object type: always section.
Pass this as section_id to GET /v1/availability or to a create — any member of a group resolves to the whole group, so a sec_… read off a reservation’s table works without knowing which row it is.
The area’s name, in the restaurant’s primary_language.
The kind of space, as the restaurant classified it: indoor, outdoor, terrace,
bar, lounge, private_room (a room of its own), patio or rooftop. For a group,
take it from the row where group_id == id.
Whether THIS row is offered for online booking. Per row, never per group: it is deliberately not cascaded between group members, so a group is worth offering when ANY of its rows is true.
The sec_… id that identifies the conceptual seating area this row belongs to.
Rows sharing a group_id are ONE choice to a guest — the same area drawn on more than
one floor plan — so group by it before rendering a picker, or the same area is offered
twice.
Exactly one row per group has group_id == id: take that row’s name and
area_type as the group’s. The others are kept in step by the product and are
normally identical, but that is a behaviour of its editing paths rather than a
constraint, so the naming row is the answer that cannot go stale.
True when this hold does not count against the shift’s covers caps, with the same meaning as on a reservation: they neither charge it nor refuse it. It is carried onto the booking when the hold converts, so converting an exempt hold needs the reservations:write:override scope.
Your own reconciliation data, echoed verbatim. Carried onto the reservation when the hold converts. ⚠️ Readable by every integration this restaurant has authorised, not only by the one that wrote it: holds are scoped to the restaurant, so another of its API keys can read this hold and this object. Put reconciliation ids here, not commercial terms or anything you would not show a competitor.
object
When the hold was taken: ISO 8601 with the restaurant’s UTC offset.
When the hold last changed, in the same format.
Example
{ "object": "hold", "kind": "basket", "status": "held", "tables": [ { "object": "table", "section": { "object": "section", "area_type": "indoor" } } ]}Path to the created hold
Dated API version applied
Present, as true, only when this response replays an earlier request with the same Idempotency-Key; the body is that original response, verbatim. Absent on a first write.
Malformed request, or no Idempotency-Key
The envelope every public-API error answers with, whatever the status.
object
Every error response carries this one object.
object
The broad class of the error, from the HTTP status.
invalid_request_error: a400,403,404,409or422— something about the request to change.authentication_error: a401— the key is missing, invalid, revoked or expired.rate_limit_error: a429— slow down and retry.api_error: any other status, a fault on our side.
The specific error, stable and machine-readable, e.g. not_found. Branch on this, not on message.
A human-readable explanation for the developer. Its wording can change.
The request parameter the error is about, when there is one — e.g. expand or limit. Absent — not null — when the error names no field.
A link to this code in the errors reference. Always present.
Example
{ "error": { "type": "invalid_request_error" }}No API key, or not a live one
The envelope every public-API error answers with, whatever the status.
object
Every error response carries this one object.
object
The broad class of the error, from the HTTP status.
invalid_request_error: a400,403,404,409or422— something about the request to change.authentication_error: a401— the key is missing, invalid, revoked or expired.rate_limit_error: a429— slow down and retry.api_error: any other status, a fault on our side.
The specific error, stable and machine-readable, e.g. not_found. Branch on this, not on message.
A human-readable explanation for the developer. Its wording can change.
The request parameter the error is about, when there is one — e.g. expand or limit. Absent — not null — when the error names no field.
A link to this code in the errors reference. Always present.
Example
{ "error": { "type": "invalid_request_error" }}Unique id for this request. Quote it to support: it is how a specific call is found in our logs. Generated per request, or echoed from the X-Request-Id you sent.
The key does not carry reservations:write
The envelope every public-API error answers with, whatever the status.
object
Every error response carries this one object.
object
The broad class of the error, from the HTTP status.
invalid_request_error: a400,403,404,409or422— something about the request to change.authentication_error: a401— the key is missing, invalid, revoked or expired.rate_limit_error: a429— slow down and retry.api_error: any other status, a fault on our side.
The specific error, stable and machine-readable, e.g. not_found. Branch on this, not on message.
A human-readable explanation for the developer. Its wording can change.
The request parameter the error is about, when there is one — e.g. expand or limit. Absent — not null — when the error names no field.
A link to this code in the errors reference. Always present.
Example
{ "error": { "type": "invalid_request_error" }}This Idempotency-Key already stands for a different hold
The envelope every public-API error answers with, whatever the status.
object
Every error response carries this one object.
object
The broad class of the error, from the HTTP status.
invalid_request_error: a400,403,404,409or422— something about the request to change.authentication_error: a401— the key is missing, invalid, revoked or expired.rate_limit_error: a429— slow down and retry.api_error: any other status, a fault on our side.
The specific error, stable and machine-readable, e.g. not_found. Branch on this, not on message.
A human-readable explanation for the developer. Its wording can change.
The request parameter the error is about, when there is one — e.g. expand or limit. Absent — not null — when the error names no field.
A link to this code in the errors reference. Always present.
Example
{ "error": { "type": "invalid_request_error" }}The hold breaks one or more of the restaurant’s rules
The 422 a write answers with when it broke the restaurant’s booking rules: the envelope above plus the itemised violations. No other status carries that array.
object
Every error response carries this one object.
object
The broad class of the error, from the HTTP status.
invalid_request_error: a400,403,404,409or422— something about the request to change.authentication_error: a401— the key is missing, invalid, revoked or expired.rate_limit_error: a429— slow down and retry.api_error: any other status, a fault on our side.
The specific error, stable and machine-readable, e.g. not_found. Branch on this, not on message.
A human-readable explanation for the developer. Its wording can change.
The request parameter the error is about, when there is one — e.g. expand or limit. Absent — not null — when the error names no field.
A link to this code in the errors reference. Always present.
Every booking rule this request broke, itemised. The whole array is the refusal: the top-level code is the fixed umbrella booking_rules_violated and the top-level message one fixed sentence, for one broken rule as for six. Only a 422 carries it.
object
The rule that was broken, e.g. cutoff_passed.
A human-readable explanation of this violation.
The request field to change to satisfy the rule, when there is one. Absent — not null — otherwise.
Whether a key carrying reservations:write:override may re-submit with this code in override and have the rule waived.
Only on section_occupied: what already holds part of the room during the window — one entry per occupied table, with the time it is held from and to. Never who holds it. Absent on every other code.
object
The occupied table (tbl_…). Null when the room has no tables and another whole-room booking holds it.
When the table is held from: HH:MM on the service_date the request named, in the restaurant’s timezone. Read on the service’s own axis, which runs past midnight — a 00:30 against a 23:30 is half an hour later the same night, not 23 hours earlier.
When the table is held until, on the same clock and the same axis as start_time. null for a booking stored without an end time.
The envelope every public-API error answers with, whatever the status.
object
Every error response carries this one object.
object
The broad class of the error, from the HTTP status.
invalid_request_error: a400,403,404,409or422— something about the request to change.authentication_error: a401— the key is missing, invalid, revoked or expired.rate_limit_error: a429— slow down and retry.api_error: any other status, a fault on our side.
The specific error, stable and machine-readable, e.g. not_found. Branch on this, not on message.
A human-readable explanation for the developer. Its wording can change.
The request parameter the error is about, when there is one — e.g. expand or limit. Absent — not null — when the error names no field.
A link to this code in the errors reference. Always present.
Example
{ "error": { "type": "invalid_request_error" }}Too many requests on the write budget
The envelope every public-API error answers with, whatever the status.
object
Every error response carries this one object.
object
The broad class of the error, from the HTTP status.
invalid_request_error: a400,403,404,409or422— something about the request to change.authentication_error: a401— the key is missing, invalid, revoked or expired.rate_limit_error: a429— slow down and retry.api_error: any other status, a fault on our side.
The specific error, stable and machine-readable, e.g. not_found. Branch on this, not on message.
A human-readable explanation for the developer. Its wording can change.
The request parameter the error is about, when there is one — e.g. expand or limit. Absent — not null — when the error names no field.
A link to this code in the errors reference. Always present.
Example
{ "error": { "type": "invalid_request_error" }}Requests permitted in the write burst
Requests remaining in the write burst
Seconds until the write bucket refills
Which budget the numbers describe. write here.
Seconds to wait before retrying
Unique id for this request. Quote it to support: it is how a specific call is found in our logs. Generated per request, or echoed from the X-Request-Id you sent.