---
title: "Holds and checkout handoff"
description: "Protect a buyer's selection in the browser, hand an opaque hold to your backend, and release or restore it safely."
---

A hold temporarily protects inventory while a buyer completes checkout. It is not a sale. The Buyer SDK creates the hold; your server inspects and books it. For box-office, invoice, or partner reservations that never touch a browser, your backend can create one directly with the [server-side hold endpoint](/server-api/holds/).

<Aside type="tip" title="Using SeatPicker?">
  `SeatPicker` creates the hold when the buyer uses its checkout action and returns a complete handoff through `onCheckout`. Use the headless `SeatingChart` methods only when your product owns the surrounding selection and checkout controls.
</Aside>

<FlowDiagram
  label="Buyer SDK hold and checkout handoff"
  steps={[
    {
      actor: "Buyer",
      title: "Selects inventory",
      detail: "The buyer chooses seats, a table, booth, or general-admission quantity in your interface.",
      tone: "buyer",
    },
    {
      actor: "Buyer SDK",
      title: "Creates a temporary hold",
      detail: "SeatPicker or SeatingChart returns an opaque hold ID and expiry instead of a permanent booking.",
      tone: "seatlayer",
    },
    {
      actor: "Browser handoff",
      title: "Sends only the hold ID",
      detail: "Your checkout route receives the hold identity and your own cart context, never a secret key or trusted price.",
      tone: "outcome",
    },
    {
      actor: "Your server",
      title: "Builds the trusted order",
      detail: "It inspects the active hold, calculates the charge from its returned items, and runs your payment workflow.",
      tone: "payment",
    },
    {
      actor: "Your server",
      title: "Books once or recovers",
      detail: "Use the stable order ID as bookingRef. Confirm success, or handle expiry and conflicts without a partial sale.",
      tone: "platform",
    },
  ]}
/>

## SeatPicker handoff

<Aside type="caution" title="Choose the event's browser-access lane">
  An ordinary Public sale on a Platform/SDK event uses its `publicKey` from an
  exact registered Embed domain. Login, presale, partner, and channel audiences
  use a short-lived `bse_…` from your authenticated
  [`buyerAccessTokenProvider`](/server-api/buyer-access-sessions/); that explicit
  credential always wins over `publicKey`. Managed public/unlisted events stay
  anonymous and use only the event key. An embed-only Platform event with none
  of its applicable credentials deliberately returns `404`.
</Aside>

```js title="browser/seat-picker.js"
const picker = new seatlayer.SeatPicker({
  container: "#picker",
  event: "ev_9f3a",
  // Public Platform sale: publishable, mode-matched account key. SeatLayer
  // validates this page's exact registered origin and keeps the grant in memory.
  publicKey: "pk_test_…",
  onCheckout: async (hold, seats, handoff) => {
    await fetch("/api/checkout/seats", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ holdId: handoff.holdId }),
    });
  },
});

await picker.render();
```

For private inventory, replace `publicKey` with a
`buyerAccessTokenProvider` backed by your authenticated server route. The hold
handoff below is identical in both lanes.

The third callback argument is the stable checkout handoff:

```ts title="CheckoutHandoff.ts"
interface CheckoutHandoff {
  holdId: string;
  expiresAt: number;
  currency: string;
  lineItems: CheckoutLineItem[];
  total: number;
}
```

Browser totals are useful for presentation, but your server must inspect the hold before creating the trusted order amount.

## Headless SeatingChart flow

```js title="browser/headless-checkout.js"
const chart = new seatlayer.SeatingChart({
  container: "#chart",
  event: "ev_9f3a",
  onSelectionChange: (seats) => updateYourCart(seats),
  onHold: ({ holdId, expiresAt }) => {
    saveCheckoutHandoff({ holdId, expiresAt });
  },
  onError: (error) => reportPickerError(error),
});

await chart.render();

document.querySelector("#continue").addEventListener("click", async () => {
  const hold = await chart.hold();

  if (!hold) {
    showMessage("One of those seats is no longer available. Please choose again.");
    return;
  }

  await beginCheckout({ holdId: hold.holdId });
});
```

`hold()` resolves to:

| Field | Meaning |
|---|---|
| `holdId` | Opaque capability identifying this hold |
| `expiresAt` | Absolute expiry timestamp in epoch milliseconds |
| `seats` | Held selection for buyer-side UI |
| `items` | Resolved hold line items |

The public method returns `null` when the hold cannot be created and sends structured unexpected errors to `onError`.

## Inspect from your server

```js title="server/inspect-hold.js"
const response = await fetch(
  `https://api.seatlayer.io/v1/events/${eventKey}/holds/${holdId}`,
  {
    headers: {
      authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`,
    },
  },
);

if (!response.ok) {
  return handleInactiveOrInvalidHold(response.status);
}

const hold = await response.json();
const order = await createOrderFromTrustedItems(hold.items);
```

Do not accept buyer-posted labels, tiers, quantities, prices, or currency as
authoritative checkout input. For a channel-priced item, the inspected hold also
returns `channelId` and the frozen `channelPricingVersion`; a later organizer
price edit does not rewrite that hold.

## Restore a hold

`SeatPicker` restores its active hold from `sessionStorage` by default. When your application owns cart persistence:

```js title="browser/restore-hold.js"
const picker = new seatlayer.SeatPicker({
  container: "#picker",
  event: eventKey,
  restoreHold: false,
  initialHoldId: cart.seatlayerHoldId,
  onHoldChange: (hold) => {
    persistCart({
      seatlayerHoldId: hold?.holdId ?? null,
      expiresAt: hold?.expiresAt ?? null,
    });
  },
  onHoldRestored: (hold) => {
    renderCountdown(hold.expiresAt);
  },
});
```

For a headless chart, call `resumeHold(holdId)`. Restoring verifies the hold and does not extend its expiry.

## Extend a hold

The SDK can request more time for the current hold:

```js title="browser/extend-hold.js"
const extended = await chart.extendHold();

if (!extended) {
  returnBuyerToSelection();
} else {
  renderCountdown(extended.expiresAt);
}
```

An active hold may be extended at most three times. A refusal is a normal outcome when the hold is missing, inactive, expired, or has reached the renewal limit.

## Release a hold

Release inventory explicitly when the buyer abandons checkout or chooses to start over:

```js title="browser/release-hold.js"
await chart.release();
```

If you do nothing, the server releases the hold at `expiresAt`.

## Conflict behavior

| Moment | Signal | Product response |
|---|---|---|
| Hold creation | `hold()` returns `null` | Refresh selection and explain that availability changed |
| Hold inspection | `404` or inactive hold | Do not create a new charge; return to selection |
| Booking | `409 conflict` | Void/refund according to payment state and let the buyer reselect |
| Restore | `null` / callback not fired | Clear stale cart hold state |
| Extend | `null` | Continue expiry recovery; do not promise more time |

## Verification checklist

- [ ] A selection produces one hold and one stored `holdId`.
- [ ] A competing browser cannot hold the same seat.
- [ ] Refresh restores the active hold only when configured.
- [ ] Release returns inventory to other buyers.
- [ ] Expiry clears the buyer cart and selection state.
- [ ] The backend calculates the charge from inspected hold items.
- [ ] No secret key appears in client code.

Continue to [Booking held seats](/server-api/booking) and [Handling hold expiry](/buyer-sdk/hold-expiry).