SeatBuilderSeatBuilder Docs
Recipes

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:

  1. Select → instant lock (short browse TTL). Picking a seat in the SDK calls /hold and immediately reserves the seat for all concurrent viewers. Keep the browse TTL short (SDK holdDurationSeconds — omit for the server default 900s; set e.g. 300s for a short browse window) so abandoned selections free up quickly.
  2. Checkout → extend to the payment window. When the buyer advances to the payment screen, your server calls POST /api/v1/events/{eventKey}/seats/extend with the chart's holdToken to refresh those seats to a longer payment window (ttlSeconds, default 600s).
  3. Render your own countdown. You display the visible timer on your checkout page using the holdExpiresAt value 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:

  1. Read the failed[] array — every entry's objectLabel is a seat to drop. Clear each one client-side (chart.deselect(objectLabel)) or rebuild the cart from server state.
  2. Surface a non-destructive message ("Some seats were no longer held and have been released. Please re-select.").
  3. 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:

  1. Browser captures holds via onObjectSelected (instant cross-user lock, short browse TTL) and POSTs them to your /cart endpoint.
  2. Buyer advances to checkout. Your backend calls /seats/extend with the holdToken to refresh the seats to the longer payment window; it drops anything in failed[] and re-prompts the buyer for those.
  3. Your checkout page renders the visible countdown from the holdExpiresAt returned by /extend (the SDK does not show it).
  4. Buyer clicks "pay". Your backend books all the kept seats in one objectLabels[] call.
  5. On a 409 at book time, nothing was booked: your backend reads failed[], reports those labels to the browser, and the browser deselects them and prompts the buyer to re-pick.
  6. 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.