Document / EN
Events, charts and workspaces
This guide covers the platform features that sit around the sales path: readable event keys, configuring an event at creation time, switching an event to another chart, administrative general-admission (GA) operations, chart validation, categories and copies, workspace activation, the lifecycle webhooks, ticket buyer ids, the event workflow (quick events, ended events, detach), the status history with origin and public chart preview links.
Rules for every call on this page
- All routes are under
/api. Examples useAPI=https://api.seatschart.comand the placeholder keysk_live_xxx. - Server-to-server:
x-api-key. Dashboard sessions useAuthorization: Bearer <JWT>. Routes marked dashboard only refuse partner keys with403 ERR_PARTNER_SCOPE. - Calls marked (idempotent) require a fresh
Idempotency-Key(one UUID per operation). Replaying the same key with the same body returns the stored response. - Errors are JSON:
{ "error": "<code>", "message": "…", …details }.
1. Event keys
An event can carry an optional key that you choose — typically the id of the event in your own system — so you never have to store the SeatsChart UUID.
| Rule | Value |
|---|---|
| Length | 1 – 100 characters |
| Pattern | ^[A-Za-z0-9][A-Za-z0-9_.~-]{0,99}$ (starts with a letter or digit; then letters, digits, _, ., ~, -) |
| Not allowed | A value that looks like a UUID |
| Uniqueness | Per workspace — a duplicate returns 409 event_key_taken |
Where a key is accepted
Every public and partner route that takes an event id accepts the key in its place:
- the path parameter
:eventId(for exampleGET /api/events/concert-2026-12-01/seats); - a body
eventId(for examplePOST /api/seats/reserve,POST /api/ga/confirm); - a body
eventIds[]and a bodyevents[](for example multi-eventchange-object-status).
An unknown key returns 404 { "error": "event_not_found" }. UUIDs are always treated as ids and never looked up as keys, so the key lookup adds no cost to calls that already use UUIDs.
Reading, filtering and changing keys
key(string | null) is returned byGET /api/events,GET /api/events/{eventId},POST /api/events,POST /api/events/bulkandPATCH /api/events/{eventId}.GET /api/events?key=concert-2026-12-01filters by exact key.PATCH /api/events/{eventId}with"key": "new-key"sets or renames it;"key": nullclears it.
curl -sS "$API/api/events?key=concert-2026-12-01" -H "x-api-key: sk_live_xxx"
curl -sS -X PATCH "$API/api/events/concert-2026-12-01" \
-H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-d '{ "key": "concert-2026-12-01-late" }'2. Configure an event at creation
POST /api/events and every item of events[] in POST /api/events/bulk accept the following optional fields, so an event is fully set up before it ever goes on sale:
| Field | Meaning |
|---|---|
key | Event key (section 1). |
tableBookingConfig | { "mode": "all_by_seat" | "all_by_table" | "custom", "tables": { "<tableId>": "by_seat" | "by_table" } } or null. |
categoryOverrides | { "<category>": { "priceCents"?, "tint"?, "forSale"? } } on top of the chart categories. |
channels | Sales channels to create: [{ name, color, channelKey?, objects?, areaPlaces? }]. channelKey is an optional secret (at least 8 characters); objects lists seats or GA areas assigned to the channel; areaPlaces assigns a number of places of a GA area. |
forSaleConfig | { forSale, objects?, categories?, areaPlaces? } — see below. |
forSaleConfig semantics
forSale: false— the listedobjectsandcategoriesare not for sale;areaPlacesare places withheld from sale per GA area.forSale: true— only the listedobjectsandcategoriesare for sale (everything else is not for sale);areaPlacesare the places left for sale per GA area.
Addressing objects. In channels[].objects, channels[].areaPlaces, forSaleConfig.objects and forSaleConfig.areaPlaces, an object is a seat label or UUID, or a GA area name or UUID. Any unknown object returns 400 objects_not_found with objects: [...], and nothing is created.
Atomicity. Everything is applied in the creation transaction. For POST /api/events/bulk, all events of the batch are created in one transaction: either every event exists with its full configuration, or none does.
curl -sS -X POST "$API/api/events" \
-H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-d '{
"name": "Concert",
"date": "2026-12-01T20:00:00Z",
"chartId": "3f2b8f0e-5d1c-4b7a-9e2f-6a1c0d9b8e71",
"key": "concert-2026-12-01",
"tableBookingConfig": { "mode": "custom", "tables": { "T-12": "by_table" } },
"categoryOverrides": { "Balcony": { "priceCents": 4500, "tint": "#0ea5e9" } },
"channels": [
{
"name": "VIP",
"color": "#7c3aed",
"channelKey": "vip-presale-2026",
"objects": ["A-1", "A-2", "GA floor"],
"areaPlaces": { "GA floor": 50 }
}
],
"forSaleConfig": { "forSale": false, "objects": ["A-3"], "categories": ["Restricted view"], "areaPlaces": { "GA floor": 20 } }
}'The response event (in bulk: each events[i]) adds:
{
"event": {
"id": "6d0c…",
"key": "concert-2026-12-01",
"categoryOverrides": { "Balcony": { "priceCents": 4500, "tint": "#0ea5e9" } },
"tableBookingConfig": { "mode": "custom", "tables": { "T-12": "by_table" } },
"channels": [
{ "id": "c41e…", "name": "VIP", "color": "#7c3aed", "keyPrefix": "…", "channelKey": "vip-presale-2026" }
],
"forSale": {
"seatsNotForSale": ["…"],
"gaAreasNotForSale": ["…"],
"notForSalePlaces": { "8d6b…": 20 }
}
}
}channelKey is a secret and is returned only once, in this response. Store it if you need it later.
Bulk
curl -sS -X POST "$API/api/events/bulk" \
-H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-d '{
"chartId": "3f2b8f0e-5d1c-4b7a-9e2f-6a1c0d9b8e71",
"events": [
{ "name": "Match 1", "date": "2026-11-01T19:00:00Z", "key": "match-01",
"forSaleConfig": { "forSale": false, "categories": ["Away fans"] } },
{ "name": "Match 2", "date": "2026-11-08T19:00:00Z", "key": "match-02",
"categoryOverrides": { "Premium": { "forSale": false } } }
]
}'Up to 50 events per call (see Operational inventory). One invalid item (duplicate key, unknown object…) rejects the whole batch.
3. Switch an event to another chart
PATCH /api/events/{eventId} with chartId moves the event to another published chart of the same workspace, atomically:
- seats are re-mapped by label and GA areas by name;
- seats whose label exists in both charts keep their seat id, so holds and sales survive the switch;
- the event geometry is republished in the same transaction.
curl -sS -X PATCH "$API/api/events/concert-2026-12-01" \
-H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-d '{ "chartId": "a7e9c3d2-1b4f-4e8a-9c6d-2f0b1e3a5c47" }'On success the response adds:
{
"chartSwitch": {
"previousChartId": "3f2b8f0e-…",
"chartId": "a7e9c3d2-…",
"keptSeats": 1180, "addedSeats": 40, "removedSeats": 12,
"keptGaAreas": 1, "addedGaAreas": 0, "removedGaAreas": 0
}
}Conflicts
The switch is refused — and nothing changes — when an object that is sold, held, pending or has status history has no counterpart in the new chart, or when a GA area of the new chart would have less capacity than its sold + held + withheld places:
HTTP/1.1 409
{
"error": "chart_switch_conflict",
"message": "…",
"objects": [
{ "objectId": "5a0e…", "label": "C-14", "objectType": "seat", "reason": "sold" },
{ "objectId": "9b17…", "label": "C-15", "objectType": "seat", "reason": "held" },
{ "objectId": "8d6b…", "label": "GA floor", "objectType": "generalAdmission", "reason": "capacity", "required": 320, "capacity": 300 }
]
}reason is one of sold, held, pending, history, capacity or missing; required and capacity are present for capacity conflicts.
How to resolve
- Add the missing labels (or GA area names) to the target chart, or raise the GA capacity to at least
required, then publish it again. - Or free the listed objects first: wait for or release the holds, cancel the orders of sold objects, or release administrative GA places (section 4).
- Retry the
PATCH. The check runs again under the same transaction, so a successful switch is always consistent.
| HTTP | error | When |
|---|---|---|
| 404 | — | The target chart does not exist in this workspace. |
| 409 | chart_not_published | The target chart has never been published. |
| 409 | chart_archived | The target chart is archived. |
| 409 | season_chart_mismatch | The event belongs to a season that uses another chart. |
| 409 | chart_switch_conflict | See above. |
4. Administrative GA operations
POST /api/events/{eventId}/actions/change-object-status (idempotent) and its shortcuts now handle GA areas for booking, holding and releasing. A GA area is addressed as { "objectId": "<area UUID or name>", "quantity": n } with quantity between 1 and 10,000.
| Operation | Body | Effect |
|---|---|---|
| Book | "status": "booked" (or /actions/book) | Sells places: creates a receipt and an order. |
| Hold | "status": "reservedByToken" (or /actions/hold) | Holds places atomically against capacity − withheld − sold − other holds. |
| Release booked places | "type": "RELEASE" (or /actions/release) without holdToken | Returns places that were booked administratively, newest receipts first. |
| Release held places | "type": "RELEASE" with holdToken | Releases that many places held by the GA hold token. |
Hold
curl -sS -X POST "$API/api/events/concert-2026-12-01/actions/hold" \
-H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 0b8f6f1e-6c0a-4a43-9a1e-2d8b9c7f1a01" \
-d '{
"objects": [{ "objectId": "GA floor", "quantity": 6 }],
"holdExpiresInMinutes": 30,
"userId": "box-office-7",
"ticketBuyerId": "crm-482913"
}'holdExpiresInMinutes— 1 to 120; defaults to the workspace hold TTL.userId— the customer identity of the hold; defaults to a generatedadmin-hold:…value.
The response adds hold: { holdToken, holdUserId, holdExpiresAt }. Turn the hold into a sale later with POST /api/ga/confirm (idempotent) using userId: holdUserId, or release it:
curl -sS -X POST "$API/api/ga/confirm" \
-H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 7c2d1a90-3e44-4b0f-8f6a-5b1c2d3e4f50" \
-d '{
"eventId": "6d0c2a1e-9b3f-4c5d-8e7a-1f2b3c4d5e6f",
"areaId": "8d6b3c3e-2f7a-4b1e-9a51-0c1f0f1d2e3a",
"quantity": 6,
"userId": "box-office-7",
"holdToken": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b"
}'
curl -sS -X POST "$API/api/events/concert-2026-12-01/actions/release" \
-H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 1a9e3b7c-0d2f-4c6e-8b5a-7f9d1e3c5b20" \
-d '{ "objects": [{ "objectId": "GA floor", "quantity": 6 }], "holdToken": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b" }'Release administratively booked places
curl -sS -X POST "$API/api/events/concert-2026-12-01/actions/release" \
-H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 3c5e7a9b-1d3f-4b6d-8e0a-2c4e6a8b0d12" \
-d '{ "objects": [{ "objectId": "GA floor", "quantity": 4 }], "orderId": "c0de1f2a-…" }'- Places are taken from the newest administrative receipts first;
orderIdrestricts the release to one administrative order. - Fewer booked places than requested →
409 insufficient_booked_places, nothing released. - Buyer purchases are never released here. To refund a buyer, cancel the order.
Preconditions
allowedPreviousStatuses / rejectedPreviousStatuses now apply to GA objects too. The previous status of a GA object is:
| Operation | Previous status |
|---|---|
| Book, hold | free |
Release without holdToken | booked |
Release with holdToken | reservedByToken |
A failed precondition returns 409 illegal_previous_status and nothing is applied.
Response
{
"gaAreas": {
"8d6b…": { "areaId": "8d6b…", "label": "GA floor", "quantity": 6, "soldCount": 120, "heldCount": 6, "status": "reservedByToken" }
},
"hold": {
"holdToken": "e1f2a3b4-…",
"holdUserId": "box-office-7",
"holdExpiresAt": "2026-12-01T18:30:00.000Z"
}
}status is booked, free or reservedByToken; heldCount is present for holds; a release of booked places lists releasedFrom: [{ receiptId, orderId, quantity }].
Cancelling orders
POST /api/orders/{orderId}/cancel (idempotent) now also cancels orders that contain administrative GA receipts: the places return to sale and the receipts are kept, marked as cancelled.
Guarantees
- Capacity is never exceeded: holds and bookings are checked against capacity minus withheld, sold and other held places in one atomic decision.
- Each call is all-or-nothing: if any object fails (capacity, precondition, insufficient booked places), nothing in the request is applied.
- Replays with the same
Idempotency-Keyreturn the stored result and never book, hold or release twice.
5. Charts
Validate
POST /api/charts/{chartId}/actions/validate checks a map without writing anything. Send { "seats": [...], "shapes": [...] } (same shape as PUT /api/charts/{chartId}/map/draft) to validate a payload before saving it, or no body to validate the current draft (or the published map when there is no draft).
curl -sS -X POST "$API/api/charts/$CHART_ID/actions/validate" -H "x-api-key: sk_live_xxx"{
"chartId": "3f2b…",
"source": "draft",
"publishable": false,
"errorCount": 1,
"warningCount": 2,
"issues": [
{ "code": "…", "severity": "error", "objectIds": ["…"] }
]
}source is body, draft or published. Publish only when publishable is true.
Categories
All category edits change the draft only — publish the chart for them to go live.
| Call | Body | Response |
|---|---|---|
GET /api/charts/{chartId}/categories | — | { chartId, source: "draft" | "published", categories: [{ id, name, color, ticketTypes, seatCount, gaAreaCount }] } |
POST /api/charts/{chartId}/categories | { id?, name, color?, ticketTypes? } | 201 { category }; 409 category_exists |
PATCH /api/charts/{chartId}/categories/{categoryId} | { name?, color?, ticketTypes? } | { category, renamedSeats, renamedGaAreas } — a rename re-labels the draft seats and GA areas |
DELETE /api/charts/{chartId}/categories/{categoryId}?reassignTo={categoryId} | — | { deleted, reassignedSeats, reassignedGaAreas } |
color is #rrggbb. Deleting a category still used by seats or GA areas without reassignTo returns 409 category_in_use with seatCount and gaAreaCount.
curl -sS -X POST "$API/api/charts/$CHART_ID/categories" \
-H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-d '{ "name": "Premium", "color": "#f59e0b" }'
curl -sS -X DELETE "$API/api/charts/$CHART_ID/categories/old-balcony?reassignTo=balcony" -H "x-api-key: sk_live_xxx"Copy a version
POST /api/charts/{chartId}/versions/{version}/actions/copy { "name"?: "…" } creates a new unpublished chart from a published version, with fresh ids:
{ "chart": { "id": "…", "name": "Arena (copy)", "sourceChartId": "3f2b…", "sourceVersion": 4 } }Copy to another workspace
POST /api/charts/{chartId}/actions/copy-to-workspace { "workspaceId": "…", "name"?: "…" } → { "chart": { id, name, workspaceId, sourceChartId } }.
Callers: a dashboard session or a workspace admin API key (see §15). The caller (for a key: its admin) must be an admin of both workspaces, the target workspace must be active, and the target plan's chart limit applies (402 when reached).
6. Workspaces (dashboard or workspace admin key)
| Call | Response |
|---|---|
GET /api/workspaces | { items: [{ workspaceId, displayName, role, planId, subscriptionStatus, status, deactivatedAt, isCurrent }] } |
POST /api/workspaces/{workspaceId}/actions/deactivate | { workspaceId, status: "deactivated", deactivatedAt } |
POST /api/workspaces/{workspaceId}/actions/activate | { workspaceId, status: "active", deactivatedAt: null } |
role is admin, map_editor or viewer; status is active or deactivated. Deactivation is idempotent and requires an admin (403 otherwise, 404 when you are not a member). You cannot deactivate your last active workspace (409 last_active_workspace).
Workspace admin API key (seats.io company admin key parity). A workspace admin can mint a key that also manages workspaces: tick Workspace admin key in Settings → API keys, or POST /api/keys { "name": "Ops", "scopes": ["read"], "workspaceAdmin": true } with an admin dashboard session (PATCH /api/keys/{id} { "workspaceAdmin": false } removes it). With x-api-key set to that key, the three calls above act for the admin who minted it: they list that admin's workspaces (isCurrent = the key's own workspace) and (de)activate those the admin still administers. The capability is re-checked on every call and disappears when the admin loses the admin role in the key's workspace. Ordinary keys keep getting 403 ERR_PARTNER_SCOPE. A key cannot deactivate its own workspace (409 own_workspace), because a deactivated workspace refuses its own keys. SDK: client.workspaces.list(), .activate(id), .deactivate(id).
What a deactivated workspace refuses. API keys, viewer JWTs and embed / public reads answer:
HTTP/1.1 403
{ "error": "workspace_deactivated", "message": "…" }All data is kept. Dashboard members can still sign in, and admins can reactivate the workspace at any time.
Chart validation severities
Each workspace chooses how every designer check is treated: Off (not checked), Warning (shown, never blocks) or Error (blocks publishing and creating events). Dashboard: Settings → Chart validation (admins edit, everyone else sees the levels; Reset to defaults restores them). The rule list and defaults are in Map designer → Validation.
| Call | Who | Response |
|---|---|---|
GET /api/workspace/chart-validation | any member or API key | { severities: { ruleId: "off" | "warning" | "error" }, defaults, overridden: [ruleId] } |
PATCH /api/workspace/chart-validation { "severities": { "orphan_gap": "error" } } | admin dashboard session | same shape |
PATCH /api/workspace/chart-validation { "reset": true } | admin dashboard session | defaults restored |
Unknown rule ids or levels return 422; partner API keys and non-admins get 403. Only differences from the defaults are stored, every change is audited (workspace.chart_validation.update), and the levels are read from the per-process workspace cache (no extra query per request; other API replicas drop the cached levels as soon as a Redis invalidation message reaches them, within milliseconds (at most 5 seconds if the message is lost)). Charts are never re-validated: their stored per-rule counts are read again under the new levels, so GET /api/charts (validation) and event creation (409 chart_invalid) follow the change immediately.
7. Lifecycle webhooks
Five new event types can be subscribed to (they are listed by GET /api/webhooks/event-types). Endpoints subscribed to ["*"] receive them automatically.
| Type | Item fields |
|---|---|
hold.expired | tenant_id, event_id, seat_id, status: "expired", expired_at — a seat hold expired; emitted once by the expiry worker. |
event.created | tenant_id, event_id, key, name, date, chart_id |
event.updated | tenant_id, event_id, key, changed_fields (string array), chart_id |
event.deleted | tenant_id, event_id, key |
chart.published | tenant_id, chart_id, version, geometry_hash, linked_event_count |
The body has the same envelope as every other webhook: { eventId, type, occurred_at, tenant_id, items: [...] }, where eventId is the webhook event id (also in X-SeatsChart-Event-Id), not a SeatsChart event.
{
"eventId": "2b6f0c1e-8a4d-4f3b-9c2e-1d0a7e5b3c91",
"type": "event.updated",
"occurred_at": "2026-10-22T14:03:11.000Z",
"tenant_id": "a1b2…",
"items": [
{ "tenant_id": "a1b2…", "event_id": "6d0c…", "key": "concert-2026-12-01", "changed_fields": ["name", "date"], "chart_id": "a7e9…" }
]
}{
"eventId": "5d3a9e7b-1c2f-4a6d-8b0e-9f1c3d5e7a24",
"type": "hold.expired",
"occurred_at": "2026-10-22T14:05:00.000Z",
"tenant_id": "a1b2…",
"items": [
{ "tenant_id": "a1b2…", "event_id": "6d0c…", "seat_id": "5a0e…", "status": "expired", "expired_at": "2026-10-22T14:05:00.000Z" }
]
}{
"eventId": "8e1f2a3b-4c5d-4e6f-8a7b-9c0d1e2f3a46",
"type": "chart.published",
"occurred_at": "2026-10-22T15:20:42.000Z",
"tenant_id": "a1b2…",
"items": [
{ "tenant_id": "a1b2…", "chart_id": "3f2b…", "version": 5, "geometry_hash": "…", "linked_event_count": 3 }
]
}These requests are signed like all others: verify x-seatschart-signature-v2 (t=<unix seconds>,v1=<hex>, an HMAC-SHA256 of "<t>.<raw body>" with the endpoint's signingSecret), reject timestamps more than 300 seconds from now, and deduplicate on X-SeatsChart-Event-Id. The legacy x-seatschart-signature is still sent. See Webhooks, scopes & usage for verification code, the endpoint URL policy, the delivery log and replays.
8. Ticket buyer ids
ticketBuyerId lets you attach your own identifier of the ticket buyer to holds and sales and find their objects later.
- Opaque string, 1 – 256 characters, no control characters.
- Accepted on
POST /api/seats/reserve,POST /api/hold-tokens,POST /api/seats/confirm,POST /api/ga/reserve,POST /api/ga/confirmandchange-object-status(and its shortcuts). - Inheritance: a confirmation without
ticketBuyerIdinherits the value given when the hold token was created. - Persisted on orders, reservations, GA receipts, object statuses and status history.
curl -sS -X POST "$API/api/hold-tokens" \
-H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a" \
-d '{ "eventId": "6d0c2a1e-9b3f-4c5d-8e7a-1f2b3c4d5e6f", "userId": "buyer-order-42", "ticketBuyerId": "crm-482913" }'Find a buyer's objects
GET /api/events/{eventId}/objects?ticketBuyerId=crm-482913
{
"ticketBuyerId": "crm-482913",
"items": [
{ "objectId": "5a0e…", "label": "A-1", "objectType": "seat", "status": "booked", "orderId": "c0de…" },
{ "objectId": "8d6b…", "label": "GA floor", "objectType": "generalAdmission", "status": "booked", "quantity": 2, "orderId": "c0df…" }
]
}status is booked, resale, reservedByToken or one of your custom statuses. ticketBuyerId is also returned by GET /api/events/{eventId}/objects/{objectId}, the object status-change history and GET /api/orders/{orderId}.
Privacy. ticketBuyerId is an opaque partner identifier. Use a stable id from your own system (a CRM or customer id, or a hash of it). Never send an email address, a name or any other personal data, and never an internal SeatsChart user id.
9. Event reports in the API reference
The 11 GET /api/events/{eventId}/reports/* routes — occupancy.csv, sales.csv, summary.json, by-status.json, by-category.json, deep-summary.json, by-label.json, by-order-id.json, by-channel.json, by-object-type.json and by-section.json — are now part of the OpenAPI document, so they appear in the API reference and in clients generated from it (Server SDKs).
10. Event workflow: quick events, ended events, detach
Quick event (dashboard). Every published chart card in the chart library has Create event: one click creates an event with sensible defaults — name "<chart name> · <short date>", date next week at 20:00 (browser time), the workspace's most recent event currency — and opens its Event Manager, where the name and date are edited inline in the header. Create event with options… opens the full form. A chart whose published map has error-level validation issues is refused with 409 chart_invalid and the same "Fix in designer" callout as the form.
Chart cards also list up to three events of the chart (upcoming soonest first, then past, then ended) with See all (N), which opens the events list filtered by that chart. GET /api/charts?limit=… items carry them:
{ "id": "3f2b…", "name": "Arena", "eventCount": 14,
"recentEvents": [ { "id": "6d0c…", "name": "Arena · Fri, Oct 30", "date": "2026-10-30T19:00:00.000Z", "status": "upcoming" } ] }Event Manager. A collapsible sidebar shows the event's chart (link to the designer), the other events of that chart (sortable by date created, name A–Z / Z–A or event date) and the seasons containing the event; below 760 px it becomes a drawer. The header shows occupancy at a glance — N unavailable / capacity with available, held, sold, not-for-sale and custom-status counts — computed in the browser from the state the manager already loads (no extra request), plus Download report (CSV) (GET /api/events/{eventId}/reports/occupancy.csv), Mark as ended and Detach event.
Ended events. "Mark as ended" sets isInThePast: true (PATCH /api/events/{eventId}, irreversible; season booking already ignores such members). Ended events are hidden from the dashboard's default events list ("Show ended" brings them back). The API keeps returning them unless you ask otherwise:
curl -sS "$API/api/events?chartId=3f2b…&ended=exclude&limit=50" -H "x-api-key: sk_live_xxx"
curl -sS "$API/api/events?sort=created&limit=20" -H "x-api-key: sk_live_xxx" # newest created firstended is include (default), exclude or only. Events now carry createdAt; GET /api/events/{eventId} also returns seasons: [{ id, name }].
Detach event moves the event onto its own copy of its chart (POST /api/events/{eventId}/actions/move-to-new-chart-copy): the new chart is a copy of the published map, seats, holds, sales and orders are untouched, and other events keep the original chart.
11. Status history with origin
GET /api/events/{eventId}/status-changes (and /objects/{objectId}/status-changes) now returns every status change: holds, releases and hold expiries as well as sales, cancellations and administrative changes. Each item has an origin:
{ "id": "…", "objectId": "5a0e…", "objectLabel": "A-12", "objectType": "seat", "action": "hold", "quantity": 1,
"previousObjectStatus": "free", "newObjectStatus": "reservedByToken", "holdOwnerHash": "9c1f…", "orderId": null,
"origin": { "kind": "api_key", "actorId": "1b7e…", "actorName": "Box office key", "ip": "203.0.113.0" },
"createdAt": "2026-10-23T18:04:11.204Z" }origin.kind | Who |
|---|---|
api_key | A partner API key (actorName = key name) |
dashboard_user | A dashboard member (actorName = their email) |
buyer_session | A buyer viewer session (actorId = the userId you sent) |
system_expiry | A hold that expired |
origin is null for changes recorded before origins existed. Privacy: the IP is truncated to its /24 (IPv4) or /48 (IPv6) network, and holdOwnerHash is a workspace-scoped digest that correlates a hold with its release or expiry without exposing the hold token.
Filters: label + labelMatch (exact default, contains, prefix, suffix; case-insensitive, seat labels and GA area names), action (hold, release, hold_expired, sell, cancel, booked, free, resale, retired, a custom status), origin (the kinds above or unknown), orderId, sort (newest default, oldest) and cursor pagination (limit, cursor → nextCursor).
curl -sS "$API/api/events/6d0c…/status-changes?label=A-&labelMatch=prefix&origin=system_expiry&limit=50" -H "x-api-key: sk_live_xxx"Performance. Holds, releases and expiries are appended to a Redis Stream (one unawaited XADD per request, never a synchronous database write) and stored in batches by the worker, so they appear within seconds. Exact, prefix and suffix label searches use an index.
The Event Manager has a History tab with the same search modes, origin filter and sort; the event reports page keeps its history card.
12. Public chart preview links
Share a read-only rendering of a chart with anyone — no login, no event data, no prices (category names and colors only).
curl -sS -X POST "$API/api/charts/3f2b…/preview-link" -H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
-d '{ "expiresInDays": 30 }'
# → { "chartId": "3f2b…", "exp": 1795000000, "signature": "…", "expiresAt": "…", "path": "/preview/charts/3f2b…?exp=…&sig=…" }Prefix path with your dashboard origin. Links are signed with a per-chart secret (HMAC-SHA256) and expire after expiresInDays (1 – 365, default 30). DELETE /api/charts/{chartId}/preview-link revokes every link of the chart (the secret is rotated; it takes effect within ~15 seconds on all API instances). The chart must be published (409 chart_not_published).
The preview page renders the published geometry in WebGL with pan / zoom and a Show row labels toggle; it is marked noindex. In the dashboard use Copy preview link in the chart's menu or the designer toolbar. The public reads (GET /api/public/chart-previews/{chartId}/manifest|geometry/{hash}?exp=…&sig=…) return 403 preview_link_invalid, 410 preview_link_expired or 404; the geometry is content-addressed and cacheable while the link is valid.
13. Chart list expansion and workspace by id
curl "$API/api/charts?expand=events,validation&limit=50" -H "x-api-key: $KEY"
curl "$API/api/workspaces/$WORKSPACE_ID" -H "Authorization: Bearer $DASHBOARD_JWT"
curl -X PATCH "$API/api/workspaces/$WORKSPACE_ID" -H "Authorization: Bearer $DASHBOARD_JWT" -H "Content-Type: application/json" -d '{"displayName":"Box office BR"}'
curl -X DELETE "$API/api/workspaces/$WORKSPACE_ID" -H "Authorization: Bearer $DASHBOARD_JWT" # soft delete = deactivateexpand=eventsaddsevents[](id,key,name,date,isInThePast, soonest first, up to 100 per chart;eventCounthas the total).expand=validationis accepted for seats.io compatibility —validationis always part of the page.GET/PATCH/DELETE /api/workspaces/{workspaceId}need a dashboard session or a workspace admin key; renaming and deleting require the admin role in that workspace. Workspaces are never hard-deleted:DELETEdeactivates (reversible with…/actions/activate) and keeps every chart, event and order.
14. Event Manager tools, category reports and chart drafts
# Table booking of one event (null = follow the chart again)
curl -X PATCH "$API/api/events/$EVENT_ID" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"tableBookingConfig":{"mode":"custom","tables":{"T-12":"by_table","T-14":"by_seat"}}}'
# Whole categories and withheld GA places
curl -X POST "$API/api/events/$EVENT_ID/inventory/for-sale/apply" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"forSale":false,"categories":["Restricted view"],"areaPlaces":{"'$GA_AREA_ID'":20}}'
# Reports by category key / label, and a chart draft
curl "$API/api/events/$EVENT_ID/reports/by-category-key.json" -H "x-api-key: $KEY"
curl "$API/api/events/$EVENT_ID/reports/summary/by-category-label.csv" -H "x-api-key: $KEY"
curl "$API/api/charts/$CHART_ID/reports/summary/by-category-key.json?version=draft" -H "x-api-key: $KEY"- Table booking per event. The Event Manager Table booking tab edits
tableBookingConfig(PATCH/api/events/{eventId}): follow the chart, book by seat, book whole tables, or choose per table. A badge says when the event overrides the chart and Reset to chart sendsnull. Before saving, the manager warns when a table would switch to whole-table booking while some (not all) of its seats are already sold or held — its free seats could not be sold until they are released — and asks for confirmation. Seat-to-table membership always comes from the chart. - Rectangle and lasso selection. The map toolbar has Click / Rectangle / Lasso (keys V, M, L). A drag replaces the selection, Shift adds to it and Alt / Option removes from it; hits come from the map's spatial index, so large sweeps stay instant. The selection feeds every tab (for sale, channels, statuses, orders).
- Whole rows. The Whole rows toggle (key R, seats.io "Select whole rows") makes a click add the seat's row block (a click on a selected seat removes it) and a rectangle or lasso take every row it touches. It is remembered per browser and not offered while
maxSelectedObjectscaps the selection. - For sale by category and GA places. The For sale tab lists each category with its not-for-sale count and puts it on or off sale in one call (
categories), and lets you withhold or return places of a GA area without closing it (areaPlaces; refused with409 insufficient_free_placeswhen the places are sold or held). Totals of seats not for sale, closed areas and withheld places are derived from the map the manager already loaded. - Reports by category key and label. Event and chart reports add
by-category-key/by-category-label(detailed, JSON and CSV) and the summary groupingssummary/by-category-key/summary/by-category-label. The key is the chart category id (or the event category key of an event-only category); the label is the objects'categoryvalue.by-categoryandsummary/by-categoryare unchanged. Rows carrycategoryKey; CSVs end with acategory_keycolumn. - Draft chart reports. Every chart report accepts
?version=draftto report the unpublished designer draft (404draft_not_foundwithout one). It reads only the draft row — never the published geometry or its caches. The dashboard chart reports switch between Published and Draft; SDK:reports.chartDraft(chartId, report).
15. Chart keys, chart versions and zones, workspace admin keys, season events
# Create a chart with a key, a designer template and categories in one call
curl -X POST "$API/api/charts" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"name":"Arena","key":"arena-main","venueType":"zones","categories":[{"key":"std","label":"Standard","color":"#22c55e"},{"label":"Wheelchair","color":"#2563eb","accessible":true}]}'
# The key works wherever a chart id does (path, body chartId, ?chartId=)
curl "$API/api/charts/arena-main?expand=zones" -H "x-api-key: $KEY"
curl -X POST "$API/api/events" -H "x-api-key: $KEY" -H "Content-Type: application/json" -d '{"name":"Opening","chartId":"arena-main"}'
curl -X PATCH "$API/api/charts/arena-main" -H "x-api-key: $KEY" -H "Content-Type: application/json" -d '{"key":"arena-2027"}'
# The published chart while a draft exists, and its zones
curl "$API/api/charts/arena-2027/map" -H "x-api-key: $KEY" # ?version=draft for the draft
curl -X POST "$API/api/charts/arena-2027/actions/validate?version=published" -H "x-api-key: $KEY"
curl "$API/api/charts/arena-2027/zones?version=published" -H "x-api-key: $KEY"
curl "$API/api/events/$EVENT_ID/zones" -H "x-api-key: $KEY" # or GET /api/events/{id}?expand=zones
# Workspace admin key: copy a chart, create a workspace, set the default workspace
curl -X POST "$API/api/charts/arena-2027/actions/copy-to-workspace" -H "x-api-key: $ADMIN_KEY" -H "Content-Type: application/json" -d '{"workspaceId":"ws_123"}'
curl -X POST "$API/api/workspaces" -H "x-api-key: $ADMIN_KEY" -H "Content-Type: application/json" -d '{"displayName":"Box office EU"}'
curl -X POST "$API/api/workspaces/ws_123/actions/set-default" -H "x-api-key: $ADMIN_KEY"
# Seasons: new events in an existing season
curl -X POST "$API/api/seasons/$SEASON_ID/actions/create-events" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"events":[{"key":"match-07","date":"2027-03-14T19:00:00Z"}]}'- Chart keys.
keyonPOST /api/chartsandPATCH /api/charts/{chartId}(nullclears it): 1–100 characters of letters, digits,_ . ~ -, never UUID-shaped, unique per workspace (409 chart_key_taken). It is accepted wherever a chart id is — pathchartId, bodychartId(events, seasons) and?chartId=(event list) — through the same cached key → id lookup as event keys: a UUID is never looked up, and a renamed key stops resolving at once on every API instance (a Redis invalidation message; the 5 second cache lifetime only bounds a lost message). Lists andGET /api/charts/{chartId}returnkey;qalso matches the exact key. - Create with categories and a template.
venueType(simple,sectionsorzones, the designer template) andcategories([{ key?, label, color, accessible? }], up to 128, keys and labels unique) are written as the chart's first draft in the same transaction (hasDraft: true). - Published or draft.
GET /api/charts/{chartId}/mapreturns the published map even while a draft exists (meta.hasUnpublishedDraftsays so);?version=draftreturns only the draft.GET …/categoriesandPOST …/actions/validateaccept?version=published|draft(without it: the draft, else the published map). A missing version is404 not_published/404 draft_not_found, like the chart reports. - Zones.
GET /api/charts/{chartId}/zones(published by default,?version=draft),GET /api/events/{eventId}/zones(the layout the event is drawn from) andexpand=zoneson both reads return{ id, name, color, floorId, focalPoint, bounds, points }. Only the zone shapes are read; counts per zone come from theby-zonereports. - Copy to another workspace with an API key.
POST /api/charts/{chartId}/actions/copy-to-workspaceaccepts a workspace admin API key (a scoped key also needscharts_write). The admin the key is bound to must administer both workspaces (checked on every call); both workspaces get achart.copy_to_workspaceaudit entry. Ordinary keys get403 ERR_PARTNER_SCOPE. - Workspaces.
POST /api/workspacesaccepts a full-access workspace admin key: the key's admin becomes admin of the new workspace (audit metadatavia: admin_key).POST /api/workspaces/{workspaceId}/actions/set-defaultsets the default workspace of the caller (for a key: of its admin) — the one dashboard sign-in opens;GET /api/workspacesmarks itisDefault. A deactivated workspace cannot be the default (409). - Season events.
POST /api/seasons/{seasonId}/actions/create-eventstakes exactly one ofeventKeys,numberOfEventsorevents: [{ key?, name?, date? }](up to 50) and creates the events on the season chart, in the season, in one transaction. A season without a source gets the first new event as source; otherwise the new events receive the season inventory from the source (syncSummary).409 season_has_no_chartuntil the season has a chart. With partial seasons,forSalePropagatedand season status overrides, this covers the seats.io seasons API (seasons and partial seasons are addressed by id or key, see section 16). - Resale listings.
resaleListingIdonPOST /api/events/{eventId}/actions/put-up-for-resale(and change-object-status) is stored on every object'sextraData.resaleListingId. Pass the listings to the renderer asresaleListings(see the README) to sell each one as a whole. - SDK.
charts.create,charts.rename,charts.get(id, { expand, version }),charts.getMap,charts.zones,charts.validate(id, body, { version }),events.zones,events.get(id, { expand }),workspaces.create,workspaces.setDefault,seasons.createEvents.
16. Season and partial-season keys, new events off sale
# A season with a key (and its events), then address it by key
curl -X POST "$API/api/seasons" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"name":"Season 2027","key":"season-2027","chartId":"arena-main","eventKeys":["match-01","match-02"]}'
curl "$API/api/seasons/season-2027" -H "x-api-key: $KEY"
# Partial seasons: keys unique within their season
curl -X POST "$API/api/seasons/season-2027/partial-seasons" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"name":"First half","key":"first-half","eventIds":["$EVENT_ID"]}'
curl -X PATCH "$API/api/seasons/season-2027/partial-seasons/first-half" -H "x-api-key: $KEY" -H "Content-Type: application/json" -d '{"key":"h1"}'
# Book across the season (or a partial season) by key
curl -X POST "$API/api/events/match-01/actions/change-object-status" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"seasonId":"season-2027","objects":["A-1"],"status":"booked"}'- Season keys.
keyonPOST /api/seasonsandPATCH /api/seasons/{seasonId}(nullor""clears it) follows the event and chart key rules (1–100 URL-safe characters, never UUID-shaped) and is unique per workspace (409 season_key_taken). It is accepted wherever a season id is: pathseasonId(every season route, reports included),?seasonId=and bodyseasonId(POST /api/seats/reserve,change-object-status, event creation and bulk creation). Lists and reads returnkey; an unknown key is404withcode: ERR_SEASON_NOT_FOUND. - Partial-season keys.
keyonPOST /api/seasons/{seasonId}/partial-seasonsand on the newPATCH /api/seasons/{seasonId}/partial-seasons/{partialSeasonId}(name and key;409 partial_season_key_taken), unique within the season. In paths the key is resolved within the season of the path. In a bodyseasonIda key is a season key first, otherwise the key of the one partial season of the workspace that uses it (409 ambiguous_season_keywhen several do: pass the id). - Cost. Keys go through the same bounded, cached key → id lookup as event and chart keys, during authentication: a UUID is never looked up, so seat and hold calls only pay a type check. A renamed, cleared or deleted key stops resolving at once on the instance that changed it and, through a Redis invalidation message, within milliseconds on the others (at most 5 seconds if the message is lost).
- New events start off sale. With the workspace setting
newEventsNotForSale(default off, see the platform guide), every new event (POST /api/events,/events/bulk, season event creation) starts with every seat and GA area not for sale, inside the creation transaction. An explicitforSaleConfigwins; season members still receive the season inventory from the source. Open sales withPOST /api/events/{eventId}/actions/mark-everything-as-for-saleor in the Event Manager. - SDK.
seasons.create({ key }),seasons.update(id, { key }),seasons.partialSeasons.create(id, { key }),seasons.partialSeasons.update(id, partialId, { name, key }).
17. Charts with their events, report filters and deep summaries, workspace filters
# A chart with its 10,000 newest events (and the total)
curl "$API/api/charts/arena-main?expand=events" -H "x-api-key: $KEY"
# Chart list with the designer template, the zones and 5 events per chart
curl "$API/api/charts?expand=venueType,zones,events&eventsLimit=5&limit=50" -H "x-api-key: $KEY"
# Chart report with tables booked as a whole
curl "$API/api/charts/arena-main/reports/by-object-type.json?bookWholeTables=chart" -H "x-api-key: $KEY"
# Event report for one value, and a deep summary
curl "$API/api/events/$EVENT_ID/reports/by-object-status.json?status=sold" -H "x-api-key: $KEY"
curl "$API/api/events/$EVENT_ID/reports/deep-summary/by-category-label.json" -H "x-api-key: $KEY"
# An event created with its own categories
curl -X POST "$API/api/events" -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"name":"Gala","chartId":"arena-main","categories":[{"key":"balcony","label":"Balcony","color":"#0ea5e9"}],"objectCategories":{"A-1":"balcony"}}'
# Workspaces: search, active only; copy a chart from workspace X to workspace Y with one admin key
curl "$API/api/workspaces?filter=europe&status=active" -H "x-api-key: $ADMIN_KEY"
curl -X POST "$API/api/charts/$CHART_ID/actions/copy-to-workspace" -H "x-api-key: $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"fromWorkspaceId":"ws_x","workspaceId":"ws_y"}'- Chart with its events.
GET /api/charts/{chartId}?expand=eventsaddsevents[]— the 10,000 most recently created events of the chart, newest first (id,key,name,date,isInThePast,createdAt) — andnumTotalEvents. It combines withexpand=zones(expand=events,zones). One bounded, indexed query; withoutexpandnothing changes. - Chart list expansions.
expand=venueTypeaddsvenueType(simple,sectionsorzones: the designer template of the published chart, else of its draft) andexpand=zonesaddszones[]of the published chart, else of its draft. Both are read for the whole page in one query that projects only the template and the zone shapes (no seat geometry).eventsLimit(1–1000, default 100) sets how many eventsexpand=eventslists per chart. - Tables booked as a whole in chart reports.
bookWholeTableson every chart report (/api/charts/{chartId}/reports/*):false(default) lists every seat;truelists each table as one object (objectType: table,capacity= its seats,seats= their labels,price= their sum);chartdoes so only for the tables the chart books as a whole (table bookingall_by_table, orcustomtables set toby_table). The response echoesbookWholeTables; summaries count a table's places as seats and addbyObjectType.table. - Event reports for one value.
by-object-status(new, detailed objects grouped by status) takes?status=;by-object-type.jsontakes?objectType=seat|ga;by-category-labeltakes?category=(or?categoryLabel=),by-category-key?categoryKey=,by-zone?zone=,by-availability?availability=andby-availability-reason?availabilityReason=(or?reason=). Only that bucket is returned.by-status.jsonkeeps returning counts. - Deep summaries.
GET /api/events/{eventId}/reports/deep-summary/by-{status|object-type|category-key|category-label|category|section|zone|availability|availability-reason|channel}.jsonreturns the buckets of the matching summary, each withdeep: one full summary bucket per section (per status forby-section, named indeepBy). The olderdeep-summary.jsonis unchanged. - Categories at creation.
POST /api/eventsand every item ofPOST /api/events/bulkacceptcategoriesandobjectCategorieswith the same rules asPATCH /api/events/{eventId}(400 category_conflict/unknown_category). They are applied first in the creation transaction, socategoryOverrides,forSaleConfigand channels see the event categories. The response returns both. - Workspace list filters.
GET /api/workspaces?filter=keeps the workspaces whose name or id contains the text (case-insensitive);status=activelists the active ones,status=inactivethe deactivated ones (defaultall). - Copy between two other workspaces.
POST /api/charts/{chartId}/actions/copy-to-workspacetakesfromWorkspaceId: one workspace admin key (or dashboard session) copies a chart of workspace X into workspace Y. Its admin must administer both (403 ERR_FORBIDDEN_ROLE); pass the chart id (keys resolve in the calling workspace only);404 ERR_CHART_NOT_FOUNDwhen the chart is not in X. X and Y getchart.copy_to_workspace, the calling workspace (when it is neither)chart.copy_between_workspaces; the response addssourceWorkspaceId. - SDK.
charts.get(id, { expand: ['events'] }),charts.list({ expand: ['venueType', 'zones', 'events'], eventsLimit }),charts.copyFromWorkspaceTo(chartId, from, to),reports.event(id, 'deep-summary/by-status'),reports.event(id, 'by-object-status', { status }),reports.chart(id, report, { bookWholeTables }),events.reportByObjectType(id, 'seat'),events.create({ categories, objectCategories }),workspaces.list({ filter, status }),workspaces.defaults().
