Document / EN
Webhooks, API key scopes, rate limits, usage, monitoring, sandboxes, workspace settings and two-factor authentication
This guide covers the controls a workspace uses to operate an integration safely: which events reach which endpoint, what each API key may do, how request limits work and how monthly usage is counted. Every feature is available in the dashboard (Webhooks, API keys, Usage) and through the API (see the webhooks, api-keys and usage tags in the API reference).
Webhooks
Event types and subscriptions
Each endpoint subscribes to ["*"] (every type, including types added later — the default) or to an explicit list:
| Type | Sent when | Delivery |
|---|---|---|
reservations.confirmed | an order was durably committed (seats sold) | transactional outbox, retried with exponential backoff (up to OUTBOX_MAX_ATTEMPTS, default 8) |
holds.created | seats or GA places were put on hold | sent by the worker right after the hold (the hold never waits for it), up to 4 attempts within ~22 s; a redelivery after a worker restart keeps the same eventId |
holds.released | held seats were released (explicit release, hold token or session) | direct |
ga.confirmed | general-admission places were sold | direct |
ga.released | a GA hold was released | direct |
objects.updated | an administrative status change (book, release, retire, custom status) | direct |
season.objects.updated | a season-level status change | direct |
webhook.test | you pressed Send test event / called POST /api/webhooks/{id}/test | one attempt, ignores the subscription |
Lifecycle types hold.expired, event.created, event.updated, event.deleted and chart.published (payloads and examples): Events, charts & workspaces. GET /api/webhooks/event-types lists every subscribable type.
curl -X POST "$API/api/webhooks" -H "x-api-key: sk_live_xxx" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/seatschart","eventTypes":["reservations.confirmed","holds.released"]}'
# → 201 {"webhook":{…,"eventTypes":["holds.released","reservations.confirmed"]},"signingSecret":"whsec_…"}
curl -X PATCH "$API/api/webhooks/$ID" -H "x-api-key: sk_live_xxx" -H "Content-Type: application/json" \
-d '{"eventTypes":["*"]}'Subscription changes apply to events emitted afterwards. Outbox obligations are fixed at commit time, so retries already queued keep their destination.
Request format and verification
Every request carries:
X-SeatsChart-Event: the event type;X-SeatsChart-Event-Id: a UUID, also present aseventIdin the body. It is stable across retries and replays — deduplicate on it;x-seatschart-timestamp: <unix seconds>: when this attempt was signed;x-seatschart-signature-v2: t=<unix seconds>,v1=<hex>(recommended): HMAC-SHA256 of the string"<t>.<raw body>"keyed with the endpoint'ssigningSecret;x-seatschart-signature: sha256=<hex>(legacy): HMAC-SHA256 of the raw body alone, still sent unchanged for backward compatibility.
Every delivery attempt — first send, retry, replay or test event — is signed with a fresh timestamp, while X-SeatsChart-Event-Id stays the same. Verify the v2 signature and reject requests whose timestamp is more than 300 seconds (5 minutes) away from your clock: a captured request can then not be replayed against you later, and a legitimate retry always passes because it is re-signed.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/** rawBody: the exact request body, read before any JSON parsing. header: x-seatschart-signature-v2. */
export function verifySeatsChartWebhook(rawBody: string, header: string | undefined, secret: string): boolean {
if (header === undefined) return false;
const parts = new Map(header.split(',').map(part => {
const i = part.indexOf('=');
return [part.slice(0, i).trim(), part.slice(i + 1).trim()] as const;
}));
const t = Number(parts.get('t'));
const v1 = parts.get('v1') ?? '';
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(v1)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest();
return timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}After a successful check, skip the request if you have already processed its X-SeatsChart-Event-Id. Receivers that still verify the legacy x-seatschart-signature keep working, but they get no replay protection — migrate to v2.
Return any 2xx quickly. Redirects are not followed; non-2xx, timeouts (8 s) and connection errors are retried as described above. Rotating the secret (POST /api/webhooks/{id}/rotate-secret) takes effect immediately, including for queued retries.
Endpoint URL policy
- Endpoints must use HTTPS.
- Internal targets are refused: loopback, private, link-local, CGNAT and cloud metadata addresses (
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16including169.254.169.254,100.64.0.0/10,::1,fc00::/7,fe80::/10),localhostand*.internalhostnames. - The check runs at registration (in production the hostname is resolved through DNS and refused if any record is internal) and again at send time: the hostname is resolved, the connection is pinned to a public address, and redirects are not followed. A blocked send is logged as
ssrf_blockedorredirect_blocked. - Outside production only, plain HTTP to
localhost/127.0.0.1is accepted for development receivers; setALLOW_DEV_LOOPBACK_WEBHOOKS=0to disable it.
Delivery log
GET /api/webhooks/{id}/deliveries?limit=50&status=failed&eventType=holds.created lists deliveries newest first with a nextCursor. Each entry has the event id and type, source (outbox, dispatch, test, replay — shown in the dashboard as Durable, Live, Test and Replay), status (pending, processing, delivered, failed), attempts, httpStatus, durationMs (wall time of the last attempt: DNS pin, connect, TLS, request and response headers), a stable lastErrorCode (http_503, webhook_timeout, webhook_connection_error, ssrf_blocked, redirect_blocked) and timestamps. Durable outbox deliveries (reservations.confirmed) record your server's HTTP status like every other source. GET …/deliveries/{deliveryId} adds the request payload. Response bodies from your server are never stored. Direct-send entries are kept for WEBHOOK_DELIVERY_RETENTION_DAYS (default 30).
Replay and test events
curl -X POST "$API/api/webhooks/$ID/deliveries/$DELIVERY/replay" -H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: $(uuidgen)"A replay re-sends the identical bytes and event id, signed with the endpoint's current secret, in one synchronous attempt. The response is 200 with the new replay delivery whether your server accepted it or not — check delivery.status. Repeating the call with the same Idempotency-Key returns the first result without sending again. Test events and replays share a per-workspace budget (WEBHOOK_MANUAL_SEND_MAX per WEBHOOK_MANUAL_SEND_WINDOW_SEC, default 30 per 60 s); beyond it the API answers 429 with Retry-After.
API key scopes
Keys are created in the dashboard (dashboard login required) with a preset or a custom set of scopes. Keys created before scopes existed keep full access.
| Scope | Allows |
|---|---|
read | events, charts, seat maps, live state, SSE stream, channels, seasons |
holds | hold / renew / release seats, best available, GA holds, hold tokens, embed tokens |
bookings | confirm sales, quotes, book / release / resell objects, read and cancel orders |
inventory_admin | sales channels, for-sale state, retire objects, object extra data, season inventory sync, season status overrides (override-season-status, use-season-status, forSalePropagated), embed domains |
charts_write | create, edit, publish, archive, tag, version, duplicate and delete charts and event seat maps |
events_write | create (also in bulk), update, duplicate, move and delete events, seasons and partial seasons |
reports | event reports, status-change history, usage |
webhooks | webhook endpoints, delivery log, replay, test events |
Presets: Read only = read; Box office = read, holds, bookings, reports; Full access = every scope. A call outside the key's scopes returns:
HTTP/1.1 403
{ "error": "insufficient_scope", "requiredScope": "holds", "message": "This API key lacks the \"holds\" scope required by this operation." }Operations not listed above that change data require a full-access key. Scope edits apply on the next request. Key, team, billing and workspace administration always require a dashboard login.
Rate limits
Limits are per workspace and depend on the plan. Each workspace has two independent buckets per window (RATE_LIMIT_TENANT_WINDOW_SEC, default 60 s):
| Plan | API lane | Sales lane |
|---|---|---|
| Free | 600 | 1,200 |
| Starter | 2,400 | 6,000 |
| Pro | 6,000 | 30,000 |
The sales lane serves the buyer path — seat map, manifest, geometry, live state and SSE stream reads, holds, best available, quotes, confirmations and releases — so a burst of report exports or administration can never block sales. Everything else uses the API lane. Responses carry X-RateLimit-Tenant-Limit, X-RateLimit-Tenant-Remaining and X-RateLimit-Tenant-Policy (for example 6000;w=60;scope=tenant;lane=sales;plan=starter). A 429 includes Retry-After (seconds until the window resets); wait at least that long before retrying with the same Idempotency-Key. A per-IP limit applies in addition. Self-hosted deployments override the defaults with RATE_LIMIT_PLAN_<FREE|STARTER|PRO>_MAX and RATE_LIMIT_PLAN_<…>_SALES_MAX.
Usage
GET /api/usage?month=YYYY-MM (default: current month, UTC) returns:
{ "month": "2026-10", "isCurrentMonth": true, "plan": { "id": "starter", "label": "Starter" },
"usedObjects": { "count": 1820, "limit": 25000 },
"charts": { "count": 4, "limit": 10 }, "events": { "count": 12, "limit": 25 },
"history": [{ "month": "2025-11", "usedObjects": 0 }, "… 12 months …"],
"byEvent": [{ "eventId": "…", "eventName": "Final", "usedObjects": 1200 }] }A used object is an object booked during the calendar month (UTC). Each seat counts once per month however many times it is retried, released and booked again; each sold general-admission place counts as one. Holds, test events and replays never count. The count is recorded in the same database transaction that commits the sale, so it is never lost or double counted. Charts and events are current totals against the plan allowance.
Monitoring
GET /api/monitoring/requests?range=10m|1h|24h|7d|30d[&before=…] (dashboard Developers → Monitoring) returns, per lane, the peak number of requests in one minute and the requests refused with 429:
{ "range": "1h", "bucketSeconds": 60, "from": "2026-10-23T11:08:00.000Z", "to": "2026-10-23T12:07:41.000Z",
"before": null, "previousBefore": "2026-10-23T11:08:00.000Z", "nextBefore": null, "retentionStart": "2026-10-22T12:07:00.000Z",
"plan": "starter", "isTest": false, "windowSeconds": 60,
"lanes": { "sales": { "limitPerMinute": 6000, "peak": 812, "rejected": 0 },
"api": { "limitPerMinute": 2400, "peak": 95, "rejected": 3 } },
"concurrency": { "current": 3, "peakLastHour": 41, "peakLastHourAt": "2026-10-23T12:07:00.000Z", "peakLastDay": 57,
"peakLastDayAt": "2026-10-23T09:00:00.000Z", "instances": 1,
"apiKeys": [{ "id": "…", "name": "Box office", "prefix": "sk_live_ab12", "current": 2, "peakLastHour": 30, "peakLastDay": 44 }] },
"points": [{ "t": "2026-10-23T12:00:00.000Z", "sales": 640, "api": 12, "salesRejected": 0, "apiRejected": 3, "concurrent": 18, "limited": true }, "…"] }10m and 1h have one point per minute, 24h one per 15 minutes (the busiest minute of the window), 7d one per hour and 30d one per UTC day. Every point says in limited whether requests were refused with 429 in it (either lane); the dashboard marks those buckets on the chart. The counters are written by the tenant rate limiter inside the Lua call it already makes for every request (no extra Redis round trip): a hash per workspace-minute (read back 24 hours) and a hash per workspace-hour with the peak minute (kept 32 days, so the last 31 days are complete). The endpoint reads a whole range in one pipelined round trip. Our limiter is a per-minute fixed window, so the chart compares requests per minute with the per-minute limit of each lane. Dashboard session (any role) or an API key with the reports scope.
History (30 days). before (ISO 8601) reads the window of the same size that ends just before that instant, aligned down to the window's buckets (minute, hour or UTC day); without it the window ends now. Each response carries the cursors to step with: previousBefore for the older window (null once nothing older is kept) and nextBefore for the newer one (null when the newer window is the live one: drop before). retentionStart is the oldest instant kept at that window size: 24 hours for 10m, 1h and 24h, 31 days for 7d and 30d. The 30-day view reads one row per day: the first time a complete day is read, its 24 hours are rolled up into a daily row (peak minute, 429 total, peak concurrency) kept as long as the hours it summarises, so later reads cost 30 small hashes and the request path writes nothing new. concurrency always describes now. The TypeScript SDK: client.workspaces.monitoring('7d', { before }).
# One week before the live window, then the week before that
curl "$API/api/monitoring/requests?range=7d" -H "x-api-key: $KEY" # → "previousBefore": "2026-10-16T13:00:00.000Z"
curl "$API/api/monitoring/requests?range=7d&before=2026-10-16T13:00:00.000Z" -H "x-api-key: $KEY"Concurrent requests. concurrency adds what per-minute counts hide: requests in flight now, the highest number of concurrent requests in any minute of the last hour and in any hour of the last day (with when), the same per API key (with its name and prefix), and concurrent per chart point. Each API instance counts its in-flight requests in memory and stores a new per-minute high inside the same limiter call (no extra round trip, usually no extra command); with several instances the peaks are added up and current is the instance that answered. Live SSE streams are connections and are not counted. Limits are per minute, not per concurrent request: at about 200 ms per request, limitPerMinute × 0.2 / 60 requests in flight for a whole minute fill a lane. The Monitoring page shows this next to the lanes. An instance is identified by API_INSTANCE_ID, else by its host name and port, so a restarted process keeps its identity (it is not added up as a second instance).
Sandboxes
A sandbox is an isolated environment that belongs to a production workspace: a workspace of its own (kind: "sandbox", parentWorkspaceId) with its own charts, events, seasons, holds, orders, ticket buyer ids, webhooks and API keys. Use it for integration tests and partner staging that mirror production, then reset it to start again. A sandbox is just another workspace to every API, so the booking paths run unchanged and at the same speed.
Create and copy from production
Manage sandboxes in Workspace → Sandboxes in the dashboard, or with the API from the production workspace (/api/sandboxes; reads need the read scope, every change a full-access key; a dashboard member reads, an admin changes). Every member of the production workspace becomes a member of the new sandbox with the same role. A workspace has at most 10 sandboxes (409 sandbox_limit_reached). The TS SDK has client.sandboxes (list, get, create, reset, delete, copyFromProduction, createAdminKey).
# Create a sandbox with a copy of the charts and their events (production key with full access, or a dashboard admin)
curl -X POST "$API/api/sandboxes" -H "x-api-key: $KEY" -H "content-type: application/json" \
-d '{"displayName":"Staging","copyCharts":true,"includeEvents":true}'
# → 201 {"sandbox":{"sandboxId":"ws_…","kind":"sandbox",…},"adminKey":{"secret":"sk_test_…",…},"copied":{"charts":[…],"events":[…],"keyConflicts":[]}}
curl "$API/api/sandboxes" -H "x-api-key: $KEY" # list with usage and rate limits
curl -X POST "$API/api/sandboxes/$SANDBOX_ID/actions/reset" -H "x-api-key: $KEY" \
-H "content-type: application/json" -d '{"rotateKeys":true,"keepWebhooks":true}'
curl -X POST "$API/api/sandboxes/$SANDBOX_ID/admin-key" -H "x-api-key: $KEY" \
-H "content-type: application/json" -d '{"revokeExisting":true}' # rotate the sandbox key
curl -X DELETE "$API/api/sandboxes/$SANDBOX_ID" -H "x-api-key: $KEY"copyCharts (or chartIds, up to 50) copies production charts with the cross-workspace chart copy: a published chart stays published (fresh object ids), a draft stays a draft. includeEvents also copies the events of each published chart (up to 200 per call) with their for-sale configuration: for-sale flags, channels with their assignments and GA place quotas, category overrides, event categories, table booking, sales rules and synced prices. Nothing sold or held is copied, and channel keys get new secrets (no production secret ever lands in a sandbox). Chart and event keys are kept when the sandbox does not use them yet (keyConflicts lists the others), so the same integration code runs against both. POST /api/sandboxes/{sandboxId}/actions/copy-from-production copies again later.
With includeEvents, the copy also brings the seasons and partial seasons of the copied events (fresh ids; members, source event and chart remapped; season keys kept when free, otherwise seasons[].key is null next to sourceKey) and each event’s event-level layout. When every chart is copied (no chartIds), events with their own map (no chart) come too (eventsWithOwnMap). Production is only read. The team follows: members who join the production workspace later get access to its sandboxes with their role (when they accept the invitation, or the next time the sandboxes are listed), role changes apply to every sandbox, and members removed from production lose its sandboxes too. A 50,000-seat event copies in about 10 s and resets in about 1.3 s; the reset clears Redis key by key from the event hold indexes, never scanning the keyspace.
Isolation
- Sandbox keys are
sk_test_…and work only inside their sandbox; production keys never work in a sandbox. Each workspace resolves its own chart, event and season keys. - Any credential of a sandbox (key or dashboard session) gets 403
ERR_SANDBOX_SCOPEon/api/sandboxes, so a sandbox can never act on its production workspace or on another sandbox. - Sandbox keys are never workspace admin keys (
workspaceAdmin: trueis refused in a sandbox), so they cannot list or manage workspaces. - Ticket buyer ids, webhooks, holds and orders are per workspace: nothing is shared with production.
Billing, rate limits, webhooks and emails
- Usage: bookings in a sandbox are metered so the Sandboxes page and
GET /api/usageshow them, but a sandbox is never billed (checkout answers 409test_workspace_not_billable) and has no chart, event or used-object limits (limit: null). - Rate limits: a sandbox runs at the limits of its production plan, in buckets of its own, so load tests in a sandbox never consume production's budget (
rateLimitsin the sandbox item). - Webhooks: deliveries of a sandbox carry
"sandbox": truein the body and theX-SeatsChart-Sandbox: trueheader (production deliveries are unchanged). - Emails: billing emails of a sandbox are never sent; other emails (team invitations) are marked
[Sandbox]. - Buyers: the event manifest carries
"testWorkspace": trueand the viewer shows a small Sandbox ribbon (production manifests and their ETags are unchanged).
Reset and delete
POST /api/sandboxes/{sandboxId}/actions/reset deletes all data of the sandbox in one transaction (all or nothing): orders, bookings, holds, events with their inventory, seasons, charts, listings, ticket buyer ids, usage, webhook deliveries and its audit log; live holds are cleared from the cache. Members, settings, the public key, embed domains, API keys (unless rotateKeys: true, which revokes them and returns a new admin key once) and webhook endpoints (unless keepWebhooks: false) stay. The sandbox row is locked first: a concurrent confirmation either commits before the reset (and is deleted) or fails after it.
DELETE /api/sandboxes/{sandboxId} removes the sandbox for good, data and configuration (keys, webhooks, members, invitations, embed domains), in one transaction; its keys and sessions stop working at once. Unlike production workspaces, which are only deactivated, a sandbox is hard-deleted. The dashboard asks you to type the sandbox name before a reset or a delete.
Sandbox admin keys
POST /api/sandboxes/{sandboxId}/admin-key mints a full-access sk_test_ key of the sandbox (returned once; name optional); revokeExisting: true rotates (revokes the other keys of the sandbox in the same transaction). Creating a sandbox mints one unless createAdminKey: false.
POST /api/workspaces with isTest: true still creates a sandbox (same rules and cap).
Copy a single chart between workspaces with Copy to workspace… in the chart library menu (POST /api/charts/{chartId}/actions/copy-to-workspace, admin of both workspaces; also with a workspace admin API key).
Workspace settings
GET /api/workspace/settings returns every setting with its effective value, the default it inherits and its source (default or override); PATCH /api/workspace/settings (workspace admins, dashboard session) sets an override with a value or returns a setting to the default with null:
| Setting | Default | Range | Effect |
|---|---|---|---|
holdPeriodMinutes | SEAT_HOLD_TTL_SEC (10 min) | 1–120 | TTL of new and renewed seat and GA holds |
maxSeatsPerHoldToken | 64 | 1–1000 | Seats one hold token can hold across appends (409 beyond); above 64 turns on large group holds (below) |
ticketBuyerValidation | off | — | Buyer tokens only for registered ticket buyer ids (below) |
gaPlacesToChannels | on | — | Channels may receive GA place quotas (areaPlaces); off → 403 ga_places_to_channels_disabled |
newEventsNotForSale | off | — | New events start with every seat and GA area off sale (an explicit forSaleConfig wins) |
Settings are read from the per-process tenant configuration cache (5 s, the same read that checks the workspace is active), so the reserve path gets its TTL with no extra query; a change applies immediately on the API instance that made it and, through a Redis invalidation message, within milliseconds on the others (at most 5 seconds if the message is lost). Every change is written to the audit log (workspace.settings.update).
Large group holds
Group and box office sales often need more seats in one order than the standard 64. Set maxSeatsPerHoldToken above 64 (up to 1000) to opt in:
- One
POST /api/seats/reserve(and/confirm,/release) accepts up to that manyseatIds, and one hold token holds up to that many seats of one event. Without the opt-in, requests stay limited to 64 seats (400). - All or nothing: the whole group is locked in one atomic Redis call. If any seat is taken, nothing is held (
409) and no other buyer ever sees part of the group as held; two overlapping groups never both win. - The sales-channel defaults (web 8, box office 20, partner 64) step aside. The organizer's booking limits (designer → Booking limits, also per channel), the per-buyer limits and this cap still apply to the whole group: a group above them is refused whole (
409 booking_limit_exceeded). - The viewer reads the cap from the embed policy: buyers can select up to it (click, row, box or lasso, host
selectObjects) and the selection is held withsession: "continue"in one call. Season and multi-event holds keep the 64-lock limit. - Confirming a large hold token commits every seat in one order (the reservation worker writes the seats in batches). Holding 200 seats runs one Redis script of about 4 ms; 1000 seats about 16 ms.
Ticket buyer validation
Register the ticket buyer ids of your customers with POST /api/ticket-buyers { "ids": ["cust_123"] } (list with GET /api/ticket-buyers, remove with POST /api/ticket-buyers/actions/remove; holds scope). With validation on, POST /api/viewer-tokens { "ticketBuyerId": "cust_123" } checks the id once (400 ticket_buyer_id_required, 403 ticket_buyer_not_registered) and binds it into the buyer token (tbid claim). Holds made with that token carry the id; a different ticketBuyerId is refused (403 ticket_buyer_mismatch) and buyer tokens without one cannot hold (403 ticket_buyer_required). Holds never query the registry. Calls with an API key are server-to-server and pass their ticketBuyerId through.
Dashboard: Workspace → Ticket buyer IDs (admins) lists and searches the registry, adds ids by paste or CSV import (sent 1,000 at a time), removes selected ids and switches validation on or off.
Seat data for buyers (extraData)
Object extraData is private by default: it is never sent to buyers. GET /api/workspace/buyer-extra-data returns { enabled, keys }; PUT /api/workspace/buyer-extra-data (workspace admins, dashboard session) replaces it, e.g. { "enabled": true, "keys": ["vipLounge", "gate"] } (at most 16 keys of letters, digits, _, -, . and :; resalePriceCents is reserved and never published; 422 validation_error otherwise). The list is kept while publication is off.
With publication on and at least one key, GET /api/public/{publicKey}/events/{eventId}/extra-data (no authentication, CDN-cacheable, max-age=5) returns { keys, objects: { [objectId]: { key: value } } } for the workspace's events, with only the listed keys and only text (up to 256 characters), number, boolean or null values; otherwise it answers 404 extra_data_not_published. The embed policy tells the viewer whether to read it (extraData: true); the chart reads it once after the map renders, for its declarative rules, templates and host objects (embed guide, Declarative rules v2).
Cost: the setting lives in the tenant configuration cache (no query); the per-event map is built with one indexed query on a miss and kept in Redis, apart from the seat map and the live availability state, which never carry extraData. Overlay writes that change extraData drop it (together with the seat map cache), and a changed key list rebuilds it on the next read. Reserve, confirm, seats, best available and the event stream never read it. Every change is audited (workspace.buyer_extra_data.update). Dashboard: Settings → Seat data for buyers.
Seat map defaults (renderer settings and direct publishing)
Workspace-wide defaults for every buyer seat map, like the seats.io default renderer settings. GET /api/workspace/defaults (any key) returns { renderer, rendererBuiltIn, draftChartDrawingsEnabled }; PATCH /api/workspace/defaults (workspace admins, dashboard session; API keys get 403) changes them, e.g. { "renderer": { "showFullScreenButton": false, "holdOnSelectForGAs": null }, "draftChartDrawingsEnabled": false }. null returns an option to its built-in value; omitted options are unchanged.
| Option | Built-in | What it does |
|---|---|---|
multiSelectEnabled | true | Buyers select several objects at a time (false: one at a time). |
showFullScreenButton | true | The fullscreen button of the seat map. |
companionSeatValidationEnabled | true | Warns when a companion seat is selected without the accessible seat next to it (selectionValidators.companionSeats). |
holdOnSelectForGAs | true | General admission places are held as soon as the quantity is confirmed (false: at checkout). |
Resolution, option by option: an explicit embed or host option wins, then the workspace default, then the built-in value. The defaults travel with the embed policy the viewer already reads (rendererDefaults on GET /api/events/{eventId}/embed-policy and the season policy), from the tenant configuration cache: no query per request, nothing added to the buyer bundle. Viewer pages cache the policy for up to a minute.
draftChartDrawingsEnabled (default true): designer changes are saved as a draft and go live when someone publishes. With false (Publish designer changes directly) the designer publishes every saved change at once, also for charts that events already use (the auto-publish check ERR_AUTO_PUBLISH_CHART_HAS_EVENTS is skipped and the publish is audited with directPublish: true). Every change is audited (workspace.defaults.update) and every other settings write keeps these values. Dashboard: Settings → Seat map defaults.
Two-factor authentication
Dashboard users can protect their account with TOTP (RFC 6238: HMAC-SHA1, 30-second steps, 6 digits) in Settings → Security:
POST /api/auth/2fa/setupreturns a secret and anotpauth://URI; the dashboard renders the QR code in the browser.POST /api/auth/2fa/enable { "code" }verifies a first code and returns 10 one-time recovery codes (shown once).- From then on,
POST /api/auth/loginanswers{ "twoFactorRequired": true, "pendingToken" }instead of tokens; finish withPOST /api/auth/2fa/verify { "pendingToken", "code" }or{ "pendingToken", "recoveryCode" }.
A pending sign-in lives 5 minutes and allows 5 attempts; 10 failed codes lock second-factor checks for the account for 15 minutes (429 too_many_attempts). A code is accepted once (its time step is stored), clock drift of ±30 s is tolerated. POST /api/auth/2fa/disable and POST /api/auth/2fa/recovery-codes require a current code or a recovery code.
Workspace admins can require 2FA for every member (Team → Two-factor authentication, PATCH /api/workspace/settings { "requireTwoFactor": true }; the admin must have 2FA on). Members without it are guided through enrolment at their next sign-in (twoFactorEnrollmentRequired → POST /api/auth/2fa/enroll/start and /enroll/confirm), cannot switch into the workspace until they enrol (403 two_factor_enrollment_required) and cannot turn 2FA off while it is required. The Team page shows each member's 2FA status.
Storage. Secrets are encrypted at rest with AES-256-GCM (the user id is bound as additional authenticated data). The key is TOTP_ENCRYPTION_KEY (32 bytes as 64 hex characters or base64); without it the key is derived from JWT_SECRET with HKDF-SHA256 and the info string seatschart/totp-secret-encryption/v1, so the signing secret is never used directly. Set TOTP_ENCRYPTION_KEY in production: with the fallback, rotating JWT_SECRET makes stored secrets unreadable (members then sign in with a recovery code and enrol again). Recovery codes are stored as SHA-256 hashes and removed atomically when used.
Event log
GET /api/event-log lists the admin changes of your charts and events, newest first, from the audit log: chart.created, chart.published, chart.archived, chart.deleted, chart.categories_updated, event.created, event.updated, event.deleted, event.map_published, event.for_sale_changed, event.channels_updated, event.categories_updated, event.table_booking_changed, event.listings_updated, event.sales_rules_updated, event.pricing_synced, event.season_status_changed. Filter with types (comma separated), from / to (ISO-8601) and resourceId (chart or event id); follow nextCursor until it is null. data holds the change details (never the e-mail of the person who made it). Any API key with the webhooks scope, or a workspace admin in the dashboard.
curl "$API/api/event-log?types=event.for_sale_changed,event.channels_updated&from=2026-10-01T00:00:00Z&limit=50" -H "x-api-key: $KEY"
# { "items": [ { "id": "…", "type": "event.for_sale_changed", "occurredAt": "…", "resource": { "type": "event", "id": "…" },
# "action": "inventory.for_sale.apply", "summary": "…", "actor": { "type": "api_key" }, "data": { "forSale": false, … } } ],
# "nextCursor": "…" }The admin types chart.created, event.for_sale_changed, event.channels_updated, event.categories_updated, event.table_booking_changed, event.listings_updated are also webhooks: subscribe to them like any other type (the dashboard lists them under Events & charts). Each item carries log_id, the id of the event log entry, so you can fetch the same change later.
Retention. Entries are kept for 365 days and then deleted, together with the audit log entries they come from, by an hourly background job (server setting EVENT_LOG_RETENTION_DAYS, at least 30 days; 0 keeps everything). seats.io keeps 14 days. To keep changes longer, store them from the event.* webhooks as they arrive.
Dashboard: Developers → Event log (admins) shows the same entries with type, event or chart and date filters (kept in the URL, so filtered views can be shared), loads older pages as you scroll, and opens each entry with its JSON payload, ready to copy.
Usage per chart, event and workspace
curl "$API/api/usage/charts?month=2026-10" -H "x-api-key: $KEY" # per chart, with its events
curl "$API/api/usage/events/$EVENT_ID?month=2026-10" -H "x-api-key: $KEY" # one event + metered objects
curl "$API/api/usage/workspaces?month=2026-10" -H "Authorization: Bearer $DASHBOARD_JWT" # every workspace you administerAll three read the used-objects metering (the same numbers as GET /api/usage); nothing is counted on the booking path. Sandboxes are metered so their usage can be shown, but never billed: usedObjects counts production workspaces only and sandboxUsedObjects the sandboxes. Map-only renders (bringYourOwnBackend, see the README) count every object of the rendered chart once per chart and month; they are listed under their chart with mapOnly: true (the entry's eventId is the chart id).
Pages and long history. GET /api/usage/events lists every event used in the month (items, eventCount, usedObjects, nextCursor; limit 1–100, default 50), unlike the top-100 byEvent of GET /api/usage. GET /api/usage/events/{eventId} pages its metered objects newest first (limit 1–1000, default 1000; objectCount, nextCursor, truncated while more pages follow). GET /api/usage/history?from=YYYY-MM&to=YYYY-MM returns up to 120 months (zero-filled, default the last 12). All reads stay on the primary-key range of the metering table; closed months are immutable, so the API caches their totals. The dashboard Usage page shows them as tabs: overview (12, 24, 36 or 60 months), By chart, By event with a drill-down per event, and Workspaces when you administer more than one workspace.
curl "$API/api/usage/events?month=2026-10&limit=50" -H "x-api-key: $KEY" # every event of the month, most used first
curl "$API/api/usage/events?month=2026-10&limit=50&cursor=$NEXT" -H "x-api-key: $KEY" # next page
curl "$API/api/usage/events/$EVENT_ID?month=2026-10&limit=500&cursor=$NEXT" -H "x-api-key: $KEY" # next page of metered objects
curl "$API/api/usage/history?from=2024-01&to=2026-10" -H "x-api-key: $KEY" # up to 120 monthsRegions and data residency
SeatsChart runs in one region; there is no region choice and no data residency option (no EU-only or US-only deployment, no per-workspace region). seats.io offers regions on demand; SeatsChart does not build them. Your data stays in the single production database and its backups; contact us before relying on a residency requirement.
Webhook names from seats.io
curl -X POST "$API/api/webhooks" -H "Authorization: Bearer $ADMIN_JWT" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks","eventTypes":["event.for.sale.config.updated","holds.created"]}'| seats.io name (alias) | Our type |
|---|---|
event.for.sale.config.updated | event.for_sale_changed |
event.channels.updated | event.channels_updated |
event.categories.updated | event.categories_updated |
event.table.booking.config.updated | event.table_booking_changed |
- Aliases are opt-in per endpoint: list them in
eventTypesand the endpoint also receives the event under that name, with the same payload andtypeset to the alias.["*"]never includes aliases, so existing endpoints get no duplicates; an endpoint that lists both names receives both deliveries.chart.createdandchart.publishedalready have seats.io's names. GET /api/webhooks/event-typeslists them inaliases({ type, aliasOf }); the dashboard endpoint editor shows them under "seats.io names (optional)".- Deprecated spellings (sunset 2027-06-01). Our underscore names
event.for_sale_changed,event.channels_updated,event.categories_updatedandevent.table_booking_changedare deprecated in favour of the seats.io names above. They are still emitted unchanged (to endpoints that list them and to["*"]) and will not be removed before 2027-06-01;GET /api/webhooks/event-typesmarks them withdeprecated: { replacedBy, sunset }. To switch an endpoint, list the seats.io name ineventTypes, then drop the old name once your handler accepts both.
New events start off sale
The workspace setting newEventsNotForSale (default off; PATCH /api/workspace/settings, dashboard → Settings → Workspace settings) makes every new event start with all seats and GA areas not for sale, inside the creation transaction (no extra query: it is read from the tenant configuration cache). An explicit forSaleConfig at creation wins. Put objects on sale in the Event Manager or with POST /api/events/{eventId}/actions/mark-everything-as-for-sale.
Not seats.io's forSaleMode. seats.io's workspace forSaleMode (RATE_LIMITED or UNLIMITED_BILLABLE) decides whether marking objects (not) for sale is rate limited (10 calls per event and day) or unlimited and billed; it says nothing about the state of new events. newEventsNotForSale decides the state new events start in. SeatsChart has no for-sale mode: for-sale calls are never billed and only the normal API rate limits apply. When you migrate, drop forSaleMode, and set newEventsNotForSale only if your events should open closed.
Error codes
Every error body carries a stable error code from one catalog; see Error codes.
