/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. |
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" }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. Exceeding the budget
answers 429 with a retryAfterSeconds hint. Releases do not consume the
budget. Returning inventory is never rate-limited.