Hold and book seats at checkout
The deferred-countdown e-commerce flow — instant cross-user lock on select, a short browse TTL, extend to a payment window at checkout, an integrator-owned countdown, and graceful handling of expired or contested holds.
Hold and book seats at checkout
This recipe shows the canonical ticketing flow with a deferred payment countdown. Selecting a seat takes an instant, cross-user lock with a short browse TTL. Only when the buyer advances to checkout does your server extend those seats to a longer payment window — and the visible countdown lives on your checkout page, driven by the deadline the extend call returns.
The mental model is three TTL phases, not one all-in-one ~10-minute timer:
- Select → instant lock (short browse TTL). Picking a seat in the
SDK calls
/holdand immediately reserves the seat for all concurrent viewers. Keep the browse TTL short (SDKholdDurationSeconds— omit for the server default 900s; set e.g. 300s for a short browse window) so abandoned selections free up quickly. - Checkout → extend to the payment window. When the buyer advances
to the payment screen, your server calls
POST /api/v1/events/{eventKey}/seats/extendwith the chart'sholdTokento refresh those seats to a longer payment window (ttlSeconds, default 600s). - Render your own countdown. You display the visible timer on your
checkout page using the
holdExpiresAtvalue returned by/extend. The SDK does not render a countdown for this flow (see SDK config).
The failure modes that matter — 409 Conflict (one or more seats are
no longer held) at book time and the per-seat failed[] results from
both /extend and /book — are handled in-line below. Booking is an
all-or-nothing batch: a single /book call confirms every kept seat
at once, and if any one of them is no longer held the whole call fails
with 409 and nothing is booked. A held-but-expired seat is a
distinct case: book fails 410 hold_expired instead of 409 (whether
or not the background expiry sweep has run), still all-or-nothing (see Error model).
Every write here is naturally idempotent — no Idempotency-Key header
needed. Retrying /book with the same holdToken after a network
timeout is safe: if every seat is already booked under that token, you
get back 200 with the identical booked[] body rather than an error.
1. Capture the hold from the SDK
The onObjectSelected callback fires every time the buyer adds a seat
to their selection. The event payload includes the seat's objectLabel
and a holdToken — the string that proves the buyer reserved this seat.
The seat is held cross-user for the browse TTL (short by default —
see holdDurationSeconds); the longer visible countdown is started
later by the extend call at checkout, not here.
import SeatBuilder from '@seatbuilder/sdk';
const chart = SeatBuilder.render({
publicKey: 'pk_live_xxx',
eventKey: 'evt_xxx',
container: 'seats-container',
apiUrl: 'https://seatbuilder.org',
// A bare number is a total cap; pass { total?, perCategory? } for
// per-category caps (see Render a seat map).
maxSelectedObjects: 4,
onObjectSelected: async ({ objectLabel, holdToken }) => {
// Persist alongside the cart so the buyer can leave + return.
await fetch('/cart/items', {
method: 'POST',
body: JSON.stringify({ objectLabel, holdToken }),
headers: { 'Content-Type': 'application/json' },
});
},
});The transient SDK-side hold is enough for the active tab. Mirroring the hold to your backend lets the buyer close the tab, log in, or finish a multi-step checkout without losing the seat.
2. Extend at checkout (start the payment window)
When the buyer advances to the payment screen, your server refreshes
the browse holds to the longer payment window by calling
POST /api/v1/events/{eventKey}/seats/extend with the same holdToken.
This is a batch, partial-success call: individual seats never fail
the whole request. Seats still validly held by the token come back in
extended[]; the fresh payment-window deadline shared by all of them is
the top-level holdExpiresAt. A seat the token no longer holds comes
back in failed[] with one of three reasons: already_booked (it was
booked under this token — nothing to extend), hold_expired (this
token's hold lapsed, before or after the expiry sweep), or not_held_by_token (someone else holds or
booked it). The one case that does fail the whole call is 409 no_active_hold — the token has no hold or booking anywhere on the
event.
POST /api/v1/events/evt_xxx/seats/extend HTTP/1.1
Host: seatbuilder.org
X-Api-Key: sk_live_xxx
Content-Type: application/json
{
"holdToken": "<the chart holdToken>",
"objectLabels": ["A-12", "A-13"],
"ttlSeconds": 600
}{
"holdExpiresAt": "2026-09-12T19:25:00.000Z",
"extended": [
{ "objectLabel": "A-12" }
],
"failed": [
{ "objectLabel": "A-13", "reason": "not_held_by_token" }
]
}The extend call is the authoritative checkout gate: drop every seat in
failed[], re-prompt the buyer for those, and proceed with the seats in
extended[]. Every successfully-extended seat shares one order-level
deadline, surfaced at the top level as holdExpiresAt — drive your
visible payment-page countdown straight from it. It is null when nothing
was extended (every seat failed).
type ExtendResult = {
holdExpiresAt: string | null;
extended: { objectLabel: string }[];
failed: { objectLabel: string; reason: string }[];
};
async function startPaymentWindow(eventKey: string, holdToken: string, labels: string[]) {
const res = await fetch(
`${API_URL}/api/v1/events/${eventKey}/seats/extend`,
{
method: 'POST',
headers: {
'X-Api-Key': process.env.SEATS_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({ holdToken, labels, ttlSeconds: 600 }),
},
);
const { holdExpiresAt, extended, failed }: ExtendResult = await res.json();
if (failed.length > 0) {
// Lost seats — drop them client-side and re-prompt for the remainder.
for (const f of failed) showToast(`Seat ${f.objectLabel} is no longer held. Please re-select.`);
}
// The visible countdown deadline = the shared order-level hold expiry
// (null when nothing was extended).
const deadline = holdExpiresAt ? Date.parse(holdExpiresAt) : null;
return { deadline, kept: extended.map((s) => s.objectLabel) };
}Render the countdown on your own checkout page (the seat-map chart is usually not mounted there) — the SDK does not show it. See Render your own countdown for the SDK config that keeps the built-in banner off (the default).
3. Confirm at checkout
When the buyer clicks "pay", confirm every kept seat in one call to
POST /api/v1/events/{eventKey}/seats/book. Pass all the labels in
objectLabels[], the shared holdToken, and a single shared extraData
(e.g. your orderId). The call is atomic and all-or-nothing.
POST /api/v1/events/evt_xxx/seats/book HTTP/1.1
Host: seatbuilder.org
X-Api-Key: sk_live_xxx
Content-Type: application/json
{
"objectLabels": ["A-12", "A-13"],
"holdToken": "<the chart holdToken>",
"extraData": { "orderId": "ord_98a2" }
}On success you get 200 with a booked[] array — one object per
confirmed seat, each carrying its chartKey, eventKey, objectLabel,
status, bookedAt, and the shared extraData:
{
"booked": [
{ "chartKey": "chart_8a2b1c", "eventKey": "evt_xxx", "objectLabel": "A-12",
"status": "booked", "bookedAt": "2026-09-12T19:20:00.000Z",
"extraData": { "orderId": "ord_98a2" } },
{ "chartKey": "chart_8a2b1c", "eventKey": "evt_xxx", "objectLabel": "A-13",
"status": "booked", "bookedAt": "2026-09-12T19:20:00.000Z",
"extraData": { "orderId": "ord_98a2" } }
]
}If any one seat is no longer held by the token, the request fails with
409 and a failed[] array — nothing was booked. Each entry carries
the offending objectLabel and a generic not_held_by_token reason:
{
"error": "One or more seats are no longer held by this token",
"failed": [
{ "objectLabel": "A-13", "reason": "not_held_by_token" }
]
}type BookResult = {
booked: {
chartKey: string;
eventKey: string;
objectLabel: string;
status: string;
bookedAt: string;
extraData?: Record<string, unknown>;
}[];
};
type BookConflict = {
error: string;
failed: { objectLabel: string; reason: string }[];
};
async function bookSeats(
eventKey: string,
objectLabels: string[],
holdToken: string,
orderId: string,
) {
const res = await fetch(
`${API_URL}/api/v1/events/${eventKey}/seats/book`,
{
method: 'POST',
headers: {
'X-Api-Key': process.env.SEATS_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
objectLabels,
holdToken,
extraData: { orderId },
}),
},
);
if (res.status === 409 || res.status === 410) {
// All-or-nothing either way: nothing was booked. 409 = not held by this
// token at all; 410 = held by this token but the hold expired. Same
// recovery: drop the lost seats and re-prompt.
const { failed }: BookConflict = await res.json();
throw new SeatsContestedError(failed.map((f) => f.objectLabel));
}
if (!res.ok) {
const body = await res.json();
throw new Error(`book failed: ${body.message ?? body.error}`);
}
const { booked }: BookResult = await res.json();
return booked; // record each in your own orders table
}4. Handle 409 / 410 (one or more seats lost) gracefully
A 409 Conflict from /book means at least one of the seats is not
held by the token at all — never held, or taken by another buyer in the
meantime. A 410 Gone means the token did hold the seat but the hold
had already expired. Both are all-or-nothing: nothing was booked
either way; do not retry the same labels blindly. Handle them
identically — read failed[], drop those labels, re-prompt:
- Read the
failed[]array — every entry'sobjectLabelis a seat to drop. Clear each one client-side (chart.deselect(objectLabel)) or rebuild the cart from server state. - Surface a non-destructive message ("Some seats were no longer held and have been released. Please re-select.").
- Let the buyer pick again. The SDK refreshes seat status when the event re-renders, and the still-held seats can be re-confirmed in a fresh batch.
class SeatsContestedError extends Error {
constructor(public readonly lostLabels: string[]) {
super(`Seats no longer held: ${lostLabels.join(', ')}`);
}
}
try {
const booked = await bookSeats(eventKey, kept, holdToken, orderId);
// Record each booked seat in your own orders table.
} catch (err) {
if (err instanceof SeatsContestedError) {
for (const label of err.lostLabels) chart.deselect(label);
showToast('Some seats were no longer held. Please re-select.');
return;
}
throw err;
}Book distinguishes exactly one thing: whether the token's hold on a lost
seat had expired (410, reason: "hold_expired") or was never
held / taken by someone else (409, reason: "not_held_by_token").
It never discloses who holds a contested seat — so beyond that one
distinction, treat every loss the same: drop and re-prompt.
Putting it together
The complete deferred-countdown checkout flow is:
- Browser captures holds via
onObjectSelected(instant cross-user lock, short browse TTL) and POSTs them to your/cartendpoint. - Buyer advances to checkout. Your backend calls
/seats/extendwith theholdTokento refresh the seats to the longer payment window; it drops anything infailed[]and re-prompts the buyer for those. - Your checkout page renders the visible countdown from the
holdExpiresAtreturned by/extend(the SDK does not show it). - Buyer clicks "pay". Your backend books all the kept seats in one
objectLabels[]call. - On a
409at book time, nothing was booked: your backend readsfailed[], reports those labels to the browser, and the browser deselects them and prompts the buyer to re-pick. - On full success, your backend iterates the
booked[]array, records each booking in your own orders table, and the SDK reflects the seats as booked on next refresh.
See Error model for the full envelope and Seats API for the hold / extend / book / release reference.