/v1/events/:eventKey/holdSecret keyMost holds come from a buyer picking seats in the browser, through the Buyer SDK hold and checkout handoff. 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 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.
New to the model? How seat holds work covers the hold window, two-buyers-one-seat concurrency, expiry, and idempotent confirmation before you reach for the endpoint below.
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. |
Responses
Every requested seat is now held for you until expiresAt.
{
"ok": true,
"holdId": "hold_01J8F2A7MRQ4",
"expiresAt": 1785312452577,
"items": [
{
"label": "STALLS-A-12",
"categoryKey": "stalls",
"tierId": null,
"unitPrice": 7500,
"currency": "GBP",
"quantity": 1
}
]
}Holds are all-or-nothing. If any seat is unavailable, nothing is held.
{
"error": "conflict",
"conflicts": [{ "label": "STALLS-A-13", "status": "held" }]
}A closed event answers 409 with "error": "event_closed".
A label that does not exist, is hidden, or carries a quantity outside the object’s occupancy rules. Nothing is held.
{
"error": "invalid_selection"
}Releasing
/v1/events/:eventKey/releaseSecret keyRelease 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.
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"
}{
"ok": true,
"released": ["STALLS-A-12", "STALLS-A-13"]
}Full flow
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 }),
});
}Let us pick the seats
/v1/events/:eventKey/best-availableSecret keyWhen 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.
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. |
nearLabels |
string[] |
No | 1 to 10 labels to pick close to, such as a buyer’s original seats. The pick stays in one contiguous group and prefers the same rows. |
preferAisle |
boolean |
No | Prefer a group with at least one aisle seat: a row end, or a seat beside a gap. Seats behind the stage still rank last. Off by default. |
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
/v1/events/:eventKey/best-available-bookSecret keyFor 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.
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" }Set seats aside for a longer offer
/v1/events/:eventKey/best-available-blockSecret keyA buyer hold lasts at most an hour. When you need to offer replacement seats
to a customer who may take a day to answer, for example after a paid order had
to move, pick the seats and block them instead. The block ends by itself at
releaseAt.
POST /v1/events/summer-gala-2026/best-available-block HTTP/1.1
Authorization: Bearer sk_test_••••••••
Content-Type: application/json
{
"qty": 2,
"nearLabels": ["C-14", "C-15"],
"releaseAt": 1767290400000,
"blockRef": "reseat-order-8812"
}{
"ok": true,
"labels": ["C-16", "C-17"],
"items": [
{ "label": "C-16", "objectId": "seat-c-16", "objectType": "seat", "categoryKey": "stalls", "tierId": null, "unitPrice": 60, "currency": "EUR", "quantity": 1 },
{ "label": "C-17", "objectId": "seat-c-17", "objectType": "seat", "categoryKey": "stalls", "tierId": null, "unitPrice": 60, "currency": "EUR", "quantity": 1 }
],
"placements": [
{ "label": "C-16", "sectionLabel": null, "rowLabel": "C", "seatLabel": "16" },
{ "label": "C-17", "sectionLabel": null, "rowLabel": "C", "seatLabel": "17" }
],
"releaseAt": 1767290400000
}It takes the same qty, categoryKey, zoneId, and nearLabels fields as
best available, plus two required fields:
| Field | Type | Description |
|---|---|---|
releaseAt |
number |
Epoch ms when the block ends. At least 1 minute and at most 72 hours from now; otherwise 400 releaseAt_required |
blockRef |
string |
Your reference for the offer, up to 160 characters, recorded in the event log; otherwise 400 blockRef_required |
To confirm, call POST /v1/events/:eventKey/book
with the labels, your bookingRef, and the same blockRef (no holdId). To withdraw the offer early,
unblock them. It shares the server hold budget.
Keep a hold alive
/v1/events/:eventKey/extendSecret keyWhen 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.
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: 600 requests per minute,
shared by hold, extend, and the three Best Available routes. Exceeding the
budget answers 429 with Retry-After and a retryAfterSeconds hint.
Releases do not consume the budget. Returning inventory is never rate-limited.
See rate limits for every budget and the response
headers, and concurrency and performance
for how simultaneous holds on the same seats are resolved.