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.
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 SeatsIO from '@seats/sdk';
const chart = SeatsIO.render({
publicKey: 'pk_live_xxx',
eventKey: 'evt_xxx',
container: 'seats-container',
apiUrl: 'https://api.your-host.example',
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: it never returns 409. 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. Any seat the token no longer holds (expired or taken by
someone else in the meantime) comes back in failed[] with a generic
not_held_by_token reason — without failing the rest of the request.
POST /api/v1/events/evt_xxx/seats/extend HTTP/1.1
Host: api.your-host.example
X-Api-Key: sk_live_xxx
Content-Type: application/json
{
"holdToken": "<the chart holdToken>",
"labels": ["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: api.your-host.example
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) {
// All-or-nothing: nothing was booked. 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 (one or more seats lost) gracefully
A 409 Conflict from /book means at least one of the seats is no
longer held by the token — the hold lapsed, or another buyer grabbed it
in the meantime. Because booking is all-or-nothing, nothing was
booked; do not retry the same labels blindly. Instead:
- 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;
}The same not_held_by_token reason covers every loss cause (expired,
taken, wrong token) — the API never discloses which, so treat them all
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.