SeatBuilderSeatBuilder Docs

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: 201 again, the label reported held, expiry unchanged (use /extend to push it out).
  • Hold (GA). quantity is 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_exceeded and 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 returns 200 with the identical booked[] 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[], not failed[], with a 200.

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 holdToken client-side. Your server calls only extend / book / release, and never POST /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, then book. 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.

Hold and book lifecycle — SeatBuilder Docs