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 use API=https://api.seatschart.com and the placeholder key sk_live_xxx.
  • Server-to-server: x-api-key. Dashboard sessions use Authorization: Bearer <JWT>. Routes marked dashboard only refuse partner keys with 403 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.

RuleValue
Length1 – 100 characters
Pattern^[A-Za-z0-9][A-Za-z0-9_.~-]{0,99}$ (starts with a letter or digit; then letters, digits, _, ., ~, -)
Not allowedA value that looks like a UUID
UniquenessPer 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 example GET /api/events/concert-2026-12-01/seats);
  • a body eventId (for example POST /api/seats/reserve, POST /api/ga/confirm);
  • a body eventIds[] and a body events[] (for example multi-event change-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 by GET /api/events, GET /api/events/{eventId}, POST /api/events, POST /api/events/bulk and PATCH /api/events/{eventId}.
  • GET /api/events?key=concert-2026-12-01 filters by exact key.
  • PATCH /api/events/{eventId} with "key": "new-key" sets or renames it; "key": null clears it.
bash
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:

FieldMeaning
keyEvent 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.
channelsSales 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 listed objects and categories are not for sale; areaPlaces are places withheld from sale per GA area.
  • forSale: true — only the listed objects and categories are for sale (everything else is not for sale); areaPlaces are 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.

bash
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:

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

bash
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.
bash
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:

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

json
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

  1. 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.
  2. 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).
  3. Retry the PATCH. The check runs again under the same transaction, so a successful switch is always consistent.
HTTPerrorWhen
404—The target chart does not exist in this workspace.
409chart_not_publishedThe target chart has never been published.
409chart_archivedThe target chart is archived.
409season_chart_mismatchThe event belongs to a season that uses another chart.
409chart_switch_conflictSee 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.

OperationBodyEffect
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 holdTokenReturns places that were booked administratively, newest receipts first.
Release held places"type": "RELEASE" with holdTokenReleases that many places held by the GA hold token.

Hold

bash
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 generated admin-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:

bash
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

bash
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; orderId restricts 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:

OperationPrevious status
Book, holdfree
Release without holdTokenbooked
Release with holdTokenreservedByToken

A failed precondition returns 409 illegal_previous_status and nothing is applied.

Response

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

bash
curl -sS -X POST "$API/api/charts/$CHART_ID/actions/validate" -H "x-api-key: sk_live_xxx"
json
{
  "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.

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

bash
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:

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

CallResponse
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:

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

CallWhoResponse
GET /api/workspace/chart-validationany member or API key{ severities: { ruleId: "off" | "warning" | "error" }, defaults, overridden: [ruleId] }
PATCH /api/workspace/chart-validation { "severities": { "orphan_gap": "error" } }admin dashboard sessionsame shape
PATCH /api/workspace/chart-validation { "reset": true }admin dashboard sessiondefaults 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.

TypeItem fields
hold.expiredtenant_id, event_id, seat_id, status: "expired", expired_at — a seat hold expired; emitted once by the expiry worker.
event.createdtenant_id, event_id, key, name, date, chart_id
event.updatedtenant_id, event_id, key, changed_fields (string array), chart_id
event.deletedtenant_id, event_id, key
chart.publishedtenant_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.

json
{
  "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…" }
  ]
}
json
{
  "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" }
  ]
}
json
{
  "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/confirm and change-object-status (and its shortcuts).
  • Inheritance: a confirmation without ticketBuyerId inherits the value given when the hold token was created.
  • Persisted on orders, reservations, GA receipts, object statuses and status history.
bash
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

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

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

bash
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 first

ended 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:

json
{ "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.kindWho
api_keyA partner API key (actorName = key name)
dashboard_userA dashboard member (actorName = their email)
buyer_sessionA buyer viewer session (actorId = the userId you sent)
system_expiryA 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).

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


Share a read-only rendering of a chart with anyone — no login, no event data, no prices (category names and colors only).

bash
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

bash
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 = deactivate
  • expand=events adds events[] (id, key, name, date, isInThePast, soonest first, up to 100 per chart; eventCount has the total). expand=validation is accepted for seats.io compatibility — validation is 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: DELETE deactivates (reversible with …/actions/activate) and keeps every chart, event and order.

14. Event Manager tools, category reports and chart drafts

bash
# 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 sends null. 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 maxSelectedObjects caps 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 with 409 insufficient_free_places when 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 groupings summary/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' category value. by-category and summary/by-category are unchanged. Rows carry categoryKey; CSVs end with a category_key column.
  • Draft chart reports. Every chart report accepts ?version=draft to report the unpublished designer draft (404 draft_not_found without 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

bash
# 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. key on POST /api/charts and PATCH /api/charts/{chartId} (null clears 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 — path chartId, body chartId (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 and GET /api/charts/{chartId} return key; q also matches the exact key.
  • Create with categories and a template. venueType (simple, sections or zones, the designer template) and categories ([{ 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}/map returns the published map even while a draft exists (meta.hasUnpublishedDraft says so); ?version=draft returns only the draft. GET …/categories and POST …/actions/validate accept ?version=published|draft (without it: the draft, else the published map). A missing version is 404 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) and expand=zones on both reads return { id, name, color, floorId, focalPoint, bounds, points }. Only the zone shapes are read; counts per zone come from the by-zone reports.
  • Copy to another workspace with an API key. POST /api/charts/{chartId}/actions/copy-to-workspace accepts a workspace admin API key (a scoped key also needs charts_write). The admin the key is bound to must administer both workspaces (checked on every call); both workspaces get a chart.copy_to_workspace audit entry. Ordinary keys get 403 ERR_PARTNER_SCOPE.
  • Workspaces. POST /api/workspaces accepts a full-access workspace admin key: the key's admin becomes admin of the new workspace (audit metadata via: admin_key). POST /api/workspaces/{workspaceId}/actions/set-default sets the default workspace of the caller (for a key: of its admin) — the one dashboard sign-in opens; GET /api/workspaces marks it isDefault. A deactivated workspace cannot be the default (409).
  • Season events. POST /api/seasons/{seasonId}/actions/create-events takes exactly one of eventKeys, numberOfEvents or events: [{ 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_chart until the season has a chart. With partial seasons, forSalePropagated and 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. resaleListingId on POST /api/events/{eventId}/actions/put-up-for-resale (and change-object-status) is stored on every object's extraData.resaleListingId. Pass the listings to the renderer as resaleListings (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

bash
# 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. key on POST /api/seasons and PATCH /api/seasons/{seasonId} (null or "" 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: path seasonId (every season route, reports included), ?seasonId= and body seasonId (POST /api/seats/reserve, change-object-status, event creation and bulk creation). Lists and reads return key; an unknown key is 404 with code: ERR_SEASON_NOT_FOUND.
  • Partial-season keys. key on POST /api/seasons/{seasonId}/partial-seasons and on the new PATCH /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 body seasonId a key is a season key first, otherwise the key of the one partial season of the workspace that uses it (409 ambiguous_season_key when 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 explicit forSaleConfig wins; season members still receive the season inventory from the source. Open sales with POST /api/events/{eventId}/actions/mark-everything-as-for-sale or 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

bash
# 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=events adds events[] — the 10,000 most recently created events of the chart, newest first (id, key, name, date, isInThePast, createdAt) — and numTotalEvents. It combines with expand=zones (expand=events,zones). One bounded, indexed query; without expand nothing changes.
  • Chart list expansions. expand=venueType adds venueType (simple, sections or zones: the designer template of the published chart, else of its draft) and expand=zones adds zones[] 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 events expand=events lists per chart.
  • Tables booked as a whole in chart reports. bookWholeTables on every chart report (/api/charts/{chartId}/reports/*): false (default) lists every seat; true lists each table as one object (objectType: table, capacity = its seats, seats = their labels, price = their sum); chart does so only for the tables the chart books as a whole (table booking all_by_table, or custom tables set to by_table). The response echoes bookWholeTables; summaries count a table's places as seats and add byObjectType.table.
  • Event reports for one value. by-object-status (new, detailed objects grouped by status) takes ?status=; by-object-type.json takes ?objectType=seat|ga; by-category-label takes ?category= (or ?categoryLabel=), by-category-key ?categoryKey=, by-zone ?zone=, by-availability ?availability= and by-availability-reason ?availabilityReason= (or ?reason=). Only that bucket is returned. by-status.json keeps 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}.json returns the buckets of the matching summary, each with deep: one full summary bucket per section (per status for by-section, named in deepBy). The older deep-summary.json is unchanged.
  • Categories at creation. POST /api/events and every item of POST /api/events/bulk accept categories and objectCategories with the same rules as PATCH /api/events/{eventId} (400 category_conflict / unknown_category). They are applied first in the creation transaction, so categoryOverrides, forSaleConfig and 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=active lists the active ones, status=inactive the deactivated ones (default all).
  • Copy between two other workspaces. POST /api/charts/{chartId}/actions/copy-to-workspace takes fromWorkspaceId: 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_FOUND when the chart is not in X. X and Y get chart.copy_to_workspace, the calling workspace (when it is neither) chart.copy_between_workspaces; the response adds sourceWorkspaceId.
  • 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().