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

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

# Hold seats from your server

**POST /v1/events/:eventKey/hold** — Authentication: Secret key

Most holds come from a buyer picking seats in the browser. 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.

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

## 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. Defaults to the event's checkout window. Clamped server-side to a 60-minute ceiling. |
| `replaceHoldId` | `string` | No | Atomically replace an existing hold with this new selection. |

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

## Responses

### 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
    }
  ]
}
```
### 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"`.
### 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"
}
```

## Releasing

**POST /v1/events/:eventKey/release** — Authentication: Secret key

Let a hold you no longer need go back on sale immediately rather than waiting
for it to expire — an abandoned checkout, a declined card, 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,
    body: JSON.stringify({ holdId, bookingRef: payment.orderId }),
  });
} else {
  await fetch(`${base}/release`, {
    method: "POST",
    headers,
    body: JSON.stringify({ labels: ["STALLS-A-12", "STALLS-A-13"], holdId }),
  });
}
```

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

## Let us pick the seats

**POST /v1/events/:eventKey/best-available** — Authentication: Secret key

When the caller does not care *which* seats, only how many — a phone order, 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 — a normal outcome, not an error to alert on.

### Book without holding first

**POST /v1/events/:eventKey/best-available-book** — Authentication: 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" }
```

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

## Keep a hold alive

**POST /v1/events/:eventKey/extend** — Authentication: Secret key

When an order takes longer than the checkout window — an invoice awaiting
approval, a caller hunting for 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"`. There is no recovering it — the buyer has to pick
again. 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 — handing inventory back is never rate-limited.

Source: https://docs.seatlayer.io/server-api/holds/index.mdx
