/v1/events/:eventKey/holdSecret keyMost 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 or releases them.
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. |
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 keyLet 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.
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,
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 }),
});
}Let us pick the seats
/v1/events/:eventKey/best-availableSecret keyWhen 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.
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
/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" }Keep a hold alive
/v1/events/:eventKey/extendSecret keyWhen 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.
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.