Document / EN
Operational inventory (box office API)
The calls a box office or partner back office uses every day: change object statuses with preconditions, edit extraData, find best seats, adjust events, control what is for sale, and package seasons.
Rules for every call on this page
- Server-to-server:
x-api-key: sk_live_xxx(placeholder). Dashboard sessions use a Bearer JWT with inventory permission. - Every mutation needs a fresh
Idempotency-Key(UUID per operation). Replaying the same key with the same body returns the stored response; reusing a key with a different body is rejected. - PostgreSQL is the source of truth. Sold seats are never double-booked: commercial transitions run under seat row locks, and Redis holds are checked inside the same decision.
| Limit | Value |
|---|---|
| Objects per change / extraData call | 64 |
| Events per multi-event change | 50 |
allowedPreviousStatuses / rejectedPreviousStatuses entries | 16 each |
GA quantity per object | 1 – 10,000 |
Best-available categories | 10 (ordered preference) |
Events per POST /api/events/bulk | 50 |
1. Change object status
POST /api/events/{eventId}/actions/change-object-status
Shortcuts with the same body: /actions/book (status booked), /actions/release (RELEASE), /actions/retire (retired), /actions/put-up-for-resale (resale).
curl -sS -X POST "https://api.seatschart.com/api/events/$EVENT_ID/actions/change-object-status" \
-H "Content-Type: application/json" \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 6f1c1d0e-8a51-4c43-9d4b-4c6f8a2a7c11" \
-d '{
"status": "booked",
"objects": [
"A-1",
{ "objectId": "A-2", "extraData": { "note": "VIP guest" } },
{ "objectId": "8d6b3c3e-2f7a-4b1e-9a51-0c1f0f1d2e3a", "quantity": 4 }
],
"allowedPreviousStatuses": ["free"]
}'| Field | Meaning |
|---|---|
objects | 1–64 items. A string or { objectId, extraData?, quantity?, ticketType? }. objectId is a seat UUID or a seat label (resolved within the event). A GA area (UUID or name) requires quantity. |
status / type | status for booked, resale, retired or a custom label. type: "RELEASE" frees unsold objects. |
holdToken | Only with booked: books seats held by this hold token (box office turning a cart into a sale). Every seat must belong to that session; seats held by another session are rejected. |
allowedPreviousStatuses | Apply only if every object is currently in one of these statuses. |
rejectedPreviousStatuses | Refuse if any object is currently in one of these statuses. |
channelKeys | Inventory channels this operation may touch (channel key or channel id). Objects in other channels are refused. |
ignoreChannels | true skips channel checks explicitly. Omitting both keeps the legacy behaviour (administrative calls are not channel-restricted). |
events | Multi-event: apply the same change to up to 50 events of the tenant (address seats by label). |
seasonId | Season or partial season id: fan out by label to every live member event in one transaction. |
ticketType (per object) | Status booked only: ticket type id recorded on the booking (order item and reservation, like a confirmed sale) and returned per object; reports group by it. Other statuses: 400. |
keepExtraData | Keeps the stored extraData; a per-object extraData sent with it is merged on top. Without it the object's extraData is replaced by the one sent (or cleared). |
resalePriceCents, orderId | Unchanged from earlier versions. |
resaleListingId | Resale only: groups the objects into one listing (extraData.resaleListingId, 1–64 URL-safe characters). The renderer sells a listing as a whole with resaleListings (README). |
Status vocabulary for preconditions: free, booked, resale, reservedByToken (an active hold), retired, or your custom status.
Response (single event)
{
"objects": {
"5a0e…": { "objectId": "5a0e…", "label": "A-1", "status": "booked", "extraData": null, "orderId": "c0de…" }
},
"gaAreas": {
"8d6b…": { "areaId": "8d6b…", "quantity": 4, "soldCount": 120 }
}
}Errors
| HTTP | error | Body |
|---|---|---|
| 409 | illegal_previous_status | objects: [{ objectId, label, status }] — every object that failed the precondition. Nothing was applied. |
| 409 | ambiguous_label | labels: [...] — a label matches more than one object in the event. |
| 404 | objects_not_found | objects: [...] — ids/labels that do not exist in the event. |
| 400 | quantity_required | objects: [...] — a GA area was addressed without quantity. |
| 400 | ga_area_whole | objects: [{ objectId, label, areaType, capacity, minOccupancy, maxOccupancy, requested }] — a GA area sold as a whole was addressed with a quantity outside its people range. |
| 409 | hold_token_conflict | The hold token is expired, foreign, or already being confirmed. |
| 409 | channel_forbidden | objects: [{ objectId, label }] outside channelKeys. |
| 409 | inventory_conflict | A commercial rule refused the change (held seat, sold seat without cancellation, capacity exhausted…). |
Multi-event
With "events": ["…", "…"] (the path event must be one of them; holdToken, orderId and seasonId are not allowed) each event is processed in its own transaction. One event failing does not roll back the others. HTTP 200 when all succeed, 207 otherwise:
{
"results": [
{ "eventId": "e1…", "ok": true, "objects": { "…": { "status": "booked" } } },
{ "eventId": "e2…", "ok": false, "status": 409, "error": "illegal_previous_status", "message": "…", "objects": [ … ] }
],
"succeeded": 1,
"failed": 1
}Retry only the failed events with a new Idempotency-Key, or replay the whole request with the same key to read the stored result.
GA quantity rules
- GA objects with
quantityacceptbooked,reservedByToken(hold, also/actions/hold) andtype: "RELEASE"(administratively booked places, or places held by a GAholdToken). Retire/resale/custom on a GA area is rejected; buyer purchases are refunded by cancelling the order. - Booking GA uses the same commercial path as a buyer: an atomic hold against live holds, then a capacity-checked sale. Capacity is never exceeded; insufficient capacity returns
409 inventory_conflictand nothing in the request is applied. - Preconditions (
allowed…/rejectedPreviousStatuses) apply to GA too: the previous status isfreefor book/hold,bookedfor release without token,reservedByTokenfor release with token. - Areas sold as a whole (
fixedOccupancy/variableOccupancy, see Map designer):quantityis the number of people (fixed: the capacity; variable:minOccupancy..capacity) and the operation takes every place of the area — book, hold and release are never partial. GA rows of the response addpeople. - Places withheld with for-sale
areaPlacesare not sellable by this call either. - GA hold, release and cancellation in detail (
holdExpiresInMinutes,holdToken/holdUserId,409 insufficient_booked_places,orderId): Events, charts & workspaces.
Concurrency guarantees
- Per event, the change is atomic: seat row locks are taken in a stable order, preconditions and holds are evaluated under those locks, and either every object changes or none does.
- A concurrent
POST /api/seats/reserveon the same seats either holds first (your call returnsinventory_conflict, orillegal_previous_statuswithreservedByToken) or loses: the hold is refused, or — if it was acknowledged in the instant before your sale committed — its lock is removed by the sale so it can never be confirmed. A seat never has more than one owner. holdTokenfreezes the buyer session before the sale, so the buyer cannot confirm the same seats at the same time; seats of the session you did not book stay held.- With
seasonIdall member events change in one transaction (cap 50 members and 64 seats overall); preconditions andchannelKeysapply to every member seat. - Replays with the same Idempotency-Key never create a second booking.
2. Update extra data (no status change)
POST /api/events/{eventId}/actions/update-extra-data
{
"objects": [
{ "objectId": "A-1", "extraData": { "note": "wheelchair companion" } },
{ "objectId": "A-2", "extraData": null }
],
"merge": false
}Max 16 KiB of JSON per object. merge: false (default) replaces the object's extraData; merge: true merges keys into it; null clears it. Statuses, owners and holds do not change. Every update is audit-logged. Response: { "objects": { "<seatId>": { objectId, label, status, extraData } } }. Same label errors as above.
3. Best available
POST /api/seats/best-available — new optional fields:
| Field | Meaning |
|---|---|
categories | Ordered preference, up to 10. The first category with a fitting block wins; later ones are fallbacks. |
ticketTypes | { "<ticketTypeId>": count }. quantity becomes the sum; the response assigns a ticket type to each seat (usable as seatTicketTypes on confirm). Requires ticket types on the category. |
extraData | Written to the held (or booked) seats. Requires hold: true or status: "booked". |
keepExtraData | With hold or status: "booked": keep the seats' stored extraData and merge extraData / orderId on top. |
status | "booked" books the best block directly instead of holding it (see section 11). "reservedByToken" is the same as hold: true. |
holdToken | Append the found seats to an existing hold session instead of creating a new one. |
tryToPreventOrphanSeats | Default true: avoid leaving a single empty seat at a row edge when another block fits. |
accessibleSeats | N: only blocks containing at least N seats flagged accessibility.wheelchair in the published seat structure (409 when none fits). |
With hold: true up to 32 candidate blocks are tried across the category preferences (each category keeps a share of the attempts). userId is required unless holdToken is given; an expired, foreign, confirming or full session returns 409 hold_token_conflict. When ticketTypes is given, only categories offering every requested ticket type are searched; events without ticket types reject it.
{
"eventId": "…",
"quantity": 3,
"categories": ["VIP", "Premium"],
"hold": true,
"userId": "box-office-7",
"holdToken": "22222222-2222-4222-8222-222222222222",
"tryToPreventOrphanSeats": true
}4. Events
PATCH /api/events/{eventId} now accepts:
| Field | Meaning |
|---|---|
name, date | Rename or reschedule (ISO-8601). |
tableBookingConfig | { mode: "all_by_seat" | "all_by_table" | "custom", tables?: { "<tableId>": "by_seat" | "by_table" } } for this event only. null restores the chart setting. |
categoryOverrides | { "<category>": { priceCents?, tint?, forSale? } } on top of the chart categories. Replaces the whole map; null clears it. priceCents sets the base price of every seat of the category for this event (clearing it restores the chart price; chart ticket-type prices still take precedence); tint recolors the category in this event's published map; forSale: false takes the category off sale. Accepted orders keep their price. |
forSalePerCategory | { "<category>": true | false } shortcut to mark whole categories for sale or not. |
GET /api/events/{eventId} returns categoryOverrides ({} when none) and tableBookingConfig (null when none).
POST /api/events/{eventId}/actions/move-to-new-chart-copy { "name"?: "…" } copies the chart and points the event at the copy without touching sold or held inventory. If the copy would orphan sold or held seats it answers 409 { "error": "structural_mismatch", "orphanedObjects": [ … ] }.
POST /api/events/bulk
{ "chartId": "…", "events": [ { "name": "Match 1", "date": "2026-11-01T19:00:00Z" }, { "name": "Match 2" } ], "seasonId": "optional" }Creates up to 50 events for one published chart in one transaction (all or none; the plan event limit is checked for the whole batch) and returns { "events": [{ id, name, date, chartId, clonedSeats, clonedGaAreas }] }.
5. For sale and channels
POST /api/events/{eventId}/inventory/for-sale/apply adds:
categories: ["VIP"]— every seat and GA area of these categories.areaPlaces: { "<gaAreaId>": 20 }— withhold 20 more GA places (forSale: false) or return 20 to sale (forSale: true). Withholding more than the currently free places (capacity − sold − held − already withheld) returns409 insufficient_free_places. The response lists the resultingnotForSalePlacesper area.everything: true— reset: every seat and GA area for sale again and no withheld places (forSalemay be omitted; cannot be combined with a selection). Retired seats stay retired; category toggles never reopen them either.
PATCH /api/events/{eventId}/inventory/channels/{channelKeyId} { name?, color? } renames or recolors a channel.
PUT /api/events/{eventId}/inventory/channels replaces all assignments atomically:
{ "assignments": [ { "channelKeyId": "…", "seatIds": ["…"], "gaAreaIds": [], "areaPlaces": { "…": 10 } } ] }Objects not listed become public. Holds on objects that become more restricted are released.
6. Partial seasons
A partial season is a package of a subset of a season's events (for example weekend games).
| Call | Body |
|---|---|
GET /api/seasons/{seasonId}/partial-seasons | → { partialSeasons: [{ id, seasonId, name, eventIds, createdAt }] } |
POST /api/seasons/{seasonId}/partial-seasons | { name, eventIds? } → { partialSeason } |
DELETE /api/seasons/{seasonId}/partial-seasons/{partialSeasonId} | → { ok: true } |
POST …/{partialSeasonId}/actions/add-events | { eventIds } → { partialSeason } |
POST …/{partialSeasonId}/actions/remove-events | { eventIds } → { partialSeason } |
Event ids must already be members of the parent season (400 not_season_member lists the others); up to 50 per call, 100 partial seasons per season. Events detached from the season leave its partial seasons. Use the partial season id as seasonId on change-object-status to book or release a package: all member events change in one transaction, exactly like a full season.
Dashboard
The Event Manager exposes the same operations: status actions (Book / Release / Retire / Resale / Custom), an "Only apply if the current status is…" guard with a conflict dialog listing the blocking seats, bulk extra data editing, and per-event category overrides. Partial seasons are managed on the Seasons page.
7. Atomic batch across events
POST /api/events/actions/change-object-status applies several status changes — different events, statuses, hold tokens and orders — all or nothing in one database transaction. Each entry of statusChanges[] (max 100, 512 objects overall) names its event with exactly one of eventId / eventKey and takes the same fields as the single-event route (objects, status or type: "RELEASE", holdToken, orderId, keepExtraData, allowedPreviousStatuses, channelKeys, ticketBuyerId, …). One Idempotency-Key covers the whole batch.
curl -X POST "$API/api/events/actions/change-object-status" \
-H "x-api-key: $KEY" -H "Idempotency-Key: order-4711:book" -H "Content-Type: application/json" \
-d '{ "statusChanges": [
{ "eventKey": "show-2026-12-01", "objects": ["A-1", "A-2"], "status": "booked", "ticketBuyerId": "cpf-123" },
{ "eventKey": "show-2026-12-02", "objects": ["A-1"], "status": "booked", "holdToken": "4c1e…" },
{ "eventId": "9b0f…", "objects": [{ "objectId": "Pista", "quantity": 2 }], "status": "booked" } ] }'
# 200 { "results": [ { "index": 0, "eventId": "…", "outcome": "applied", "objects": {…}, "gaAreas": {} }, … ] }
# 409 { "error": "inventory_conflict", "failed": 1, "results": [ { "index": 0, "outcome": "rolled_back" },
# { "index": 1, "outcome": "failed", "status": 409, "error": "hold_token_conflict", … }, { "index": 2, "outcome": "rolled_back" } ] }- Never partial: on any failure nothing is written; the response lists every change as
failed(witherrorandmessage) orrolled_back. Invalid requests report all invalid changes at once. - No deadlocks, no overbooking: rows are locked in a fixed order (orders, then seats by event and seat, then GA areas), so concurrent batches on the same seats simply serialize. Seats held by buyers are only booked with their
holdToken, and GA places are reserved in the same structure buyers hold from. - Administrative GA holds (
reservedByToken) and GA releases by hold token cannot join a batch (400not_atomic); an object or hold token may appear in only one change. - The
events[]form of the single-event route acceptsatomic: truefor the same guarantee when one change applies to several events.
8. Best available: sections, channels and order id
{ "eventId": "9b0f…", "quantity": 4, "sections": ["Lower 101", "Lower 102"], "channelKeys": ["partner-channel-key"],
"hold": true, "userId": "buyer-42", "orderId": "ORD-4711" }sections(max 20 ids or names) ranks across the union of those layout sections (use eithersectionIdorsections).channelKeys(channel secret keys or ids) lets a server key pick from those channels plus public seats;ignoreChannels: trueallows every channel. Both are for API keys and dashboard sessions; buyers keep using thex-channel-keyheader.orderId(withhold: true) is stored on the held seats asextraData.orderIdand echoed in the response — send it again asexternalOrderIdwhen you confirm.
9. Season status overrides
Season members follow the season status: season bookings (seasonId on reserve), season status changes (seasonId on change-object-status) and season inventory sync apply to every live member. An event can take some objects out of that flow (seats.io OVERRIDE_SEASON_STATUS / USE_SEASON_STATUS):
| Call | Body | Effect |
|---|---|---|
GET /api/events/{eventId}/season-status | — | { eventId, season: { id, name, sourceEventId } | null, isSource, forSalePropagated, overriddenObjects } |
POST /api/events/{eventId}/actions/override-season-status | { objects, release?, allowBookedRelease? } | The objects (seat labels or UUIDs, max 1000) stop following the season in this event. |
POST /api/events/{eventId}/actions/use-season-status | { objects } | The objects follow the season again. |
PATCH /api/seasons/{seasonId}/events/{eventId} | { forSalePropagated } | Re-link (true, copies the source's for-sale flags now) or detach the member's for-sale configuration. |
Both actions require an Idempotency-Key. The season source event defines the season status and cannot override it (409 season_source_event); events outside a season get 409 not_season_member.
No overbooking — the rule. Every object keeps exactly one authority per event: its seat row and its Redis key. An override only changes which (event, object) pairs the season touches, so no object can ever be sold twice:
- Overriding never changes a status. A seat booked for the season stays booked in the event; it is now the event's own status.
release: truealso frees the objects in this event only. Unsold objects go through the administrative engine (held seats and accepted orders are refused with 409inventory_conflict).- Sold objects are refused with 409
season_booked(objects: [{ objectId, label, orderId, seasonTicket }]). WithallowBookedRelease: true— the explicit opt-in — seats of a season (multi-event) order are given back for this event only: under the seat row lock the reservation and the order item of this event are cancelled (season_status_override), the seat becomes available and its Redis confirmed marker is cleared. The order stays confirmed for its other events; refunds are up to you. Seats sold in this event only are never released here (409event_sale: cancel the order). use-season-statusis accepted only when the event and the season agree for each object: both free, or both sold in the same order. Otherwise 409season_status_conflictlistsobjects: [{ objectId, label, reason }](held,event_sold,season_sold,retired,missing) and nothing changes. Then the season's non-commercial state (for sale, channel, custom statuses) is copied onto those objects.- A season booking or season status change started from a member that overrides one of the requested objects is refused (409
season_status_overridden): start it from the season source.
forSalePropagated. While true (the default) season sync and the source's for-sale cascade (cascadeSeason) copy the source's for-sale flags onto the member. A direct for-sale change on a member (for-sale apply, category availability) sets it to false, so the member keeps its own configuration; GET /api/seasons/{seasonId} lists forSalePropagated and overriddenObjectCount per member.
Hot paths (seat map, reserve, confirm, best available, stream) never read overrides: they act per event on the event's own keys.
10. Event categories
PATCH /api/events/{eventId} accepts event-level categories (seats.io event categories / objectCategories):
{
"categories": [{ "key": "press", "label": "Press", "color": "#0ea5e9", "accessible": false }],
"objectCategories": { "A-1": "press", "A-2": "cat-vip" }
}categoriesreplaces the event-only categories (max 128; keys and labels unique and not reused from the chart: 400category_conflict). They join this event's published layout categories, so the renderer, legend and filters show them;accessibleis published with the category.objectCategoriesreplaces the object → category map: object label (seat label or GA area name) → event category key, chart category id or chart category name (max 10000; 400unknown_categorywithlabels).- Seats carry the category label (like chart categories carry their name), so category overrides (
categoryOverrides["Press"]), synced price tables, sales rules, best available (categories: ["press"]or["Press"]), reports and the renderer's section aggregates all use the event category with no extra request. Reassigned seats take the price override of their new category (else the chart price) and close with a closed category. Removing an assignment restores the previous category. - Assignments are resolved once, when saved, and the event geometry is republished. Events without event categories keep their geometry hash. Chart switch, map publish and event duplication keep the assignments.
GET /api/events/{eventId}returnscategoriesandobjectCategories. Changes are logged asevent.categories_updated(event log and admin webhook) and emitevent.updatedwithchanged_fields.
Event Manager. Object statuses shows a Season panel on season members: override or use the season status for the selected seats, release them (and, explicitly, give season tickets back), see the overriding seats and re-link the for-sale configuration. Categories adds Event categories: create event-only categories and assign the seats selected on the map, with instant validation.
11. Ticket types on bookings and direct best-available bookings
ticketType per object on change-object-status, /actions/book, the atomic batch and season fan-out ({ "objectId": "A-1", "ticketType": "adult" }, also for GA quantities): recorded on the new booking like a confirmed sale and returned in objects[id].ticketType / gaAreas[id].ticketType. Only with status booked.
Best available books directly with status: "booked" (API keys with the bookings scope, or dashboard sessions allowed to change object statuses; buyers get 403, scoped keys without bookings get 403 insufficient_scope):
{ "eventId": "9b0f…", "status": "booked", "ticketTypes": { "adult": 2, "child": 1 }, "categories": ["VIP"],
"orderId": "ORD-4711", "extraData": { "pos": "box-3" } }- Same engine as
POST /events/{eventId}/actions/book: one SQL transaction under the seat row locks; a seat held by a buyer is never taken; when a ranked block was taken meanwhile, the next one is tried (up to 8). - Idempotent: the booking order id is derived from the
Idempotency-Key; a retry returns the same response and books nothing more. - Response:
status: "booked",booked: true,bookingOrderId(cancel withPOST /orders/{orderId}/cancel),seatIds,ticketTypesandobjects(status, extraData — withorderId—, orderId, ticketType per seat).holdandholdTokendo not combine with it (400). keepExtraData: truekeeps the seats' stored extraData (mergingextraData/orderIdon top), for bookings and holds alike.
12. Replace the for-sale configuration and the channels in one call
PUT /api/events/{eventId}/inventory/for-sale
{ "forSale": false, "seatIds": ["5a0e…"], "categories": ["Obstructed"], "areaPlaces": { "8d6b…": 20 } }
PUT /api/events/{eventId}/inventory/channels/definitions
{ "channels": [
{ "id": "c0de…", "name": "Fan club", "seatIds": ["5a0e…", "77aa…"] },
{ "name": "Partner B", "channelKey": "partner-b-secret", "color": "#16a34a", "gaAreaIds": ["8d6b…"] } ] }- For sale (
PUT …/inventory/for-sale, seats.ioreplaceForSaleConfig): one transaction.forSale: false— the listed seats, GA areas and categories are off sale and everything else is on sale;forSale: true— only the listed objects are on sale.areaPlaceskeeps an area on sale and withholds that many places (false) or leaves only that many on sale (true). Retired seats stay off sale; withholding more than the free places is 409insufficient_free_placesand nothing changes. Holds on objects that went off sale are released; viewers update live. The incrementalPOST …/for-sale/applyis unchanged. - Channels (
PUT …/inventory/channels/definitions, seats.iochannels.replace): the event's channels become exactly the listed ones — kept byid(or an existingchannelKey) and renamed / recoloured / re-keyed, created otherwise (a generated secret is returned once), and deleted when unlisted. Every object gets the channel it is listed under or becomes public;areaPlacesper channel is replaced.channels: []deletes every channel.PUT …/inventory/channelsstill replaces only the assignments of existing channels. - The atomic batch (
POST /api/events/actions/change-object-status) now takes up to 100 status changes.
13. Hold tokens, several objects at once and seasons
POST /api/hold-tokens { "eventId": "9b0f…", "userId": "box-office-7", "expiresInMinutes": 90 }
POST /api/events/{eventId}/objects/actions/retrieve { "objects": ["A-1", "A-2", "5a0e…"] }
POST /api/seasons { "name": "Season 2027", "chartId": "c0de…", "eventKeys": ["round-1", "round-2"] }
POST /api/seasons/{seasonId}/actions/add-events { "eventIds": ["…", "…"], "sync": true }
POST /api/seasons/{seasonId}/actions/remove-events { "eventIds": ["…"] }
GET /api/seasons/{seasonId}/partial-seasons/{partialSeasonId}expiresInMinutes(1–120) on hold-token creation sets the session lifetime instead of the workspace hold period (seats.ioholdTokens.create). Server API keys and dashboard sessions only; buyer tokens get 403.- Several objects at once: the same object info as
GET /events/{eventId}/objects/{objectId}for up to 100 ids or labels —{ objects: { [ref]: info }, notFound, ambiguous }(seats.ioretrieveObjectInfos;readscope). - Seasons created with their events:
eventKeys(one event per key, named{season} · {key}) ornumberOfEvents(N events) onchartId— season and events in one transaction, the first event becomes the source; 402 over the plan event limit, 409 when a key is already used (nothing is created). Response addsevents: [{ id, key, name }]. - Add / remove several events at once: up to 50 per call, all or nothing under the season lock. Adding refuses the whole call with 404
events_not_found, 409already_in_other_seasonorchart_mismatch(witheventIds); new members are synced from the source (sync: falseskips it). Removing also drops the events from the partial seasons and clears the source when it leaves (sourceCleared). - One partial season:
GET …/partial-seasons/{partialSeasonId}→{ partialSeason: { id, seasonId, name, eventIds, createdAt } }. - Server SDK:
holdTokens.create({ expiresInMinutes }),events.retrieveObjects,seasons.create/addEvents/removeEvents/partialSeasons.get,channels.channelMembership.
14. Mark everything (not) for sale
curl -X POST "$API/api/events/$EVENT_ID/actions/mark-everything-as-not-for-sale" -H "x-api-key: $KEY"
curl -X POST "$API/api/events/$EVENT_ID/actions/mark-everything-as-for-sale" -H "x-api-key: $KEY" \
-H "Content-Type: application/json" -d '{"cascadeSeason":true}'- seats.io
markEverythingAsNotForSale/markEverythingAsForSale: every seat and GA area of the event goes off sale (or back on sale: every seat except retired ones, withheld GA places cleared) in one SQL transaction under the event row lock. Concurrent for-sale changes of the event run one after the other, so the result is never a mix. - Holds on objects that went off sale are released after COMMIT; new holds are refused with
409 SEAT_NOT_FOR_SALE/GA_NOT_FOR_SALE. Sold seats stay sold. - One cache invalidation and one
for_salerealtime message per direction. The response has the counts ofPUT …/inventory/for-sale(changedSeats,changedGaAreas,seatsNotForSale,gaAreasNotForSale,releasedSeatHolds,clearedGaHoldAreas). - Audited (
inventory.for_sale.mark_everything_not_for_sale/inventory.for_sale.mark_everything_for_sale), in the event log asevent.for_sale_changedand delivered as that webhook (and asevent.for.sale.config.updatedto endpoints that opted in).cascadeSeasonandforSalePropagatedbehave as in section 12. Scopeinventory_admin. - Dashboard: Event Manager → For sale → Whole event → "Take everything off sale" / "Put everything on sale", each after a confirmation. The workspace setting
newEventsNotForSalemakes new events start off sale. - SDK:
events.markEverythingAsNotForSale(eventId, { cascadeSeason? }),events.markEverythingAsForSale(eventId, { cascadeSeason? }).
15. Listings (resale and primary) and listing types
Server-side listings group objects of one event that are sold together (sellAsWhole, default) or one by one, with a price and a type. They replace host-only resaleListings when the server should own the inventory (resale marketplaces, packages, "best seats" offers).
# Workspace listing types: colours (seats, legend, price pills) and semantics (primary | resale).
curl -X POST "$API/api/listing-types" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"key":"premium","name":"Premium","kind":"primary","backgroundColor":"#b45309","iconColor":"#ffffff","icon":"star"}'
# A listing of three seats sold as a whole at R$ 40 per seat (or "totalPriceCents" for the whole listing).
curl -X POST "$API/api/events/$EVENT_ID/listings" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"key":"P1","type":"premium","objects":["A1","A2",{"objectId":"A3","priceCents":4500}],"priceCents":4000}'
curl "$API/api/events/$EVENT_ID/listings?status=active&type=premium&limit=50" -H "x-api-key: $KEY"
curl -X POST "$API/api/events/$EVENT_ID/listings/P1/actions/withdraw" -H "x-api-key: $KEY"| Field | Meaning |
|---|---|
key | Unique per event (listing_key_taken); a UUID is generated when omitted. Paths accept the listing id or key. |
type | Listing type key; omitted = untyped, resale semantics. The type with key resale styles untyped listings (seats.io convention). |
objects | 1–50 seats (id or label) and/or GA areas { objectId, quantity }, each with an optional priceCents. An object belongs to at most one active listing (listing_object_conflict). A listing sold as a whole holds seats only, or the places of one GA area. |
priceCents / totalPriceCents | Price per object, or of the whole listing (sold as a whole only). Shown to buyers; the confirmed amount stays the partner's (or the quote's). |
status | active, sold (closed by the order that sells its last seat, or POST …/actions/mark-sold for sales settled outside SeatsChart) or withdrawn. Closed listings never reopen. |
partnerMetadata | Up to 4 KiB of JSON, returned as is. |
Holds and sales. Holding any seat of a listing sold as a whole holds every seat of it in the same atomic hold (POST /api/seats/reserve returns them all); two buyers racing for overlapping listings never split one. GA places of such a listing are held exactly as listed. A confirmation that covers only part of it is refused with 409 listing_incomplete (listing, missingObjects). Best available never picks its seats. The order that sells its last seat closes it as sold with the order id.
Changes while objects are held. Withdrawing a listing, or changing its objects or sellAsWhole, is refused with 409 listing_has_holds (heldObjects) until the holds end (confirmed, released or expired); creating a listing over held objects is refused the same way. Price, type and metadata changes apply at once.
Releases. Releasing any seat of a held listing sold as a whole (POST /api/seats/release) releases the whole listing in the same atomic call (the response lists every seat released); releasing the hold session (POST /api/seats/holds/release) or the GA hold (POST /api/ga/release) frees it whole too, and an administrative release by GA hold token (…/actions/release with holdToken) frees every held place of a listed area whatever quantity asks. A listing is never left partly held, under any concurrency.
Map re-publish and chart switch. A map publish or a chart switch that would remove objects of active listings is refused with 409 listing_objects_removed (listings: id, key, removedObjects). Withdraw or update those listings first, or retry with withdrawAffectedListings: true to withdraw them in the same transaction (409 listing_has_holds while any of their objects is held); the response lists them (withdrawnListings, chartSwitch.withdrawnListings) and the event log records listing.withdrawn.
Put up for resale with a listing. POST /events/{eventId}/actions/put-up-for-resale keeps working as before; add listing: { type?, sellAsWhole?, priceCents?, totalPriceCents?, partnerMetadata?, key? } to also create a listing of the same objects (key defaults to resaleListingId, price to resalePriceCents). The listing is checked before the status change; the response carries listing.
Buyers. The viewer receives the active listings in the compact state (listings block, geometry indices, absent for events without listings) and every change live over SSE. Seats take their listing type colour, the legend lists the types with their price range, a price pill sits over each listing, and tooltips show the type, the grouping and the listing price. The listingType filter uses the server kinds (resale = untyped and resale-type listings). Host resaleListings still work and win: a server listing that shares an object with a host listing is left out of the viewer. GA-area listings get the same pill, centred on the area (the listing total when it is sold whole, else the price of one place), and their place prices count in the legend range.
Performance. Every listing change bumps events.listings_version in the same transaction. Holds, confirmations and the visual state read that version with the event head they already read and cache the listing context under it: no listing query while it is unchanged, none ever for events without listings, and the listing seats travel in the hold's first statement (a listing hold keeps 4 round trips).
Webhooks and event log. event.listings_updated (action = listing.created, listing.updated, listing.withdrawn, listing.sold). Scopes: reads read; listings bookings; listing types events_write.
Dashboard. Event Manager → Listings: filter by status and type, open a listing (objects, dates, order, partner data), select it on the map, withdraw it, create one from the seats selected on the map, and manage listing types.
SDK. listings.types.list/create/update/delete, listings.list(eventId, { status, type, cursor }), listings.listAll, get, create, update, withdraw, markSold.
16. Edit the for-sale configuration (one event or several)
# One event: both lists in one transaction (seats.io editForSaleConfig)
curl -X POST "$API/api/events/$EVENT_ID/actions/edit-for-sale-config" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"forSale":[{"object":"A-1"},{"category":"Balcony"}],"notForSale":[{"object":"GA floor","quantity":50},{"object":"B-7"}]}'
# → { "ok": true, "forSaleConfig": { "forSale": false, "objects": ["B-7"], "categories": [], "areaPlaces": { "GA floor": 50 } },
# "changedSeats": 41, "changedGaAreas": 1, "seatsNotForSale": 1, … }
# Several events, all or nothing (a sector released for every date of a show)
curl -X POST "$API/api/events/actions/edit-for-sale-config" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"events":[{"event":"show-fri","forSale":[{"category":"Sector B"}]},{"event":"show-sat","forSale":[{"category":"Sector B"}]}]}'- Incremental. Only the listed objects, GA places and categories change; everything else keeps its state (
PUT …/inventory/for-salereplaces the whole configuration). Items are{ "object" }(seat UUID or label, GA area UUID or name),{ "object", "quantity" }(GA places:notForSalewithholds that many more places, checked under the GA row lock against sold and held places —409 insufficient_free_places;forSalereturns that many withheld places) or{ "category" }. An explicit object wins over its category; retired seats never go back on sale; sold seats stay sold. - One transaction. Both lists of an event, and every event of the multi-event call (1–100 events, each once, at most 20 000 items), are applied in one SQL transaction; events are locked in id order, so overlapping calls run one after the other. Any refusal writes nothing, and the body names the failing event (
index,event). - Errors. The same object or category in both lists:
400 for_sale_edit_conflict. Unknown objects:404 objects_not_found. A label that names several objects:409 ambiguous_label. Unknown event key:404 event_not_found. - Effects as with the other for-sale calls: holds on objects that went off sale are released after COMMIT, one cache invalidation and
for_salerealtime messages per event (GA place changes as GA updates), auditinventory.for_sale.edit(event logevent.for_sale_changed, the webhook and its seats.io alias),cascadeSeasonandforSalePropagatedas in section 12. - Response. Per event, the resulting seats.io
forSaleConfig(forSale: false;objects= labels of the seats and names of the GA areas off sale;categorieswhose every object is off sale, without repeating their objects;areaPlaces= GA area name → withheld places), pluschangedSeats,changedGaAreas,seatsNotForSale,gaAreasNotForSale,notForSalePlaces,releasedSeatHoldsandclearedGaHoldAreas. - Idempotency and limits.
Idempotency-Keyis optional (the SDKs always send one, so a retry never withholds places twice). There is no daily limit per event (seats.io allows 10 calls per event and day in its rate-limited for-sale mode). Scopeinventory_admin. - SDK.
events.editForSaleConfig(eventId, { forSale, notForSale }),events.editForSaleConfigForEvents({ events }).
Confirmations racing a for-sale change (every for-sale call). A confirmation is decided against the committed for-sale state, never against the hold. Once a change that takes a seat off sale has committed, confirming a hold taken before it is refused at once with 409 SEAT_NOT_FOR_SALE, even in the instant before the hold itself is released. A confirmation accepted while the change was still running is failed by the order commit, which re-reads the seat under its row lock (GET /api/orders/{orderId} → failed). A sale committed before the change stays sold. A hold never makes an off-sale seat sellable.
17. Seasons whose bookings do not propagate
curl -X POST "$API/api/seasons" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"name":"Members 2027","chartId":"arena-main","eventKeys":["pass-2027","match-01","match-02"],"bookingsPropagated":false}'
curl -X PATCH "$API/api/seasons/$SEASON_ID" -H "x-api-key: $KEY" -H "Content-Type: application/json" -d '{"bookingsPropagated":true}'
curl "$API/api/seasons/$SEASON_ID/reports/by-availability.json?seasonBookingsPropagated=true" -H "x-api-key: $KEY"bookingsPropagated(season create, update, list and detail; defaulttrue).true: a season booking (seasonIdonPOST /api/seats/reserveand confirm, or onchange-object-status) locks the object in every live member event, as before.false(seats.io seasons whose bookings do not propagate): the season source event is the season's own inventory. Season bookings lock the object there only (paired by label when started from another member) and the other members keep selling it on their own — for example a members' pass whose holders check in game by game. For-sale configuration, channels and category sync still follow the source. Changing the flag never moves existing bookings.- Partial seasons of such a season also book the source event; when the source is not one of the partial season's events, their status changes are refused with
409 season_bookings_not_propagated. - Reports. Season and partial-season reports take
seasonBookingsPropagated(true/false, default the season'sbookingsPropagated).true: an object is available at season level only when it is free in every evaluated member.false: only the season's own bookings count, i.e. the source event (when it is part of the report). The response states the value used (seasonBookingsPropagated) and which events wereevaluated; per-event totals do not change. - Cost. The flag rides on the season row that season holds already read: no extra round trip on reserve or confirm.
- SDK.
seasons.create({ bookingsPropagated }),seasons.update(id, { bookingsPropagated }),reports.season(id, report, { query: { seasonBookingsPropagated: "false" } }). - Dashboard. Seasons → select a season → Season settings: the Season bookings lock every event switch. Turning it off asks for a confirmation first, because the other events can then sell seats that season holders booked.
18. Hold timer: when it starts and what extends it
curl -X POST "$API/api/hold-tokens" -H "x-api-key: $KEY" -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
-d '{"eventId":"9b0f…","userId":"buyer-42","expiresInMinutes":15}'
# → { "holdToken": "…", "expiresAt": "2031-05-01T19:15:00.000Z", "expiresInSeconds": 900 }
curl -X POST "$API/api/hold-tokens/$HOLD_TOKEN" -H "x-api-key: $KEY" -H "Content-Type: application/json" -d '{"expiresInMinutes":20}'
curl "$API/api/hold-tokens/$HOLD_TOKEN" -H "x-api-key: $KEY"
# → { …, "createdAt": "…", "maxLifetimeSeconds": 7200, "expiresAt": "…", "expiresInSeconds": 1180 }- The timer starts when the hold token is created. That is
POST /api/hold-tokens(an empty session), or the firstPOST /api/seats/reserve/ best-available hold sent withoutholdToken(the token is created with that first hold). The embedded viewer creates its token when the map is ready in thecontinueandstartsession modes (a reload resumes the stored token incontinue), when the host callsstartNewSessioninmanualmode, and never innone. The first expiry is creation + the hold period: the workspace hold period (PATCH /workspace/settings, default 10 minutes,SEAT_HOLD_TTL_SECon self-hosted APIs) orexpiresInMinutes(1–120, server API keys and dashboard sessions only). - Selecting more seats extends it. Every successful hold that adds seats to the token (
POST /api/seats/reservewithholdToken, best available withholdToken, every click of the viewer's hold-on-select) restarts the countdown: the session and every seat on it then expire one workspace hold period after that hold. This applies to tokens created withexpiresInMinutestoo, so after a selection the countdown is the workspace hold period again. Releasing seats does not change the expiry, and a refused hold changes nothing. - Extending on purpose.
POST /api/hold-tokens/{holdToken}withexpiresInMinutes(seats.iochangeExpiry) sets the expiry to now + that many minutes (1–120);0releases every seat and ends the session.POST /api/seats/holds/renewsets it to now + the workspace hold period, after re-checking that the seats are still sellable. - Hard cap: 2 hours after creation. No extension, selection or renewal moves the expiry beyond token creation + 7200 seconds (
maxLifetimeSecondsinGET /api/hold-tokens/{holdToken}); once that point is reached, appends and renewals are refused with409and the seats are released. - Confirmation. A confirmation claims the token: from then on it cannot be extended or grown, and the claim keeps the remaining time.
- Compared with seats.io. On seats.io a hold lasts 15 minutes from token creation by default and selecting more seats does not extend it; only an explicit expiry change does. On SeatsChart an active buyer keeps the session alive by selecting (within the 2-hour cap), and an idle selection expires one hold period after its last hold. Show the
expiresAtof the latest hold (or ofGET /api/hold-tokens/{holdToken}) as the checkout countdown; do not compute it from the token creation time.
19. Resale of seats that are not for sale (season tickets)
Clubs that sell season tickets ("sócio-torcedor") withhold the season-ticket seats from single-game sale: in each match event those seats are not for sale. When a season-ticket holder cannot attend a game, they release their seat for resale in that match only. SeatsChart sells such a seat when all of these hold:
- The match event is a member of a season and keeps its own for-sale configuration:
forSalePropagated: falseon its membership (PATCH /api/seasons/{seasonId}/events/{eventId}, or automatically after any direct for-sale change on the member). The season source event always defines the season and is never exempt. - The seat has an active listing of kind
resalein that event (untyped listings have resale semantics; listings of aprimarytype do not count). - The checkout names the seat:
POST /api/seats/reservewith its id, thenPOST /api/seats/confirm. The embedded viewer offers it like any other resale listing seat (listing colour and price; a click holds it), and a resale marketplace or your own checkout does the same through the API. While the listing is active, the buyer state (GET /api/events/{eventId}/stateand the realtime stream) shows the seat as for sale; the seat row keeps its ownforSale: false.
curl -X PATCH "$API/api/seasons/$SEASON_ID/events/$MATCH_ID" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"forSalePropagated":false}'
curl -X POST "$API/api/events/$MATCH_ID/listings" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"key":"resale-B-12","objects":["B-12"],"sellAsWhole":false,"priceCents":9000}'- Everything else is refused as before with
409 SEAT_NOT_FOR_SALE: the same seat without an active resale listing, in a member that follows the season (forSalePropagated: true), in an event outside seasons, and carts that mix it with an unlisted seat that is not for sale. GA places withheld from sale stay withheld. - Best available never picks these seats. It ranks primary inventory for "give me N seats" requests; a resale purchase is the buyer choosing a listing's seats. This matches listings sold as a whole, which best available never picks either.
- When the listing closes mid-flow. Withdrawing a listing while its seats are held is refused (
409 listing_has_holds). If it closes anyway (sold off-platform with…/actions/mark-sold, or a withdrawal that passed its hold check just before the hold) or the member is re-linked to the season before the confirmation, the confirmation is refused with409 SEAT_NOT_FOR_SALE. A confirmation already accepted is re-checked by the order commit under a lock on the listing: the order fails (GET /api/orders/{orderId}→failed) and the seat is never sold; a closing that races the commit waits for it. The sale itself closes the listing assoldwhen its last seat sells, and the seat stays not for sale for the next sale. - Cost. The membership flag rides on the event statement that holds and confirmations already read, and the listing membership comes from the listing context cached under the event's listings version: no extra query or round trip. The order commit adds one locking statement only for orders that contain a seat that is not for sale.
- Dashboard. Seasons → select a season → events table → For sale follows the season switch per event. Turning it back on asks for a confirmation: it copies the source event's for-sale configuration at once, releases holds on objects that go off sale and ends the resale of seats that are not for sale in that event.
