Hold and book lifecycle
The end-to-end hold → book → release flow with contract-verified request and response JSON for every write and read endpoint.
Hold and book lifecycle
This guide walks the full server-side lifecycle of a seat: hold it,
book it, release it, and read its status or the parent
event at any point. Every JSON block below is copied verbatim from
the API contract (openapi.json) so what you read here is exactly what
the API sends and accepts.
This guide deepens the e-commerce recipe in Hold seats and confirm at checkout — the recipe shows the browser-to-cart-to-checkout choreography; this guide is the endpoint-by-endpoint contract reference. For the concepts behind the statuses, see Core concepts.
All write calls authenticate with a secret key
(X-Api-Key: sk_live_xxx) except hold and release, which also
accept a public key (release only with a holdToken, and only for
held rows under it — see Authentication for
the full key-type matrix). The read calls accept a public or secret key.
Expiry is lazy, the sweep is eventual
A hold's hold_expires_at is the single source of truth for whether it's
still reserved. The moment that timestamp passes, the row is free for
every read and write — /status, a fresh /hold from another token,
/book, /extend — even before anything has physically updated the
row. There is a background sweep job that flips the row to free and
emits seat.hold_expired, but it typically runs within a few seconds of
expiry, not instantly; don't rely on the webhook to know a seat became
free — poll /status or attempt the hold instead, and treat the webhook
as an eventual notification, not a live signal. The same
seat.hold_expired event also fires when another buyer's hold or
booking takes over a seat whose previous hold had already expired.
An expired hold is remembered for its token until the seat is held
again (by any token) or an operator force-releases or changes it. For a
GA area the record stays for that token. That is what lets
/book answer 410 hold_expired and /extend answer
reason: "hold_expired" whether or not the sweep has run. The remembered
hold is free for every other purpose: it never shows in /status, never
counts against GA capacity, and releasing it (before or after the sweep)
returns it under alreadyFree without clearing it.
Retries are safe — every write is naturally idempotent
None of the seat endpoints need an Idempotency-Key header. Retrying
the exact same request with the same holdToken is always safe:
- Hold (seat). Re-holding a label your token already (live-)holds is
a no-op:
201again, the label reportedheld, expiry unchanged (use/extendto push it out). - Hold (GA).
quantityis treated as the token's total desired reservation for that area, not an increment. Repeating the same quantity is a no-op. A different quantity resizes the hold to that new total — grows or shrinks the token's row — checked against the area's remaining capacity excluding the token's own current reservation; if the new total doesn't fit,409 ga_capacity_exceededand the previous quantity is kept. Expiry is unchanged on a re-hold either way (use/extend). - Book. If every requested label is already booked under the same
holdToken, the call returns200with the identicalbooked[]body — no duplicate webhook, no double usage count. A mixed batch (some already booked by this token, the rest still held by it) books the remainder and returns all of them. - Release. A label that's already free is not an error: it comes
back in
alreadyFree[], notfailed[], with a200.
Two flows: which one are you building?
Who takes the initial hold depends on your integration flow. Get this
right or you will double-hold a seat (re-holding a seat the SDK already
holds returns a 409).
- SDK checkout — the browser SDK auto-holds each seat on select and
captures the
holdTokenclient-side. Your server calls onlyextend/book/release, and neverPOST /seats/hold. Every endpoint below still applies — just skip step 1 (hold), because the SDK already holds the seat. - Headless / server-authoritative — no JS SDK; your server owns the
whole lifecycle. It calls
hold(step 1) first, thenbook. The rest of this guide walks this flow end to end.
1. Hold a seat
This step runs only in the headless flow. In SDK checkout the SDK has already taken the hold — skip to step 2.
Take a hold on a single seat with POST /events/{eventKey}/seats/hold.
The holdToken you pass ties the hold to a buyer session; extraData is
an opaque bag you can round-trip (e.g. your order id). A successful hold
returns 201 with the seat's new state and its holdExpiresAt.
The initial hold's ttlSeconds defaults to 900s (15 min) when
omitted; the SDK forwards its holdDurationSeconds into this field — see
the holdDurationSeconds field in the SDK reference for the
full reconciliation.
POST /api/v1/events/evt_q3-2026-jazz-night/seats/hold HTTP/1.1
Host: seatbuilder.org
X-Api-Key: sk_live_xxx
Content-Type: application/json
{"objectLabels":["A-12","A-13"],"holdToken":"7f6c3a91-b1ef-4d0a-9c84-3f12a8e0b5d2","extraData":{"orderId":"ord_98a2"}}{"chartKey":"chart_8a2b1c","eventKey":"evt_q3-2026-jazz-night","holdToken":"7f6c3a91-b1ef-4d0a-9c84-3f12a8e0b5d2","holdExpiresAt":"2026-10-02T18:55:00.000Z","objects":[{"objectLabel":"A-12","status":"held","extraData":{"orderId":"ord_98a2"}},{"objectLabel":"A-13","status":"held","extraData":{"orderId":"ord_98a2"}}]}2. Book the held seats
At checkout, promote the holds to permanent bookings with a single
all-or-nothing call to POST /events/{eventKey}/seats/book. Pass every
seat label in objectLabels[] and the shared holdToken; all the labels
must currently be held by that token. Success returns 200 with a
booked[] array — one object per confirmed seat. Booked rows keep the
holdToken that booked them internally (so the same token can later
/release them), but the token itself is never returned in a response
or a webhook.
POST /api/v1/events/evt_q3-2026-jazz-night/seats/book HTTP/1.1
Host: seatbuilder.org
X-Api-Key: sk_live_xxx
Content-Type: application/json
{"objectLabels":["A-12","A-13"],"holdToken":"7f6c3a91-b1ef-4d0a-9c84-3f12a8e0b5d2","extraData":{"orderId":"ord_98a2"}}{"booked":[{"chartKey":"chart_8a2b1c","eventKey":"evt_q3-2026-jazz-night","objectLabel":"A-12","status":"booked","bookedAt":"2026-10-02T18:46:00.000Z","extraData":{"orderId":"ord_98a2"}},{"chartKey":"chart_8a2b1c","eventKey":"evt_q3-2026-jazz-night","objectLabel":"A-13","status":"booked","bookedAt":"2026-10-02T18:46:00.000Z","extraData":{"orderId":"ord_98a2"}}]}If a label was held by the token but that hold has already expired
(before or after the expiry sweep), the response is 410 hold_expired
and nothing is booked (distinct from 409 not_held_by_token, which
covers every other loss — taken by someone else, or never held). This
applies to general-admission areas too. See Error model.
3. Release seats
Give seats back to inventory with
POST /events/{eventKey}/seats/release. Pass holdToken to scope
the release to that token's own rows — objectLabels[] becomes
optional: omit it to release everything the token holds (public key)
or holds-or-booked (secret key) on the event. A secret key may omit
holdToken entirely for individual seats (admin release of any
held/booked row); a public key must always supply holdToken, and a
GA area always requires one (400 hold_token_required otherwise).
A public key releases only held rows under its token — never a
booked seat. A secret key with a token releases both held and booked
rows under it; without a token it can release any held/booked
individual seat regardless of who holds it.
POST /api/v1/events/evt_q3-2026-jazz-night/seats/release HTTP/1.1
Host: seatbuilder.org
X-Api-Key: pk_live_xxx
Content-Type: application/json
{"objectLabels":["A-12","A-13"],"holdToken":"7f6c3a91-b1ef-4d0a-9c84-3f12a8e0b5d2"}{"chartKey":"chart_8a2b1c","eventKey":"evt_q3-2026-jazz-night","status":"free","releasedAt":"2026-10-02T19:15:30.000Z","released":[{"objectLabel":"A-12"},{"objectLabel":"A-13"}],"alreadyFree":[]}Labels that were already free (never held, or expired) come back in
alreadyFree[] with the same 200 — that's success, not a failure.
GA entries in released[] additionally carry the freed quantity:
{"objectLabel":"GA-LAWN","quantity":2}. If any requested label is
held/booked by a different token — or booked at all, for a public key
— the whole call fails 409 not_held_by_token with failed[] and
nothing is released (all-or-nothing, same as hold/book).
4. Read seat status
Poll the current state of every non-free object in an event with
GET /events/{eventKey}/status. This is a read-only call (no request
body) — a public key is enough. Objects absent from the response are
implicitly free; an expired-but-unswept hold is already reported free
here too (see Expiry is lazy
above). Hold tokens are never returned. Pass your own ?holdToken=
to flag your live holds: mine: true on a seat, mineQuantity on a GA
area. extraData is included for secret-key callers only.
A seat entry: { objectLabel, objectType: "seat", status, categoryKey, holdExpiresAt?, mine? }.
A general-admission area is one aggregated entry per area (emitted
once anything is held or booked): { objectLabel, objectType: "generalAdmission", status, categoryKey, capacity, held, booked, remaining, mineQuantity? }.
GET /api/v1/events/evt_q3-2026-jazz-night/status?holdToken=7f6c3a91-b1ef-4d0a-9c84-3f12a8e0b5d2 HTTP/1.1
Host: seatbuilder.org
X-Api-Key: pk_live_xxx{"eventKey":"evt_q3-2026-jazz-night","objects":[{"objectLabel":"A-12","objectType":"seat","status":"held","categoryKey":"premium","holdExpiresAt":"2026-10-02T18:55:00.000Z","mine":true},{"objectLabel":"A-13","objectType":"seat","status":"booked","categoryKey":"premium"},{"objectLabel":"GA-LAWN","objectType":"generalAdmission","status":"free","categoryKey":"lawn","capacity":500,"held":12,"booked":40,"remaining":448,"mineQuantity":2}]}5. Read the event
Fetch an event's metadata — including the chart it renders and the
published chart version it is pinned to — with GET /events/{eventKey}.
Also a read-only call.
GET /api/v1/events/evt_q3-2026-jazz-night HTTP/1.1
Host: seatbuilder.org
X-Api-Key: pk_live_xxx{"eventKey":"evt_q3-2026-jazz-night","chartKey":"chart_8a2b1c","chartPublishedVersion":3,"name":"Friday jazz night, October 2026","createdAt":"2026-09-18T11:30:00.000Z","updatedAt":"2026-09-18T11:30:00.000Z"}Reset a sandbox event (testing only)
Between automated test runs it's often easier to wipe an event's seat
state than to release every row individually.
POST /events/{eventKey}/reset (secret key, sandbox events only —
403 sandbox_only against production) returns every seat and GA area to
free: it deletes the event's holds/bookings and pending expiry jobs
become no-ops. No webhooks fire and nothing is written to seat history.
POST /api/v1/events/evt_q3-2026-jazz-sandbox/reset HTTP/1.1
Host: seatbuilder.org
X-Api-Key: sk_test_xxx{"eventKey":"evt_q3-2026-jazz-sandbox","reset":true,"removed":12}Putting it together
The authoritative server-side lifecycle is: hold each seat the buyer
selects (step 1), book the whole selection in one atomic call at
checkout (step 2), and release anything the buyer abandons (step 3).
Read status (step 4) to reconcile your own state against the platform,
and read the event (step 5) to confirm which chart version is live.
Every write is naturally idempotent, so retrying any of these calls with
the same holdToken (and, for hold/book, the same request) is always
safe — see Retries are safe
above.
Remember which flow you are in: in SDK checkout the server must
never call /seats/hold (the SDK already holds — start at book),
while in the headless flow the server calls hold then book.
For the full checkout choreography — capturing holds from the SDK,
extending to a payment window, and recovering from a 409 at book time —
see Hold seats and confirm at
checkout. For the webhook that tells your
backend a seat was booked or a hold expired, see
Webhooks.
Core concepts
The mental model behind SeatBuilder — charts vs events, the seat lifecycle, hold-token ownership, and how the browser SDK and the REST API fit together.
General admission
The full GA quantity lifecycle — hold, extend, book, release — with contract-verified request and response JSON, remaining-capacity semantics, and the SDK selection flow.