---
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."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.seatlayer.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Holds and checkout handoff

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.

> **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.

## SeatPicker handoff

```js title="browser/seat-picker.js"
const picker = new seatlayer.SeatPicker({
  container: "#picker",
  event: "ev_9f3a",
  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();
```

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.

## 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).

Source: https://docs.seatlayer.io/buyer-sdk/holds-and-checkout/index.mdx
