Skip to content

Your backend · 11 endpoints

REST backend contract

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.

In TypeScript

Who may call what
EndpointsCaller
Read layouts, availabilityAnyone who may view the event (often public)
Create, hold, releaseSigned-in customers (or anonymous sessions you can rate-limit)
ConfirmThe customer who owns the hold, after payment
Write layoutsVenue staff

Authentication and authorisation

Use what you already have: bearer tokens (Authorization: Bearer …), or cookie sessions with CSRF protection. Recommended authorisation:

Endpoint groupWho
Read layouts, availabilityAnyone who may view the event (often public)
Create, hold, releaseSigned-in customers (or anonymous sessions you can rate-limit)
ConfirmThe customer who owns the hold, after payment
Write layoutsVenue 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".

Errors

json
{
  "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:

json
{
  "error": {
    "code": "AREA_SOLD_OUT",
    "message": "Fan pit has only 3 places left.",
    "unavailableAreaIds": ["ga-fan-pit"]
  }
}
json
{ "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.

Status codesHow the adapter turns each response into a BookingError code. Your server sends the status on the left for the code it means; network failures, timeouts and aborts happen in the browser.
ResponseCodeRetry
Body has error.code from the contractthat code (AREA_SOLD_OUT and LIMIT_EXCEEDED only arrive this way)per code
400, 422VALIDATIONno
401 (after one onUnauthorized retry)UNAUTHORIZEDno
402PAYMENT_REJECTEDno
403FORBIDDENno
404 on hold routesHOLD_NOT_FOUNDno
409, 412CONFLICT (or body code, e.g. SEATS_UNAVAILABLE, AREA_SOLD_OUT)no
410HOLD_EXPIREDno
429RATE_LIMITEDyes
5xxSERVERyes
Network failureNETWORKyes
TimeoutTIMEOUTyes
Caller abortedABORTEDno
Response does not match the contractINVALID_RESPONSEno

Layouts4

GET/v1/layouts/:layoutId

Get a layout

Who may call it
Anyone who may view the event (often public)
Responses
200

200 with the layout document. Include revision.

POST/v1/layouts

Create a layout

Who may call it
Venue staff
Responses
201422

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.

PUT/v1/layouts/:layoutId

Save a layout

Who may call it
Venue staff allowed to write this layout (authorise per layout)
Headers
If-Match
Responses
200400409412422428
Error codes
CONFLICT

Headers: If-Match: "<revision the client edited>". Body: the layout (or, with the adapter's sendManifest option, { "layout": { … }, "manifest": { … } }).

  • If the stored revision equals the If-Match revision: store it with revision + 1, respond 200 { "revision": 8, "updatedAt": "…" }.
  • Otherwise: respond 409 (or 412) with the stored document: { "error": { "code": "CONFLICT", "message": "…" }, "current": { …layout… } }. Never overwrite.
  • A missing or malformed 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:

  • Require 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.
  • Authorise per layout: check that the caller may write this particular layout (its venue, its tenant), not merely that they are "venue staff".
  • Validate the whole document with validateLayout from @openseat/core and refuse 422 on errors. Do not trust the client's validation.
POST/v1/layouts/:layoutId/validateoptional

Validate a layout

Who may call it
Venue staff
Responses
200

Optional. Body: a layout. 200 { "valid": true, "errors": [], "warnings": [...] }. Useful when layouts are imported by other tools.

Inventory manifest

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:

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

Rules your server must enforce

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:

RuleCheck on the server
maxTickets / maxSeats / minSeatsOne 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 saleEvery key in unsellableInventory(layout).inventoryIds for the event's layout revision is blocked: never held or sold, whatever the client sends.
allowedCategoriesEvery seat's category (from your event's layout snapshot) is one the customer may buy, such as a presale or members-only tier.
Mixed categoriesWhen the event forbids mixing (allowMixedCategories: false), all seats share one category.
Orphan seatsWhen you prevent single-seat gaps (preventOrphans), re-run the check against current availability, not the client's view of it.
bookAsWhole tables and boothsA hold for part of a whole table is refused; a table or booth is held as one unit (quantity 1).
Standing capacity and quantitiesQuantity is a positive integer, and sold + held + requested ≤ the area's capacity (atomically, like seat holds); otherwise AREA_SOLD_OUT.
Seat-to-event membershipEvery seat ID exists in the layout revision the event uses. Reject unknown IDs; never create inventory from a client-supplied ID.
RevisionHolds and bookings refer to the event's fixed layout revision, not to whatever the latest layout is.
Duplicate seat IDsDe-duplicate seatIds in a request (or reject duplicates) before counting or holding.
Request sizeCap 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 limitsPer customer and per IP on holds and confirmations, so one client cannot hold a whole venue or probe availability at speed (429 RATE_LIMITED).
PricesPrice every order from your price list for the event. Never accept a price, priceLabel or categoryPrices value from the client.
Layout writesURL ID equals body ID, per-layout write permission, If-Match revision, validateLayout, and no removal of sold or held seat IDs.
Bookings across layout savesConfirmed 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.

Events and seats2

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.

GET/v1/events/:eventId/seatsoptional

List an event's seats

Who may call it
Anyone who may view the event (often public)

Optional: the event's seat list with categories and prices, if you serve them separately from availability.

GET/v1/events/:eventId/availability?seatIds=a,b,c

Get availability

Who may call it
Anyone who may view the event (often public)
json
{
  "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):

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

Holds4

POST/v1/events/:eventId/holds

Hold seats

Who may call it
Signed-in customers (or anonymous sessions you can rate-limit)
Headers
Idempotency-Key
Responses
201400409422

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:

json
{ "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:

    json
    {
      "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"
    }
  • Any seat taken: hold nothing, respond 409 with SEATS_UNAVAILABLE and unavailableSeatIds.
  • An area with fewer places left than requested, or not for sale: hold nothing, respond 409 with AREA_SOLD_OUT and unavailableAreaIds.
  • Over the per-buyer limit: hold nothing, respond 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.
  • A quantity that is not a whole number from 1 up (or more than 1 for a booth or a whole table), or an unknown inventory ID: 422 VALIDATION.
  • Same Idempotency-Key again (a retry): return the original response, do not create a second hold.
  • A missing 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).

GET/v1/holds/:holdId

Get a hold

Who may call it
Only the customer who owns the hold (anyone else gets 404 HOLD_NOT_FOUND)
Responses
200404410

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.

DELETE/v1/holds/:holdId

Release a hold

Who may call it
Only the customer who owns the hold (anyone else gets 404 HOLD_NOT_FOUND)
Responses
204404

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.

POST/v1/holds/:holdId/confirm

Confirm a hold

Who may call it
Only the customer who owns the hold, after payment
Headers
Idempotency-Key
Responses
200402404410422

Headers: Idempotency-Key. Body: { "paymentReference": "pi_3Nx…" }.

  1. Verify the payment with your provider using the reference. Never trust the client's claim that it paid.
  2. Atomically turn the hold into a booking.
  3. 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.

Checkout before confirm

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.

Real-time updates1

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.

GET/v1/events/:eventId/streamoptional

Stream status changes

Who may call it
Anyone who may view the event (often public)

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