---
title: "Hold seats from your server"
description: "Reserve seats by label from your backend with a secret key, then book or release them before the hold expires."
---

<ApiEndpoint method="POST" path="/v1/events/:eventKey/hold" auth="Secret key" />

Most holds come from a buyer picking seats in the browser, through the
[Buyer SDK hold and checkout handoff](/buyer-sdk/holds-and-checkout/). This
endpoint is for
the ones that do not: a phone or box-office order, an invoice reserved while
payment authorizes, an allocation held for a partner. Your backend reserves the
seats itself, then [books](/server-api/booking/) or releases them.

A hold is not a sale, and holding costs nothing: only a confirmed sold seat
consumes a credit under
[per-sold-seat pricing](https://seatlayer.io/pricing/).

New to the model? [How seat holds work](/server-api/how-seat-holds-work) covers
the hold window, two-buyers-one-seat concurrency, expiry, and idempotent
confirmation before you reach for the endpoint below.

<Aside type="note" title="Which hold do I want?">
  If a buyer is choosing seats on screen, the SDK already holds them and hands
  your backend an opaque `holdId`. Use that. Reach for this endpoint when there
  is no browser in the loop.
</Aside>

## Request

```http title="HTTP request"
POST /v1/events/summer-gala-2026/hold HTTP/1.1
Host: api.seatlayer.io
Authorization: Bearer sk_test_••••••••
Content-Type: application/json

{
  "labels": ["STALLS-A-12", "STALLS-A-13"],
  "ttlMs": 900000
}
```

| Field | Type | Required | Description |
|---|---|---:|---|
| `labels` | `string[]` | Yes* | Seat labels to reserve. |
| `selections` | `object[]` | Yes* | Instead of `labels`, when you need a tier or a quantity: `{ label, tierId?, quantity? }`. Required for a variable-occupancy object such as a shared table or a GA area. |
| `ttlMs` | `number` | No | Requested hold window. For this trusted server endpoint it overrides the event duration; when omitted, the event duration and then the 15-minute default apply. Clamped to 60 minutes. |
| `replaceHoldId` | `string` | No | Atomically replace an existing hold with this new selection. |

<Aside type="caution" title="Prices are ours, not yours">
  Prices, tiers, currency and occupancy limits are resolved from the event's own
  published chart. Anything you send about money is ignored. Build your order
  total from the `items` in the response.
</Aside>

## Responses

<Tabs>
  <TabItem label="201 Held">
    Every requested seat is now held for you until `expiresAt`.

    ```json title="201 response"
    {
      "ok": true,
      "holdId": "hold_01J8F2A7MRQ4",
      "expiresAt": 1785312452577,
      "items": [
        {
          "label": "STALLS-A-12",
          "categoryKey": "stalls",
          "tierId": null,
          "unitPrice": 7500,
          "currency": "GBP",
          "quantity": 1
        }
      ]
    }
    ```
  </TabItem>
  <TabItem label="409 Conflict">
    Holds are all-or-nothing. If any seat is unavailable, nothing is held.

    ```json title="409 response"
    {
      "error": "conflict",
      "conflicts": [{ "label": "STALLS-A-13", "status": "held" }]
    }
    ```

    A closed event answers `409` with `"error": "event_closed"`.
  </TabItem>
  <TabItem label="422 Invalid selection">
    A label that does not exist, is hidden, or carries a quantity outside the
    object's occupancy rules. Nothing is held.

    ```json title="422 response"
    {
      "error": "invalid_selection"
    }
    ```
  </TabItem>
</Tabs>

## Releasing

<ApiEndpoint method="POST" path="/v1/events/:eventKey/release" auth="Secret key" />

Release a hold you no longer need immediately rather than waiting for it to
expire. Common cases include an abandoned checkout, a declined card, or a
cancelled phone order.

```http title="HTTP request"
POST /v1/events/summer-gala-2026/release HTTP/1.1
Authorization: Bearer sk_test_••••••••
Content-Type: application/json

{
  "labels": ["STALLS-A-12", "STALLS-A-13"],
  "holdId": "hold_01J8F2A7MRQ4"
}
```

```json title="200 response"
{
  "ok": true,
  "released": ["STALLS-A-12", "STALLS-A-13"]
}
```

## Full flow

```js title="server/reserve.js"
const base = "https://api.seatlayer.io/v1/events/summer-gala-2026";
const headers = {
  authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`,
  "content-type": "application/json",
};

// 1. Reserve the seats.
const held = await fetch(`${base}/hold`, {
  method: "POST",
  headers,
  body: JSON.stringify({ labels: ["STALLS-A-12", "STALLS-A-13"] }),
});

if (held.status === 409) return seatsNoLongerAvailable();
const { holdId, items } = await held.json();

// 2. Charge from OUR prices, never the caller's.
const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
const payment = await charge(total);

// 3. Book it, or hand the seats straight back.
if (payment.ok) {
  await fetch(`${base}/book`, {
    method: "POST",
    headers,
    // Pin the seats you charged for: `409 hold_changed` means the buyer
    // changed seats after this hold was priced. Refund; do not retry.
    body: JSON.stringify({ holdId, labels: items.map((item) => item.label), bookingRef: payment.orderId }),
  });
} else {
  await fetch(`${base}/release`, {
    method: "POST",
    headers,
    body: JSON.stringify({ labels: ["STALLS-A-12", "STALLS-A-13"], holdId }),
  });
}
```

<Aside type="tip" title="Holds expire on their own">
  If your backend dies mid-checkout, the seats return to sale when the hold
  lapses. Releasing is an optimization for the buyer behind you, not a
  correctness requirement.
</Aside>

## Let us pick the seats

<ApiEndpoint method="POST" path="/v1/events/:eventKey/best-available" auth="Secret key" />

When the caller does not care *which* seats, only how many, such as for a phone
order or a "best 4 together" request, ask for best available instead of naming labels. The
picker is the same one the buyer widget uses, so a phone order and a web order
get the same answer for the same inventory.

```http title="HTTP request"
POST /v1/events/summer-gala-2026/best-available HTTP/1.1
Authorization: Bearer sk_test_••••••••
Content-Type: application/json

{ "qty": 4, "categoryKey": "stalls" }
```

| Field | Type | Required | Description |
|---|---|---:|---|
| `qty` | `number` | Yes | How many to pick. Clamped to the server maximum rather than rejected. |
| `categoryKey` | `string` | No | Restrict to one price category. |
| `zoneId` | `string` | No | Restrict to one zone. An unknown zone answers `422`, never silently ignored. |
| `ttlMs` | `number` | No | Requested hold window, same contract as `/hold`. |

The response is a hold: `holdId`, `expiresAt`, the chosen `labels`, and priced
`items`. A `409` with `reason: "sold_out"` or `"not_enough_together"` means the
request could not be satisfied. Treat that as a normal outcome, not an alert.

### Book without holding first

<ApiEndpoint method="POST" path="/v1/events/:eventKey/best-available-book" auth="Secret key" />

For box-office and phone sales where payment is already taken, pick and book in
one call. `bookingRef` is required so the sale can be reconciled against your
own order.

```http title="HTTP request"
POST /v1/events/summer-gala-2026/best-available-book HTTP/1.1
Authorization: Bearer sk_test_••••••••
Content-Type: application/json

{ "qty": 2, "bookingRef": "phone-1183" }
```

<Aside type="caution" title="Prefer this over hold-then-book">
  Doing it as two calls leaves inventory stranded until the hold expires
  whenever the second call fails. One call has no gap to fail in.
</Aside>

## Keep a hold alive

<ApiEndpoint method="POST" path="/v1/events/:eventKey/extend" auth="Secret key" />

When an order takes longer than the checkout window, for example while an invoice
awaits approval or a caller finds their card, extend the hold rather than
releasing and re-holding. Releasing first hands the seats to whoever is racing
for them in between.

```http title="HTTP request"
POST /v1/events/summer-gala-2026/extend HTTP/1.1
Authorization: Bearer sk_test_••••••••
Content-Type: application/json

{ "holdId": "h_9f2c…", "ttlMs": 600000 }
```

A hold that is already gone, expired, or at its renewal cap answers `409` with
`error: "cannot_extend"`. The hold cannot be recovered; the buyer must pick
again. On this trusted server endpoint, an explicit `ttlMs` takes priority over
the event duration. Extensions share the hold budget and the same server-side
TTL ceiling, so a hold cannot be renewed indefinitely.

## Rate limits

Server holds are budgeted per secret key rather than per IP, because one backend
legitimately speaks for every buyer on your platform. Exceeding the budget
answers `429` with a `retryAfterSeconds` hint. Releases do not consume the
budget. Returning inventory is never rate-limited.