Get a layout
- Who may call it
- Anyone who may view the event (often public)
- Responses
- 200
200 with the layout document. Include revision.
Your backend · 11 endpoints
This is the API @openseat/adapter-rest expects. It is a recommendation, not a requirement: any API works through a custom adapter. The OpenSeat packages need no server of their own: implement this contract in the backend you already have, in any language.
All bodies are JSON. Times are ISO 8601 in UTC.
At a glance
In TypeScript
| Endpoints | Caller |
|---|---|
| Read layouts, availability | Anyone who may view the event (often public) |
| Create, hold, release | Signed-in customers (or anonymous sessions you can rate-limit) |
| Confirm | The customer who owns the hold, after payment |
| Write layouts | Venue staff |
Use what you already have: bearer tokens (Authorization: Bearer …), or cookie sessions with CSRF protection. Recommended authorisation:
| Endpoint group | Who |
|---|---|
| Read layouts, availability | Anyone who may view the event (often public) |
| Create, hold, release | Signed-in customers (or anonymous sessions you can rate-limit) |
| Confirm | The customer who owns the hold, after payment |
| Write layouts | Venue staff |
Enforce ownership: a customer must only read, release or confirm their own holds.
Anonymous buyers. Buyers need not sign in. One way: give each browser a random, HttpOnly buyer cookie on its first visit and store only its sha256, scope holds, confirmations and "my bookings" to it, and answer another browser's hold with 404 HOLD_NOT_FOUND rather than 403, so hold IDs cannot be probed. With cookies, require an Origin header equal to your site's origin on every POST, PUT, PATCH and DELETE (403 FORBIDDEN otherwise) as the CSRF defence, and call the adapter with credentials: "same-origin".
{
"error": {
"code": "SEATS_UNAVAILABLE",
"message": "2 seats are no longer available.",
"unavailableSeatIds": ["A:3", "A:4"]
}
}code should be one of SEATS_UNAVAILABLE, AREA_SOLD_OUT, LIMIT_EXCEEDED, HOLD_EXPIRED, HOLD_NOT_FOUND, PAYMENT_REJECTED, CONFLICT, UNAUTHORIZED, FORBIDDEN, VALIDATION, RATE_LIMITED, SERVER. Use the matching HTTP status (see the error table), and always send the code in the body for AREA_SOLD_OUT and LIMIT_EXCEEDED, since no HTTP status maps to them:
{
"error": {
"code": "AREA_SOLD_OUT",
"message": "Fan pit has only 3 places left.",
"unavailableAreaIds": ["ga-fan-pit"]
}
}{ "error": { "code": "LIMIT_EXCEEDED", "message": "You can buy up to 10 tickets." } }message is shown to buyers: use names ("Row C, Seat 5", "Fan pit"), never IDs.
| Response | Code | Retry |
|---|---|---|
Body has error.code from the contract | that code (AREA_SOLD_OUT and LIMIT_EXCEEDED only arrive this way) | per code |
| 400, 422 | VALIDATION | no |
401 (after one onUnauthorized retry) | UNAUTHORIZED | no |
| 402 | PAYMENT_REJECTED | no |
| 403 | FORBIDDEN | no |
| 404 on hold routes | HOLD_NOT_FOUND | no |
| 409, 412 | CONFLICT (or body code, e.g. SEATS_UNAVAILABLE, AREA_SOLD_OUT) | no |
| 410 | HOLD_EXPIRED | no |
| 429 | RATE_LIMITED | yes |
| 5xx | SERVER | yes |
| Network failure | NETWORK | yes |
| Timeout | TIMEOUT | yes |
| Caller aborted | ABORTED | no |
| Response does not match the contract | INVALID_RESPONSE | no |
200 with the layout document. Include revision.
Body: a layout document. Validate it with validateLayout from @openseat/core (it runs in Node). 201 with { "revision": 1, "updatedAt": "…" }, or 422 with the validation errors in error.details.
Headers: If-Match: "<revision the client edited>". Body: the layout (or, with the adapter's sendManifest option, { "layout": { … }, "manifest": { … } }).
If-Match revision: store it with revision + 1, respond 200 { "revision": 8, "updatedAt": "…" }.409 (or 412) with the stored document: { "error": { "code": "CONFLICT", "message": "…" }, "current": { …layout… } }. Never overwrite.If-Match is a 428 or 400, not an unconditional write.This is optimistic concurrency: no locks while editing, and the editor shows the user both versions when a conflict happens. The client sends force only as the user's choice to overwrite after a conflict; the request is then an ordinary PUT with the newer If-Match, so the rule above still holds.
Identity rules. The layout ID in the URL is the record being written:
body.id (or body.layout.id) to equal :layoutId; respond 422 otherwise. Never store a document under a different ID than the URL names, and never let a save change a layout's ID.validateLayout from @openseat/core and refuse 422 on errors. Do not trust the client's validation.Optional. Body: a layout. 200 { "valid": true, "errors": [], "warnings": [...] }. Useful when layouts are imported by other tools.
The editor sends an InventoryManifest with every save (SaveRequest.manifest): one entry per seat with its public seat ID, section, row or table, category and position, plus the areas sold as units (standing areas with their capacity, booths with an inventoryId, tables marked bookAsWhole). Build it yourself from the stored layout with buildInventoryManifest(layout); on the server, rebuild it from the validated layout rather than trusting the copy in the request.
Diff each save against the previous one to reconcile inventory:
import { buildInventoryManifest, diffManifests, validateLayout, type InventoryManifest } from "@openseat/core";
/** Checks a PUT /v1/layouts/:layoutId body. Returns the status and body to send, or the manifest to store. */
export function checkLayoutSave(
layoutId: string,
body: unknown,
previous: InventoryManifest,
soldOrHeld: ReadonlySet<string>, // inventory keys with tickets sold or held, in any event using this layout
) {
const raw = (body as { layout?: unknown } | null)?.layout ?? body; // bare layout, or the sendManifest envelope
const result = validateLayout(raw);
if (!result.valid || !result.layout) {
return {
status: 422,
body: { error: { code: "VALIDATION", message: "The layout is not valid.", details: result.errors } },
};
}
if (result.layout.id !== layoutId) {
return { status: 422, body: { error: { code: "VALIDATION", message: "The layout ID does not match the URL." } } };
}
const next = buildInventoryManifest(result.layout); // never trust the manifest in the request
const { removedSeatIds } = diffManifests(previous, next);
const nextAreas = new Set(next.areas.map((a) => a.inventoryId));
const removedAreaIds = previous.areas.map((a) => a.inventoryId).filter((id) => !nextAreas.has(id));
if ([...removedSeatIds, ...removedAreaIds].some((key) => soldOrHeld.has(key))) {
return {
status: 409,
body: { error: { code: "CONFLICT", message: "Seats that are sold or held cannot be removed." } },
};
}
return { status: 200, layout: result.layout, manifest: next };
}diffManifests compares seats only (addedSeatIds, removedSeatIds, movedSeatIds, relabeledSeatIds); compare areas by inventoryId yourself, as above.
The editor's save guard asks the user before a save removes sold or held seats (give LayoutEditor an isInventoryInUse check backed by your bookings, and unsold removals go through without a question), but only your server knows which seats are sold or held in which events: decide there whether a removal is allowed.
Saving a layout never touches bookings: keep confirmed bookings in your own tables and carry them over when an event moves to a new revision.
The browser applies these rules for a good experience. A client can be modified, so the server must check each one again on every hold, confirmation and save, using its own data:
| Rule | Check on the server |
|---|---|
maxTickets / maxSeats / minSeats | One per-buyer limit across seats, standing places, booths and whole tables (a whole table counts its seats), per hold and per customer across holds; refuse with LIMIT_EXCEEDED. |
| Not for sale | Every key in unsellableInventory(layout).inventoryIds for the event's layout revision is blocked: never held or sold, whatever the client sends. |
allowedCategories | Every seat's category (from your event's layout snapshot) is one the customer may buy, such as a presale or members-only tier. |
| Mixed categories | When the event forbids mixing (allowMixedCategories: false), all seats share one category. |
| Orphan seats | When you prevent single-seat gaps (preventOrphans), re-run the check against current availability, not the client's view of it. |
bookAsWhole tables and booths | A hold for part of a whole table is refused; a table or booth is held as one unit (quantity 1). |
| Standing capacity and quantities | Quantity is a positive integer, and sold + held + requested ≤ the area's capacity (atomically, like seat holds); otherwise AREA_SOLD_OUT. |
| Seat-to-event membership | Every seat ID exists in the layout revision the event uses. Reject unknown IDs; never create inventory from a client-supplied ID. |
| Revision | Holds and bookings refer to the event's fixed layout revision, not to whatever the latest layout is. |
| Duplicate seat IDs | De-duplicate seatIds in a request (or reject duplicates) before counting or holding. |
| Request size | Cap body size (for example 1 MB for holds, a few MB for layouts; the editor refuses imports above 20 MB) and the number of seat IDs per request. |
| Rate limits | Per customer and per IP on holds and confirmations, so one client cannot hold a whole venue or probe availability at speed (429 RATE_LIMITED). |
| Prices | Price every order from your price list for the event. Never accept a price, priceLabel or categoryPrices value from the client. |
| Layout writes | URL ID equals body ID, per-layout write permission, If-Match revision, validateLayout, and no removal of sold or held seat IDs. |
| Bookings across layout saves | Confirmed bookings live in your tables, not in the layout. A new revision keeps every booking whose key still exists and re-blocks Not for sale keys. |
An event uses one layout revision. Snapshot the layout when you create the event (or reference a fixed revision) so later edits to the venue cannot change an event that is on sale.
Optional: the event's seat list with categories and prices, if you serve them separately from availability.
{
"eventId": "e1",
"seats": [
{ "seatId": "stalls:A:1", "status": "booked", "version": 412 },
{ "seatId": "stalls:A:2", "status": "available", "priceLabel": "₹450", "categoryId": "std", "version": 388 },
{ "seatId": "stalls:A:3", "status": "held", "expiresAt": "2026-10-18T10:15:00Z", "version": 415 }
],
"serverTime": "2026-10-18T10:05:00Z"
}Seats you omit are treated as available. version must increase every time a seat changes. Cache briefly if you like; it is a display hint.
Areas. Standing areas, booths and whole tables go in an optional areas array next to seats, keyed by inventory key (a standing area's or booth's inventoryId, a whole table's object ID):
{
"eventId": "e1",
"seats": [{ "seatId": "T3-1", "status": "booked", "version": 51 }],
"areas": [
{ "inventoryId": "ga-fan-pit", "status": "available", "remaining": 796, "capacity": 800, "version": 12 },
{ "inventoryId": "booth-B7", "status": "blocked", "remaining": 0, "capacity": 1, "version": 3 },
{ "inventoryId": "dinner:table:3", "status": "booked", "remaining": 0, "capacity": 1, "version": 9 }
]
}remaining is how many places can still be held right now; status is "available" while anything is left, "blocked" when not for sale, otherwise "held" or "booked". Servers that predate areas can leave the array out. Also report each area by its inventory key in seats ({ "seatId": "booth-B7", "status": "blocked" }) if you want the map to colour it: booths, standing areas and whole tables share the map's status keys with seats.
Not for sale. Report every key from unsellableInventory(layout).inventoryIds (and the seats of whole tables among them) as "blocked", and refuse holds on them.
Headers: Idempotency-Key: <uuid>. Body: { "seatIds": ["stalls:A:5", "stalls:A:6"] }, with an optional areas array of { inventoryId, quantity } for standing places, booths and whole tables:
{ "seatIds": ["stalls:A:5"], "areas": [{ "inventoryId": "ga-fan-pit", "quantity": 4 }] }seatIds may be empty when areas is not (a standing-only cart). @openseat/adapter-rest sends areas only when there are some, so a seats-only request keeps the original body.
Everything free: hold every seat and every area quantity atomically, respond 201:
{
"holdId": "h_81f",
"eventId": "e1",
"seatIds": ["stalls:A:5"],
"areas": [{ "inventoryId": "ga-fan-pit", "quantity": 4 }],
"expiresAt": "2026-10-18T10:15:00Z",
"serverTime": "2026-10-18T10:05:00Z"
}409 with SEATS_UNAVAILABLE and unavailableSeatIds.409 with AREA_SOLD_OUT and unavailableAreaIds.422 with LIMIT_EXCEEDED. Count seats, standing places and booths one each, and a whole table as its number of seats; count the buyer's other active holds and bookings for the event too.422 VALIDATION.Idempotency-Key again (a retry): return the original response, do not create a second hold.Idempotency-Key is a 400 VALIDATION. Scope keys per buyer and per event, so one buyer's key never returns another buyer's hold.Apply per-customer limits here (maximum tickets, maximum concurrent holds).
200 with the hold, 404 HOLD_NOT_FOUND, or 410 HOLD_EXPIRED. A hold that has been released or confirmed is no longer live: 410 too.
Releases the seats. 204. Releasing an already released or expired hold is not an error (204 or 404). Releasing a hold that was already confirmed must not touch the booking: answer 204 and leave the seats sold.
Headers: Idempotency-Key. Body: { "paymentReference": "pi_3Nx…" }.
200 { "bookingId": "b_19c", "seatIds": [...], "areas": [...] } (areas as held; leave it out when there were none).Failures: 402 PAYMENT_REJECTED, 410 HOLD_EXPIRED (and start a refund if money was taken), 404 HOLD_NOT_FOUND, and 422 LIMIT_EXCEEDED when you also limit tickets per person (for example per phone number) across bookings. A repeated idempotency key returns the original result.
The response may carry more fields than bookingId, seatIds and areas; the adapter ignores them.
The contract leaves payment to you: whatever produces a paymentReference your server can verify. A server that takes the buyer's details can add a step between the hold and the confirmation.
Optional. Either let clients poll availability, or offer a server-sent events stream (GET /v1/events/:eventId/stream) whose messages are arrays of seat status records (area keys can be included the same way, as { "seatId": "<inventory key>", "status": … }, to colour areas on the map):
data: [{"seatId":"stalls:A:5","status":"held","version":416}]Messages may also be an object { "seats": [...] }; @openseat/adapter-rest accepts both. Send a : ping comment every 20 to 30 seconds so proxies keep the connection open, and a retry: line to set the reconnect delay. If your server can miss changes (its database listener reconnected, say), send an empty data: {"seats":[]} and let clients refetch availability; clients should refetch on every reconnect anyway.
Real-time updates make the map fresher. They do not make it authoritative.
Optional. Either let clients poll availability, or offer a server-sent events stream (GET /v1/events/:eventId/stream) whose messages are arrays of seat status records (area keys can be included the same way, as { "seatId": "<inventory key>", "status": … }, to colour areas on the map):
data: [{"seatId":"stalls:A:5","status":"held","version":416}]Messages may also be an object { "seats": [...] }; @openseat/adapter-rest accepts both. Send a : ping comment every 20 to 30 seconds so proxies keep the connection open, and a retry: line to set the reconnect delay. If your server can miss changes (its database listener reconnected, say), send an empty data: {"seats":[]} and let clients refetch availability; clients should refetch on every reconnect anyway.
Real-time updates make the map fresher. They do not make it authoritative.
Generated from apps/docs/integration/backend-api.md