Document / EN

Integration guide

Documentation for SeatsChart integrations.

Start here: Quick start (3 steps).

Ticketing platforms: partner pricing (no quote), synced price tables, meia-entrada quota, lot switching and per-buyer limits — Ticketing platforms.

Buyer UX: phone layout, prices and fees, holds, accessibility and the checkout hand-off for storefronts that embed the map — Buyer UX guidelines. What changed recently: the changelog.


One <script> and one div: the loader creates the sandboxed viewer iframe, checks every message (viewer origin and iframe source), turns every chart event into a callback and every renderer command into a promise.

html
<div id="seat-map" style="height:640px"></div>
<button id="best" type="button">Best 2 VIP seats</button>
<script src="https://seatschart.com/embed/v1/seatschart.js"></script>
<script>
  const chart = new SeatsChart({
    divId: "seat-map",
    event: "00000000-0000-4000-8000-000000000000",
    publicKey: "pub_…",                 // optional workspace public key (not a secret)
    token: "JWT_FROM_YOUR_BACKEND",     // short-lived buyer JWT — never an API key
    language: "pt",
    maxSelectedObjects: { total: 6, byCategory: { VIP: 2 } },
    priceFormatter: { maximumFractionDigits: 0, showFreeAsText: true },
    objectColor: { byCategory: { VIP: "#7c3aed" }, byStatus: { locked: "#f59e0b" } },
    onObjectSelected: (e) => console.log("selected", e.label),
    onSelectionInvalid: (e) => console.warn(e.reason, e.max ?? ""),
    onHoldCreated: (hold) => checkout(hold.holdToken, hold.seatIds), // confirm server-side
  }).render();

  chart.getReportBySelectability().then((r) => console.log(`${r.selectable} seats selectable`));
  document.querySelector("#best").onclick = () =>
    chart.selectBestAvailable({ number: 2, categories: ["VIP"] }).catch((e) => alert(e.message));
</script>
PathCacheUse
/embed/v1/seatschart.js · /embed/v1/seatschart.min.js1 h + stale-while-revalidateStable v1 channel: fixes roll out without code changes
/embed/v1/seatschart.<hash>.js · .<hash>.min.js1 year, immutablePin an exact build with SRI (integrity + crossorigin="anonymous")
/embed/v1/manifest.json5 minCurrent hashed names and sha384 integrity values

The loader is also exported from @seatschart/embed (import { SeatsChart } from "@seatschart/embed" — pass webBase, since there is no script URL to derive it from).

Config: divId or container, webBase (default: the script's origin), event, events (multi-event), publicKey/workspaceKey, token, parentOrigin (default location.origin), channel, channelKey, channelKeys, season, language, showLegend, multiSelectEnabled, maxSelectedObjects, selectedObjects, holdToken, availableCategories, unavailableCategories, currency, theme, statusPalette, transparent, backgroundColor, pricing, height, title, className, allowFullscreen, timeoutMs, holdTokenInputName, selectedObjectsInputName, form, every host customisation option below, and every on… callback from the events table.

Methods (all return promises and wait for the first render; a viewer refusal rejects with SeatsChart.ChartCommandError): selectObjects, deselectObjects, clearSelection, listSelectedObjects, selectBestAvailable, holdBestAvailable, findObject (null when unknown), listCategories, setFilteredCategories, clearFilteredCategories, setFilters, clearFilters, highlightObjects, clearHighlights, unhighlightObjects, zoomToObjects, zoomToSelectedObjects, zoomToSection, zoomToZone, zoomToFilteredCategories, resetView, isometricView, stageView, set3DCameraPreset, get3DCameraState, set3DCameraState, setFilteredSection, clearFilteredSection, clickConfirmationButton, previewObjectChanges, resetObjectChanges, openFullScreen, closeFullScreen, goToFloor, goToAllFloorsView, listFloors, getReportBySelectability, getReportBySelectabilityGroupedBy, getHoldToken, startNewSession, releaseHoldToken, isObjectInChannel, showViewFromSeat, trySelectObjects, doSelectObjects, selectCategories, deselectCategories, listZones, setSpotlight, setSpotlightOnSelection, clearSpotlight, pulse, unpulse, resolvePrompt, setPricing, changeConfig, request (any raw command), plus whenReady(), rerender(patch?) (new iframe, hold session kept), destroy(), getIframe() and the synchronous holdToken property. Config also takes session, channelKeys and the prompt callbacks onPlacesPrompt, onTicketTypePrompt, onPlacesWithTicketTypesPrompt (see Checkout flow below).

Host customisation (declarative, never host code)

The iframe never evaluates host functions. Customisation is data, validated in the viewer (invalid fields are dropped and listed in the iframe console / changeConfig warnings), sent on ?config= (base64url JSON; very large tables are applied with changeConfig right after the first render) and patchable live with changeConfig (null resets a key).

OptionShapeEffect
objectColor{ byId, byLabel, rules, byCategory, byStatus } → #rrggbbSeat tint. Order: byId → byLabel → rules (first match; match criteria in Declarative rules v2 below) → byCategory → byStatus (available, locked, sold, notForSale). Selection and highlight colours still win
objectLabelsame tables → text templateBuyer-facing label (tooltip, touch preview, accessible list, announcements). Placeholders: see Declarative rules v2 below
tooltip (seats.io popoverInfo){ lines?, byCategory?, rules?, hidePrice?, maxLines? }Extra popover lines: templates with [b]bold[/b], [i]italic[/i], [br] line breaks and [url=https://…]links[/url] (at most 8 lines show; maxLines up to 16); rules: [{ match, lines }] adds lines to the objects a rule matches
priceFormatter{ locale?, currencyDisplay?: "symbol" | "narrowSymbol" | "code" | "name" | "none", minimumFractionDigits?, maximumFractionDigits?, prefix?, suffix?, showFreeAsText? }Every price (tooltip, preview, legend, list, ticket types, totals). showFreeAsText shows a localized "Free" for zero prices
numberOfPlacesToSelect1–50Ticket quantity first: a click selects that many adjacent seats in the row (centred on the click, avoiding single gaps when possible); also the order cap and the GA default quantity
maxSelectedObjectsnumber or { total?, byCategory?, byTicketType? }Caps (1–1000; a cap above 50 is an opt-in: the sales channel's order limit and the organizer's booking limits still apply, in the viewer and on the server; holding more than 64 seats needs large group holds, a workspace maxSeatsPerHoldToken above 64). Category / ticket-type caps show a localized message and emit selection.invalid (max_selected_per_category / max_selected_per_ticket_type)
showSectionContentsauto | always | onZoom | neverauto: level-of-detail dots on very large maps. always: real seats at every zoom. onZoom: sections only until the buyer zooms in (or clicks a section). never: the buyer picks a section first. Section modes need section shapes; otherwise auto
showMinimapbooleanfalse hides the minimap
showSurroundingsbooleanSurroundings layer (streets, parking, landmarks drawn dimmed under the map): unset follows the chart setting (shown by default); true / false overrides it
sessionModecontinue (default) | start | manual | nonecontinue: hold session created on load and resumed after a reload in the same tab. manual: no session until the host calls startNewSession() (or passes holdToken). none: no hold session — seats are held when the buyer presses the hold button. Applies at render (rerender() to switch)
selectionValidators{ noOrphanSeats?: boolean | { mode?: "strict" | "lenient", ignoreCategories?: boolean, ignoreResale?: boolean, highlight?: boolean, disallow?: boolean }, consecutiveSeats?: boolean | { blockCheckout?: true }, companionSeats?: boolean | { blockCheckout?: true }, minimumSelectedPlaces?: number }, or the seats.io list form [{ type: "noOrphanSeats", … }, { type: "consecutiveSeats" }, { type: "minimumSelectedPlaces", minimum: 4 }, { type: "companionSeats", enabled: false }]noOrphanSeats (default on, strict) does not refuse clicks: after every select, deselect and live status change the whole selection is validated, orphans (a free seat next to the selection whose two neighbours are taken, unavailable or the row end) are ringed on the map, selection.invalid (orphan_seats, objectIds = the orphans) fires and the viewer's checkout button waits until the selection is valid. lenient excuses orphans the seat count cannot avoid in that row (4 tickets in a free row of 5). Rows come from the designer's section + row labels (geometric guess on legacy maps); a category change ends a row unless ignoreCategories. Resale seats (resale listings) are selectable and form a block of their own, whatever their category: a free seat next to a resale seat is an orphan, and a free resale seat next to selected resale seats can be one. ignoreResale: true counts resale seats as regular seats of their category (categories still split blocks unless ignoreCategories too). highlight: false keeps the map unmarked; disallow: true refuses a selecting click whose resulting selection still strands a seat (deselecting always works). Ticket-quantity mode (numberOfPlacesToSelect) keeps its orphan-avoiding block picker. consecutiveSeats reports selection.invalid (consecutive_seats) / selection.valid and shows a hint; blockCheckout also disables the viewer's hold button. companionSeats (default on, warn-only) reports selection.invalid (companion_seat, objectIds = the companion seats) when a selected companion seat has no selected wheelchair, semi-ambulatory or plus-size seat beside it in its row, and shows a notice; { blockCheckout: true } also disables the hold button, false turns it off. minimumSelectedPlaces (1–50, like the top-level option, which wins when both are set) makes the checkout wait until that many places are selected. In the list form a listed validator is on and an unlisted noOrphanSeats is off (seats.io semantics); enabled: false turns any entry off (seats.io's way to switch off companionSeats, which is on by default), in the object form too (companionSeats: { enabled: false }). Unknown noOrphanSeats options are ignored with a development warning
modenormal | static | print | spotlightRead-only modes keep pan / zoom, open no hold session and hide the cart; print also hides the zoom controls and minimap (see Checkout flow)
prompts{ places?, ticketType?, placesWithTicketTypes?, timeoutMs? }Prompts the host draws itself (the loader sets them from its prompt callbacks)
objectWithoutPricingSelectablebooleanfalse: objects without any price are dimmed and cannot be selected
fitTowidthAndHeight (default) | widthInitial camera: whole map, or fill the width from the top
loading{ text?, logoUrl? }Loading screen until the seats paint
messages{ [i18nKey]: text }UI text overrides (max 200 keys, 300 characters each)
selectedObjectsids, labels or { id | label, ticketType }Initial highlight (no hold)
accessibilityFilterlist of wheelchair, companion, semiAmbulatory, plusSize, hearing, liftUpArmrests, aisle, hideLimitedView, hideVertigo, hideLimitedLegroomAccessibility filter applied when the chart renders (the buyer can still change it). Seat options keep matching seats in full colour (any matches); hide* options dim the seats they describe
gestureHandlingauto (default) | cooperative | greedyWheel / touch policy — see Buyer experience
selectionModeslist of click, rectangle, lasso (default all three)Modes in the corner control; click always stays; ["click"] hides the switch
showSeatLabelsboolean (default true)Seat numbers inside the seats once they are large enough on screen
showRowLabelsboolean (default true)Row labels at both ends of each row once the rows are legible (seats.io name; 2D, the 3D view draws none)
minimumSelectedPlaces1–50The viewer's own checkout waits (with a hint) until at least that many places are selected
objectIconoverride table → an icon name, or { icon, color }A badge icon drawn on the object (66 icons in our style, or any FontAwesome 4.7 icon; color tints the badge). See Declarative rules v2 below
isObjectVisibleoverride table → true / falsefalse hides the object (not drawn, not selectable) — e.g. { byStatus: { sold: false } } (seats.io isObjectVisible)
selectableObjectsids, labels or { label, amount } (max 5000)Only these objects can be selected; everything else is dimmed. { label, amount } (seats.io) also caps the places a buyer can take in that general admission area
objectWithoutCategorySelectablebooleanfalse: objects without a category are dimmed and cannot be selected
sectionColoroverride table (byId, byLabel, rules) → #rrggbbSection colour by section id or name, or by rule (zone, floor, extraConfig)
hideSectionsNotForSalebooleanSections where nothing is for sale disappear (shape, chip and seats), live as the for-sale state changes
showSectionPricingOverlay / showObjectPopover / showFilterControls / showLegend / showZoomOutButton / showFullScreenButtonboolean (default true)Hide the overview price labels, the section summary popover, the Filters control, the legend, the fit (zoom out) button or the fullscreen button
canGoBackboolean (default true)Zone drill-down: false hides the back control (and All zones) once a zone is focused, so the buyer stays in that zone
resaleListings{ selectAsGroup?, listings: [{ id, objects }] } (max 500 listings × 50 objects)Resale listings (resaleListingId of put-up-for-resale). Resale objects of a listing become selectable (selected locally, never held by the viewer — your marketplace completes the resale). With selectAsGroup (default true) one click selects or deselects the whole listing, and a failed hold drops the whole listing; a listing with an unavailable object is refused (selection.invalid listing_unavailable) Server-side listings (OPERATIONAL_INVENTORY §15) reach the viewer by themselves; host listings win over a server listing that shares an object with them.
fullScreenTargetframe | hosthost (the script-tag loader default): the fullscreen button asks the host page to fill its window with the iframe (fullScreen.request → chart.command.setFullScreen); Escape on either side restores it. frame: Fullscreen API / in-frame expansion
partySize{ prompt?, filter?, min?, max? } or a numberseats.io partySize. prompt: true asks How many tickets? when the map opens (a modal the buyer answers before using the map; keyboard and screen-reader ready) and dims every seat that is not part of that many adjacent free seats in its row or table; the size can later be changed in Filters, never cleared. filter: true shows the party size stepper in Filters (always shown with prompt). min / max bound both (1–20, default 1 and 8); min = max applies that size at once, without prompt or stepper. Without partySize the Filters stepper stays as before (Any, 2–20). A number (partySize: 3) is seats.io's exact party size, the same as numberOfPlacesToSelect. Unlike seats.io, changing the size keeps the buyer's selection (held seats are never dropped silently)
style{ font?, cornerRadius?, buttonShape? }seats.io style for the viewer chrome (popovers, tooltips, floating toolbars, panels, dialogs and the map frame), applied as CSS variables. cornerRadius: round (default), square or a radius in px (0–24). buttonShape: round (pill, default) or square. font: system, Inter, Roboto, Montserrat, WorkSans, NotoSansHK, Lato or NunitoSans. The viewer never downloads fonts from other sites (its CSP allows font-src 'self' data: only): a named font is used when the buyer's device has it, otherwise the system font; seat and row numbers on the map keep the map font. Font URLs are refused with a warning
objectPopover{ showAvailability?, showCategory?, showLabel?, showPricing?, showUnavailableNotice?, stylizedLabel?, confirmSelection?, confirmTicketTypeSelection? }seats.io objectPopover: what the seat tooltip, the seat card and the section summary show, and how a selection is confirmed. confirmSelection: auto / touch (default: a tap on a touch screen opens the seat card, then Select seat or a second tap selects; a mouse click selects at once), always (every click or tap opens the card first) or never (taps select at once too). Keyboard selection stays direct. confirmTicketTypeSelection: true: picking a ticket type marks it and Confirm (or picking it again) selects. stylizedLabel shows row and seat in columns. showPricing: false hides prices in these popovers only (tooltip.hidePrice also hides the overview labels and the price filter). Defaults keep the current look: showAvailability true and stylizedLabel false (seats.io: false and true), every other flag true. showObjectPopover: false still hides the section summary
legend{ hideNonSelectableCategories?, hideUnavailableLegendItem?, hidePricing?, hideCategoryName? }seats.io legend, see Legend, filter controls, floors and form posts below
filterControls{ category?: { enabled?, multiSelect?, zoomOnSelect?, sortBy? }, price?, accessibility?, section?, ticketType? } / falseseats.io filterControls (each control { enabled? }); false hides the Filters button. Same section
activeFloor / lockActiveFloor / showFloorElevatorstring / boolean / boolean (true)seats.io floors API: first floor, lock, elevator. Same section
holdOnSelectForGAsboolean (default true)seats.io holdOnSelectForGAs. false: general admission places are selected, not held, and are held at checkout. See Checkout flow below
objectWithoutChannelSelectableboolean (default true)With channelKeys: false keeps public objects unselectable, so only the objects of the listed channels can be booked (seats.io channels without NO_CHANNEL). The viewer reads the channel objects once (GET /api/events/{eventId}/channel-objects); without channel keys it does nothing
inputDeviceauto (default) | cursor | touchseats.io inputDevice: cursor treats every pointer as a mouse (hover tooltips, a click selects at once), touch as a finger (no hover tooltips, a tap opens the seat card first, 44 px controls). auto follows each pointer
showActiveSectionTooltipOnMobileboolean (default true)false: on a touch screen a tap on a section zooms in without opening the section card
colors{ selected?, accent?, tooltipBackground?, title?, error? } → #rrggbbseats.io colors, see Guided selection, seat search and colours below
showSearchButtonboolean (default false)A search button in the map controls opens the seat search (the accessible seat list, search field focused)
multiFloor{ alwaysColorizeBezels?, view? }alwaysColorizeBezels: true: in the all-floors view every floor keeps its accent frame, not only on hover. view: the 2D perspective of that view, "stage" (default) or "isometric" (seats.io's chart-level multi-floor view; isometricView() / stageView() switch it)
stepThrough{ enabled } or true (default off)seats.io stepThrough: the guided selection, same section below
extraConfigJSON object (≤ 4096 characters)seats.io extraConfig: data your rules read (match.extraConfig, {extraConfig.key}); changeConfig({ extraConfig }) re-evaluates every rule
objectLabelAppliesToAreasboolean (default false)objectLabel also renames general admission areas on the map

Declarative rules v2

seats.io passes JavaScript functions (objectColor(object, defaultColor, extraConfig), objectLabel, objectIcon, isObjectVisible, sectionColor, popoverInfo). Functions cannot reach a chart in another origin, so SeatsChart takes the same decisions as data: override tables whose rules match on what those functions usually test. Rules compile once per configuration change; checking an object is a few set lookups, so a 50,000-seat map repaints as fast as without rules. No host code ever runs in the chart, and the rule runtime downloads only when a host uses rules.

Matchers (rules: [{ match, value }]; every criterion present must hold, values inside a list are alternatives; text ignores case):

CriterionMatches
ids, labels, categories, statusesObject id, label, category name, status (available, locked, sold, notForSale)
sections, rowsSection id or name; row label
zones, floorsZone and floor id or name
objectTypesseat, generalAdmission, table, section
ticketTypesThe object can be bought with one of these ticket types (id or name)
price{ min?, max? } in major currency units, inclusive, on the price the buyer sees (host pricing included); unpriced objects never match
accessibilityAny of wheelchair, companion, semiAmbulatory, plusSize, hearing, liftUpArmrests, aisle, restrictedView, obstructedView, vertigo, limitedLegroom (seats.io names such as accessible, companionSeat, aisleSeat too)
extraConfig{ key: value | [values] }: the rule applies only while your extraConfig has those values (alone, it switches a rule on or off for every object)
extraData{ key: value | [values] } on the object's published extraData (see below)
notThe object must not match this criterion set (one level)

A rule with an unknown criterion or a malformed value is dropped with a development warning, so a typo never widens a rule.

Templates (objectLabel, tooltip lines): {label}, {id}, {category}, {row}, {seat}, {section} (id), {sectionLabel}, {status}, {price} (formatted), {zone}, {floor}, {entrance}, {objectType}, {ticketTypes}, {extraConfig.key} and {extraData.key}. Unknown placeholders render empty. Popover lines accept [b]…[/b], [i]…[/i], [br] (or a newline) and links, [url=https://…]text[/url] or [url]https://…[/url]; everything else, HTML included, is shown as text.

Popover links open in a new tab (rel="noopener noreferrer"). The URL must start with https:// as written; placeholders in it are filled URL-encoded ([url=https://tickets.example.com/seat/{id}]Seat details[/url]). http:, javascript: and data: URLs, URLs with quotes, spaces or credentials, unterminated tags and nested links are not links: their text shows as plain text. The link text is plain text (wrap the whole tag in [b] or [i] to style it). On a computer, a tooltip with a link opens just above the seat and stays open while the pointer is on it; on a phone the link is in the seat sheet. maxLines (1–16, default 8) shows more lines; above 4 it also raises how many lines each list (lines, a byCategory entry, a rule) takes. A line holds up to 320 characters.

Icons (objectIcon): star, heart, info, bolt, ban, tag, check, eye, wheelchair, user, users, child, crown, diamond, trophy, gift, ticket, music, microphone, camera, cocktail, coffee, utensils, car, wifi, plug, sun, moon, snowflake, fire, leaf, flag, bell, lock, clock, question, exclamation, plus, minus, times, parking, restroom, elevator, stairs, entrance, exit, medkit, shield, umbrella, beer, wine, burger, shopping-bag, percent, money, credit-card, tv, video, volume, ear, paw, couch, bus, map-marker, calendar, thumbs-up. These names and nearby FontAwesome 4.7 names (fa-glass, cutlery, warning, sign-in, first-aid, deaf, shopping-cart, …) show our drawing. Any other FontAwesome 4.7 icon name or alias (786 names, such as anchor, fa-rocket, bicycle or navicon) shows the FontAwesome glyph, with or without the fa- prefix; its outline downloads only when your configuration names it (about 4 KB per icon), and an unknown name shows no icon (with a development warning). { icon: "crown", color: "#c9a227" } draws the badge in your colour (the glyph stays readable). Icons are drawn locally, never loaded from your site or a third-party CDN. Font Awesome 4.7.0 by Dave Gandy: font under the SIL OFL 1.1, code under the MIT License.

js
const chart = new SeatsChart({
  divId: "seat-map", event: "00000000-0000-4000-8000-000000000000", token: "JWT_FROM_YOUR_BACKEND",
  extraConfig: { highlightAisles: true },
  objectColor: { rules: [
    { match: { price: { min: 200 } }, value: "#7c3aed" },
    { match: { price: { min: 100, max: 199.99 } }, value: "#2563eb" },
    { match: { accessibility: ["aisle"], extraConfig: { highlightAisles: true } }, value: "#16a34a" },
  ] },
  isObjectVisible: { rules: [{ match: { rows: ["Z"] }, value: false }] },
  objectIcon: { rules: [{ match: { extraData: { vipLounge: true } }, value: { icon: "crown", color: "#c9a227" } }] },
  tooltip: { rules: [{ match: { categories: ["VIP"] }, lines: ["[b]{sectionLabel}[/b] · row {row}[br][i]Lounge access included[/i]"] }] },
}).render();
chart.changeConfig({ extraConfig: { highlightAisles: false } }); // re-evaluates every rule

extraData for buyers (opt-in). Object extraData is private by default: it stays on the server, in reports and in the Event Manager. A workspace admin can publish selected keys in Settings → Seat data for buyers (or PUT /api/workspace/buyer-extra-data with { enabled: true, keys: ["vipLounge"] }). Then, for that workspace's events only, the chart reads those keys once after the map renders (GET /api/public/{publicKey}/events/{eventId}/extra-data, a cached, CDN-friendly read that is never part of the live availability state): rules can match them (match.extraData), templates show them ({extraData.key}) and host objects carry them (object.extraData). Only listed keys with text (up to 256 characters), number, boolean or null values are published; resalePriceCents never is. Turning the setting off stops publication within seconds. The Event Manager always sees every key (staff are signed in).

Multi-event embed (events)

events: [eventA, eventB, …] (2–50 events using the same chart, first = source) renders the source chart with the combined availability: a seat is free only when its label is free in every event, sold/locked anywhere shows it taken, and realtime updates stream from every event. Holds use the existing group booking (POST /api/seats/reserve with eventIds): one hold token locks the seat in every event, and the handoff payload carries eventIds. Confirm on your server with scope: "hold", the source eventId, the full source seatIds and the holdToken.

Limitations: seats pair across events by unique label (missing/duplicate labels show as not for sale); an order can expand to at most 64 event-seats, so the selection cap is floor(64 / events); best available searches the source event and only keeps seats free in the combined view (it selects, then holds through the session — no single-event "find & hold"); general admission areas are not part of multi-event holds.

Checkout flow: hold calls, lost seats, modes and prompts

Hold calls. With a hold session (sessionMode continue / start, or after startNewSession) every selection change is held at once. Changes that land in the same frame (a block, selectCategories, several host calls in a row) are sent as one POST /api/seats/reserve and one POST /api/seats/release, side by side, each with its own Idempotency-Key. Seats being reserved already count as the buyer's own (the realtime echo of the lock never evicts them); a failed reserve drops them from the selection with hold.failed.

Event typeCallback (loader / SeatChart / listener)Payload
hold.pendingonHoldCallsInProgress— the first hold / release call started while none was in flight
hold.settledonHoldCallsComplete— every call finished: read chart.holdToken and go to payment
hold.succeededonHoldSucceededobjectIds, holdToken, expiresAt (one per reserve call), objects, ticketTypes (ticket type per held place)
hold.confirmedonHoldConfirmedobjectIds, holdToken — your server confirmed the buyer's hold (POST /api/seats/confirm with the buyer's holdToken): the viewer takes the seats out of the cart, stops the payment timer, shows Purchase confirmed and never reports those seats as expired
hold.failedonHoldFailedobjectIds, message, objects (selectedTicketType)
release.succeeded / release.failedonReleaseHoldSucceeded / onReleaseHoldFailedobjectIds, holdToken / message, objects (selectedTicketType). On release.failed the buyer also sees a short notice ("Some seats could not be released. They are freed when your hold expires.", messages key viewer.releaseFailed or seats.io holdReleaseFailedModalBodyBecauseOther); the seats stay held and the next selection change retries
selection.lostonSelectedObjectUnavailableobjectIds, reason: taken (another buyer), not_for_sale, channel_locked (moved to a channel this buyer cannot book), expired (own hold ran out)
chart.rerenderingonChartRerenderingStarted— the chart is rebuilt (layout changed, rerender())
prompt.requestonPromptRequest (raw)id, kind, object, options, timeoutMs — see Prompts

General admission at checkout (holdOnSelectForGAs: false, seats.io name, default true). By default, confirming a quantity in a general admission area holds the places at once. With false the places are only selected: onObjectSelected receives the area with quantity (the number of places), and the cart lists 3 × Pista with a remove button (onObjectDeselected). Nothing is held until checkout: the viewer's own checkout button holds the GA places first (POST /api/ga/reserve, then hold.succeeded and the usual hand-off per area) and then the seats, or your server holds them when it takes over the checkout (POST /api/ga/reserve with the areaId and quantity from onObjectSelected, server to server with your API key). Selected places can be sold out by the time of checkout: the hold then fails with hold.failed and the cart keeps them. Seats keep their own hold session; changeConfig({ holdOnSelectForGAs: false }) applies to the next GA selection.

General admission in listSelectedObjects. The viewer's cart prices the places picked this way like seats (category or ticket-type price, or your pricing; an area priced per area counts once) and counts them in the total. listSelectedObjects() still resolves with the selected seat ids only. listSelectedObjects({ includeGeneralAdmission: true }) adds one entry per area after the seat ids: { objectType: "generalAdmission", id, label, quantity, category, unitPriceCents, priceCents, ticketTypes? } (prices in cents of the event currency, null when the area has no price; ticketTypes lists { id, name, quantity, priceCents } when the places were picked per ticket type). The raw chart.command.result reply carries the same entries in generalAdmission, next to objectIds. Types: ChartSelectedObject and ChartSelectedGeneralAdmission, exported by @seatschart/embed and the React, React Native, Vue and Angular packages.

The viewer's own Continue button shows Holding your seats… with a spinner and waits while calls are in flight. onHoldSucceeded now follows hold.succeeded (one call per reserve batch); the checkout hand-off is still onSessionHold / onHoldCreated. chart.holdToken (script-tag loader) reads the current token synchronously.

Modes (mode): normal (default); static — read-only (pan and zoom work, no selection, no hold session, no cart); print — static without the zoom / minimap chrome; spotlight — read-only with setSpotlight dimming. In every mode: setSpotlight(objectIds), setSpotlightOnSelection(), clearSpotlight() (aliases setSpotlightObjects, clearSpotlightObjects) dim everything else through the same tint / alpha path as the category filter — no geometry is rebuilt — and pulse(objectIds) / unpulse() loop the selection pulse (resolves false and does not animate with prefers-reduced-motion).

Prompts. Register a prompt callback on the loader and the viewer asks your page instead of opening its own dialog; it waits for your answer (confirm or cancel) and falls back to its built-in UI after prompts.timeoutMs (default 60 s).

js
new SeatsChart({
  divId: "seat-map", event: "…", token: "…",
  onTicketTypePrompt: ({ object, ticketTypes }, confirm, cancel) =>
    myDialog.pick(`Ticket for ${object.label}`, ticketTypes).then((id) => (id ? confirm(id) : cancel())),
  onPlacesPrompt: ({ object, maxPlaces }, confirm, cancel) =>
    myDialog.quantity(object.label, 1, maxPlaces).then((n) => (n ? confirm(n) : cancel())),
  onPlacesWithTicketTypesPrompt: ({ ticketTypes, maxPlaces }, confirm) => confirm({ adult: 2, child: 1 }),
  onHoldCallsInProgress: () => (payButton.disabled = true),
  onHoldCallsComplete: () => (payButton.disabled = false),
}).render();
PromptWhenAnswer
ticketTypeA seat of a category with several ticket types is selected (click or trySelectObjects)ticket type id or name; it applies to that seat
placesA general-admission area is clickedquantity (minPlaces..maxPlaces) — held at once
placesWithTicketTypesA general-admission area whose category has several ticket types{ [ticketTypeId]: quantity } — held at once, the hand-off ga.ticketTypes carries the split
placesWithTicketTypes (listings)A seat of a listing sold whole whose category has several ticket types{ [ticketTypeId]: quantity } for exactly the listing’s seats (minPlaces = maxPlaces); confirming selects every seat of the listing, each with its ticket type (seats in listing order take the types in the order given). Without this prompt, ticketType (or the built-in card) asks once and the type applies to the whole listing

Hand-rolled hosts set prompts: { ticketType: true, places: true, placesWithTicketTypes: true, timeoutMs } and answer prompt.request with chart.command.resolvePrompt (promptId, value or cancelled: true; chart.resolvePrompt(id, valueOrNull)). Invalid answers are refused and the prompt stays open; late answers are refused. While a prompt is open the map shows Waiting for your choice….

Selecting objects. trySelectObjects(objects) and doSelectObjects(objects) take ids, labels (case-insensitive) or { id | label, ticketType } (max 50). try prompts for a ticket type when prompts.ticketType is on and none is given; do never prompts (the category's last choice applies, else its default). selectCategories(categories) selects every selectable object of those categories (one hold call; refused when it would exceed the order cap), deselectCategories(categories) removes them. selectedObjects accepts the same references (labels and ticket types travel in the config; they are highlighted, not held).

More options. listZones() → { id, name, color, floorId, totalCount, availableCount }[]; getReportBySelectabilityGroupedBy(field) (category, section, zone, floor, status) → buckets with the report counters (NO_CATEGORY / NO_SECTION / NO_ZONE / NO_FLOOR for unset); session: "start" (alias of sessionMode) forgets a stored session and starts a fresh one; channelKeys: string[] — the buyer sees and books the seats of every listed channel plus public seats (up to 16 keys, merged with channelKey; the viewer sends them as one x-channel-key: k1,k2 list, which server calls may use too); objectWithoutPricingSelectable: false dims objects without any price and refuses them; fitTo: "width" fills the width (top aligned) instead of fitting the whole map; loading: { text, logoUrl } shows a loading screen until the seats paint (logoUrl: an https: URL the viewer CSP allows — viewer or map-asset origin — or a data:image/…;base64, URL up to 4 KB); messages: { "viewer.checkoutFab": "Buy {count}" } overrides UI texts (max 200 keys, placeholders kept).

Migrating from seats.io. The seats.io callback names work as aliases: onObjectMouseOver / onObjectMouseOut (= onObjectHovered / onObjectUnhovered), onBestAvailableSelectionFailed (= onBestAvailableFailed) and onSelectedObjectBooked (= onHoldConfirmed, the buyer's held objects were booked by your server). chart.selectedObjects returns the selected ids synchronously; releaseHoldToken() releases everything the current token holds and starts a fresh session; isObjectInChannel(object, channelKey) asks the API whether an object belongs to the channel of a key you hold (GET /api/events/{eventId}/channel-membership, buyer tokens allowed — only the channels of the keys you send are answered). The React SeatChart has the same methods on its handle (trySelectObjects, doSelectObjects, selectCategories, deselectCategories, setSpotlight, setSpotlightOnSelection, clearSpotlight, pulse, unpulse, releaseHoldToken, isObjectInChannel, selectedObjects), the typed prompt props (onPlacesPrompt, onTicketTypePrompt, onPlacesWithTicketTypesPrompt with confirm / cancel), forwards every host option and expands to the page window on fullscreen.

Buyer experience: per-seat ticket types, tap-to-zoom, gestures, box / lasso

Per-seat ticket types. Ticket types are chosen per seat, so one order can mix meia and inteira in the same category. Clicking a seat of a category with several selectable types opens a card (row / seat, category, each type with name, description and price; unavailable types are shown disabled) next to the seat on desktop and as a bottom sheet on phones; the seat is selected once the buyer picks a type (Escape or Cancel leaves it unselected). The buyer's last choice is marked and becomes the default of seats added in bulk (best available, box / lasso, selectCategories, doSelectObjects without a type); "Also apply to the other selected seats" switches every selected seat of the category, and the cart lets the buyer change any seat's type. Categories with one type select at once. When the host registered onTicketTypePrompt (prompts.ticketType), prompt.request is emitted instead of the card (no answer before timeoutMs: the card opens). The hold hand-off (onHoldCreated / SEATSCHART_RESERVATION_SUCCESS) carries seatTicketTypes ({ seatId: ticketTypeId }) and seats[] with each line's ticketTypeId and priceCents; pass seatTicketTypes unchanged to POST /api/quotes and POST /api/seats/confirm.

Fees (seats.io pricing.allFeesIncluded and fee). When your pricing sets fee (per price entry, channel or ticket type, major units) or allFeesIncluded, the tooltip, the phone preview, the ticket-type card and every cart line show the ticket price with the fee on its own line, as seats.io does: "incl. €2.50 fee" when allFeesIncluded: true, "+ €2.50 fee" otherwise; without a fee, allFeesIncluded: true reads "All fees included" and false reads "+ fees"; with neither nothing is added. The fee of the seat's ticket type wins over the entry's. Until the buyer picks a ticket type, a seat with several types shows each type's price and the span of their fees ("+ €1.00–€3.00 fee", or "+ up to €3.00 fee" when some types have none), never the default type's fee, as seats.io does. What the buyer pays does not change: with fees added on top the cart total, the overview "from" prices, the section card and the price filter still include the fee (allFeesIncluded: true: the price already holds it). A synced price table (PUT /api/events/{eventId}/pricing with feeCents and allFeesIncluded) shows its fees the same way; the confirmed price stays the table price. The fee code and its strings load only for events whose pricing has fees. Override the texts with messages (viewerFees.feeIncluded / viewerFees.feeExcluded with {fee}, viewerFees.allFeesIncluded, viewerFees.excludingFees, viewerFees.feeUpTo with {fee}, or the seats.io keys feeIncluded, feeExcluded, allFeesIncluded, excludingFees).

Tap-to-zoom. A tap on seats too small to hit (under 24 px on touch, 12 px with a mouse) zooms in, centred on the tap, to a comfortable size and selects nothing; the next tap selects. Level-of-detail dots zoom the same way.

Seat numbers show inside the seats once they are at least 20 px on screen (free seats only; selected seats show a check). showSeatLabels: false turns them off. Row labels at both ends come from the designer's row settings; showRowLabels: false hides them (both switch live with changeConfig).

Gestures (gestureHandling): auto (default) is cooperative when the chart is embedded in a page (iframe) and greedy on the standalone viewer; fullscreen is always greedy. Cooperative: a plain wheel and one finger scroll the host page, Ctrl/⌘ + wheel (or a trackpad pinch) zooms and two fingers pan / zoom, with a short hint ("Use Ctrl + scroll to zoom the map"). Greedy: the wheel zooms and one finger pans.

Selection modes (selectionModes, default ["click", "rectangle", "lasso"]): the corner control switches between clicking seats, dragging a rectangle and drawing a lasso. An area adds every selectable seat inside it in one hold call (Alt / Option + drag removes), up to the order limit and the per-category / ticket-type caps (a notice says how many did not fit); with selectionValidators.noOrphanSeats.disallow a sweep that would strand a seat is refused. ["click"] hides the switch; ticket-quantity mode and read-only modes keep click only.

Select whole rows (seats.io selectRows): the same menu has a Select whole rows toggle. While it is on, a click on a seat takes its whole row block: the seat and its neighbours on both sides up to the first seat that cannot be taken (sold, held, another category, filtered out, or an aisle); a click on a selected seat drops the selected seats of that block, and a rectangle or lasso grows to the rows it touches. A row is taken all or nothing: when it does not fit the order limit or a per-category / ticket-type cap, nothing is selected and the buyer is told why; orphan, companion and minimum validators apply as for any multi-seat selection, and the row is held in one hold call. As in seats.io it is offered only with multi-select (not with numberOfPlacesToSelect, a partySize prompt or filter, or read-only modes), not when maxSelectedObjects is set, and not in the 3D view. The Event Manager toolbar has the same Whole rows toggle (key R).

js
new SeatsChart({
  divId: "seat-map", event: "…", token: "…",
  gestureHandling: "cooperative", // auto | cooperative | greedy
  selectionModes: ["click", "lasso"],
  showSeatLabels: true,
  onHoldCreated: ({ holdToken, userId, seatTicketTypes }) => quoteAndPay({ holdToken, userId, seatTicketTypes }),
}).render();

Buyer page weight. The viewer route loads only its own styles (no dashboard CSS, no web fonts — system font stack), the viewer translations, Pixi's seat-map subset and lazy chunks for the lightboxes, minimap, 3D view, export and ticket card; the map bootstrap starts in parallel with the JavaScript. CI keeps the route's transfer under a size budget (e2e/viewer-bundle-budget.spec.ts).


Section prices, section card and filters

Overview prices. While seat numbers are not legible (zoomed out, or level-of-detail dots on large maps), a quiet pill on the top edge of every section and GA area shows the lowest price a buyer can pay there — "from R$ 120", the price itself when every seat costs the same, or "Sold out". The numbers come from the state the viewer already holds (no extra request): free seats and prices per section are computed once and then follow every realtime change seat by seat; lots, sold-out ticket types (meia-entrada quota) and the three pricing modes (partner host pricing, synced_catalog, seatschart_catalog) apply exactly as at checkout. Prices use the event currency and the viewer language ("a partir de R$ 120", "ab 120 €"); tooltip.hidePrice: true keeps only "Sold out".

Section card. At overview, hovering a section (desktop) shows a card with its name, free seats and the price of each category and ticket type. On phones a tap still zooms in, and the tapped section's card opens below the map while it zooms (it never blocks the next tap). Keyboard and screen-reader users get the same numbers on the section chips (description + card on focus).

Filters. The corner Filters button (badge = active criteria) opens a panel with a price range slider (two thumbs; arrows, Page Up / Down, Home / End), sections, ticket types and the accessibility options, plus Reset and "Show N seats". Seats that do not match are dimmed (tint only); seats outside the price, section and ticket type filters cannot be clicked or swept with box / lasso (accessibility options only dim, as before), and best available picks among seats that match every filter (the server's suggestion is checked; otherwise the closest adjacent matching seats are selected). Changes are announced in the live region. Hosts drive the same filters:

js
const chart = new SeatsChart({
  divId: "seat-map", event: "…", token: "…",
  onFiltersChanged: ({ filters, matchingCount }) => updateResultsBadge(matchingCount),
});
chart.render();
// Prices in major units (like `pricing`); sections and ticket types by id or name.
await chart.setFilters({ price: { max: 150 }, sections: ["Plateia"], ticketTypes: ["Meia-entrada"], accessibility: ["wheelchair"] });
await chart.clearFilters();
CommandPayloadResult
chart.command.setFiltersfilters: price? (min?, max?, major units), sections?, ticketTypes? (ids or names), accessibility?, categories?, accessibilityTypes?, listingType?, partySize?filters (applied, canonical section ids and type names); unknown names are refused
chart.command.clearFilters—filters: {}

Event filters.changed (filters, matchingCount = free seats that match) → onFiltersChanged.

Legend, filter controls, floors and form posts

Legend (legend, seats.io names). The inline legend starts with a grey Not available entry while some seats or GA areas cannot be booked (as in seats.io). hideUnavailableLegendItem removes it, hideNonSelectableCategories drops the categories without any selectable object right now (sold out, off sale or outside availableCategories; it follows realtime), hidePricing keeps the names only and hideCategoryName the prices only (the colour dot stays and screen readers still hear the name). hidePricing and hideCategoryName cannot be combined.

Filter controls (filterControls). Every group of the Filters panel shows when the chart has something to filter, unless enabled: false (category, price and accessibility as in seats.io, plus section and ticketType). category.enabled: true shows the category chips even when they repeat the sections; multiSelect: false keeps one category at a time; zoomOnSelect: true fits the camera to the picked categories; sortBy is price (low to high), priceDescending, name or none (chart order, the default). filterControls: false is showFilterControls: false (no Filters button). Unlike seats.io, the category and price controls are on by default and can show together.

Floors. activeFloor (floor id or name) opens a multi-floor chart on that floor (an unknown name logs a console warning and the first floor shows). Without activeFloor, a multi-floor chart opens on the all-floors view in 2D, as seats.io does, and that first view reports no floor.changed; the 3D view, lockActiveFloor and showFloorElevator: false open on the first floor instead. With lockActiveFloor: true the elevator hides and neither the buyer nor goToFloor / goToAllFloorsView can change floors. showFloorElevator: false only hides the elevator. The elevator is a vertical radio group (top floor first, All floors at the bottom; arrow keys, Home and End pick, as in seats.io). All floors and chart.goToAllFloorsView() open the all-floors view: in 2D every floor is drawn in perspective above the one below on the map canvas (WebGL; the elevator then shows each floor's available places). Hovering a floor on the map or in the elevator makes the others translucent, a click on a floor enters it, the wheel (or a vertical drag) tilts the stack, and Escape returns to the floor shown before. Floors that stand over each other are spread apart; floors side by side stay close. It reports floor.changed with allFloors: true (empty floorId), and listFloors() then marks no floor active. In the 3D view, the 3D floor bar and the floor list card are used instead.

Floor perspective (seats.io isometricView / stageView, 2D only): the all-floors view is seen from the stage side by default ("stage", floors tilted and in perspective) or from above ("isometric", the whole stack turned and tilted). multiFloor: { view } sets it, chart.isometricView() and chart.stageView() switch it at runtime and open the all-floors view of a multi-floor chart (unless lockActiveFloor); on a single-floor chart they do nothing in 2D, as in seats.io. In the 3D view they keep moving the 3D camera to its isometric or stage preset (a SeatsChart addition), and chart.set3DCameraPreset("isometric" | "stage") opens the 3D view first when needed. The stacked view is drawn from one texture per floor (redrawn only when that floor's seats change), so it stays fluid on large venues.

Form posts (holdTokenInputName, selectedObjectsInputName). Storefronts that post a plain HTML form get hidden inputs in the <form> around the chart (or in the form element / selector): the current hold token and the selected object ids, comma-separated, kept in step with every hold and selection change. Never create these inputs yourself.

html
<form action="/checkout" method="post">
  <div id="seat-map"></div>
  <button type="submit">Continue</button>
</form>
<script>
  new SeatsChart({
    divId: "seat-map", event: "…", token: "…",
    holdTokenInputName: "holdToken",          // <input type="hidden" name="holdToken" value="…">
    selectedObjectsInputName: "selectedSeats", // value="id1,id2"
  }).render();
</script>

Ticket types in hold callbacks. hold.succeeded (onHoldSucceeded) also carries objects (each held seat with its selectedTicketType: ticketType, label, price in cents; each GA area with its quantity and its places per type in ticketTypes) and ticketTypes, one entry per held place with its objectId (seats.io's second argument). hold.failed and release.failed (onHoldFailed, onReleaseHoldFailed) carry objects with the selectedTicketType each object was (or would have been) held with. A GA hold reports its own token and never replaces chart.holdToken. Categories priced without ticket types report one stable Standard type (<categoryId>:standard).

Labels. Seat numbers up to three characters are squeezed horizontally to fit the seat (they keep their height); longer ones shrink. Sections and areas without a name show ? on the map, in the section chips and in prompts (never their internal id).

Vue and Angular components

@seatschart/vue (Vue 3.3+, Nuxt) and @seatschart/angular (Angular 18+, standalone components) wrap the same host API as the script-tag loader — the buyer chart (SeatsChart), the Event Manager and the designer — with the same options, the seats.io callback names as events / outputs and every command on the component. Neither touches window before mount, so server rendering (Nuxt, Angular SSR) outputs the empty wrapper.

bash
npm install @seatschart/vue @seatschart/embed      # Vue 3.3+ / Nuxt
npm install @seatschart/angular @seatschart/embed  # Angular 18+

Vue:

vue
<script setup lang="ts">
import { ref } from "vue";
import { SeatChart, useSeatChart } from "@seatschart/vue";

const props = defineProps<{ eventId: string; jwt: string }>();
const chart = useSeatChart(); // typed template ref: every SeatsChart command
const cart = ref<string[]>([]);
const checkout = (holdToken: string): void => { /* your payment step */ };
</script>

<template>
  <SeatChart ref="chart" web-base="https://YOUR_WEB" :event="props.eventId" :token="props.jwt"
    :max-selected-objects="4" :settings3d="{ lightPreset: 'exteriorSunset' }"
    @object-selected="(e) => cart.push(e.label)" @session-hold="(e) => checkout(e.holdToken)" />
  <button type="button" @click="chart?.clearSelection()">Clear</button>
</template>

Angular:

ts
import { Component } from "@angular/core";
import { SeatChartComponent } from "@seatschart/angular";

@Component({
  selector: "app-seats",
  imports: [SeatChartComponent],
  template: `
    <seatschart-chart #chart webBase="https://YOUR_WEB" [event]="eventId" [token]="jwt"
      [maxSelectedObjects]="4" [settings3d]="{ lightPreset: 'exteriorSunset' }"
      (objectSelected)="cart.push($event.label)" (sessionHold)="checkout($event.holdToken)" />
    <button type="button" (click)="chart.clearSelection()">Clear</button>
  `,
})
export class SeatsComponent {
  eventId = "EVENT_UUID";
  jwt = ""; // short-lived buyer token from your server
  cart: string[] = [];
  checkout(holdToken: string): void { /* your payment step */ }
}
VueAngular
ComponentsSeatChart, EventManager, Designer<seatschart-chart>, <seatschart-event-manager>, <seatschart-designer> (SeatChartComponent, EventManagerComponent, DesignerComponent)
Optionsprops (web-base, event, token, object-color, settings3d, …); the chart style option is chart-styleinputs (webBase, event, token, objectColor, settings3d, …); the chart style option is chartStyle
Callbacksevents: onObjectSelected → @object-selectedoutputs: onObjectSelected → (objectSelected); the manager / designer onError → (managerError) / (designerError)
Host prompts@places-prompt, @ticket-type-prompt, @places-with-ticket-types-prompt → (params, confirm, cancel)(placesPrompt), (ticketTypePrompt), (placesWithTicketTypesPrompt) → { params, confirm, cancel }
Commandstemplate ref (useSeatChart(), useEventManager(), useDesigner())component methods (@ViewChild or a template variable)
Designer Exit buttonshown with an @exit-requested handlershown with an (exitRequested) subscriber

Changed options are compared by value. Declarative renderer options and pricing (chart), selectability rules, caps, filters, language and colorScheme (Event Manager), and language, colour schemes, mode and safeModeOptions (designer) are applied live with changeConfig; height, title and className update the iframe in place; any other option re-renders the iframe (the hold session continues). Angular reads changes in ngOnChanges: pass new object references. Unmounting (ngOnDestroy) removes the iframe and its message listener; pending commands reject. Package READMEs: @seatschart/vue, @seatschart/angular.

Web embed — iframe viewer (B2B2C)

The public route /viewer/<event_uuid> serves the Pixi/WebGL seat map. Third-party sites can embed it in an <iframe>. Consumers cannot send custom headers from a cross-origin iframe, so the partner backend must pass a short-lived HS256 JWT on the query string (same secret as the SeatsChart API).

Query parameters

ParameterPurpose
token or access_tokenBearer JWT for GET /api/events/... and POST /api/seats/reserve (mirrors SSE query auth on the API).
parent_originRequired for reservation postMessage delivery: the exact parent page origin (e.g. https://shop.example.com). Must match event.origin in the parent listener. HTTP is only allowed for localhost-style hosts.
embed=1Minimal page chrome (no dashboard links / headings) for tight layouts.
transparent=1Transparent page background and WebGL clear alpha so the map blends with the host site.
bg=RRGGBBOptional opaque canvas background (hex, with or without #). Ignored when transparent=1.

If parent_origin is missing or invalid, the viewer does not send postMessage after a successful reserve (and logs a console warning in the iframe so integrators notice).

Snippet 1 — HTML iframe

Replace the host, event UUID, JWT, and parent storefront origin with your values. The JWT must include the correct tenant_id claim.

html
<iframe
  title="SeatsChart seat map"
  src="https://seatschart.com/viewer/00000000-0000-4000-8000-000000000000?token=JWT_GENERATED_BY_YOUR_BACKEND&parent_origin=https%3A%2F%2Fshop.example.com&embed=1&transparent=1"
  width="100%"
  height="600"
  style="border:0"
  allow="fullscreen"
  loading="lazy"
></iframe>

Example with a custom solid background (no transparency):

html
<iframe
  title="SeatsChart seat map"
  src="https://seatschart.com/viewer/00000000-0000-4000-8000-000000000000?token=JWT_GENERATED_BY_YOUR_BACKEND&parent_origin=https%3A%2F%2Fshop.example.com&embed=1&bg=0f172a"
  width="100%"
  height="600"
  style="border:0"
></iframe>

Snippet 2 — Parent page: postMessage listener

When the shopper completes a successful reserve in the iframe, the viewer calls parent.postMessage(..., parent_origin) with a strict target origin (never "*").

javascript
window.addEventListener("message", (event) => {
  // Must match the iframe ?parent_origin= you configured:
  if (event.origin !== "https://shop.example.com") return;

  const data = event.data;
  if (!data || typeof data !== "object") return;
  if (data.type !== "SEATSCHART_RESERVATION_SUCCESS") return;

  const { eventId, seatIds, status, userId, holdToken, expiresAt, channel, totalPriceCents, seatTicketTypes, ga } =
    data.payload ?? {};
  if (status !== "held") return;

  console.log("Seats held — start partner checkout:", {
    eventId,
    seatIds,
    userId,
    holdToken,
    expiresAt,
    channel,
    totalPriceCents,
    seatTicketTypes,
    ga,
  });
  // → redirect to your PSP; on success your backend calls POST /api/seats/confirm
  // → on abandon: POST /api/seats/holds/release with holdToken or POST /api/seats/release
});

Message shape (status: "held" after embed; partner confirms server-side):

json
{
  "type": "SEATSCHART_RESERVATION_SUCCESS",
  "payload": {
    "eventId": "<uuid>",
    "seatIds": ["<uuid>"],
    "status": "held",
    "userId": "<same-as-reserve>",
    "holdToken": "<uuid>",
    "expiresAt": "2026-06-30T12:15:00.000Z",
    "channel": "web",
    "totalPriceCents": 12000,
    "seatTicketTypes": { "<seatId>": "<ticketTypeId>" },
    "seats": [
      {
        "seatId": "<uuid>",
        "label": "A-12",
        "category": "VIP",
        "ticketTypeId": "full",
        "priceCents": 6000
      }
    ],
    "ga": {
      "areaId": "<uuid>",
      "areaName": "General admission",
      "quantity": 2
    }
  }
}

Payment: SeatsChart does not run the buyer's PSP. The embedding storefront (e.g. a ticketing platform) charges on its stack, after freezing the price with POST /api/quotes, then calls POST /api/seats/confirm (server-to-server) with the same userId, seatIds and holdToken plus quoteId, externalPaymentId, paidAmountCents and paidCurrency.

Security note: Always verify event.origin in the parent (as above). Treat payload as untrusted until your backend confirms payment or order state.

Viewer token (partner backend)

Mint the buyer token with your partner API key — SeatsChart signs it, so you never hold the platform JWT_SECRET:

bash
curl -sS -X POST "https://api.seatschart.com/api/viewer-tokens" \
  -H "Content-Type: application/json" -H "x-api-key: sk_live_xxx" \
  -d '{ "ttlSeconds": 900 }'
# → { "token": "eyJ…", "tokenType": "Bearer", "expiresInSeconds": 900, "expiresAt": "…" }

With @seatschart/sdk: const { token } = await sc.viewerTokens.create({ ttlSeconds: 900 }). Lifetime 60–3600 seconds (default 900); the token is bound to the API key's workspace (tenant_id). A buyer token cannot mint another one (403).

Do not expose the API key in the browser; only the resulting token query value is passed to the iframe src.


Embedded Event Manager (host JavaScript API)

Embed the Event Manager in your back office and drive it from your page, like seats.io's EventManager. Load /embed/v1/seatschart-manager.js (a separate script, so storefronts never download the back-office API): it exposes window.SeatsChartEventManager (also SeatsChart.EventManager when the buyer loader is on the page, and SeatsChartEventManager in @seatschart/embed). Mint a manager embed token on your server (POST /api/embed-tokens with { "purpose": "manager", "eventId": "…" } and your partner API key): a secret key never goes into the page. Your page origin must be an allowed embed origin of the workspace.

html
<div id="manager"></div>
<script src="https://seatschart.com/embed/v1/seatschart-manager.js"></script>
<script type="module">
  const manager = new SeatsChartEventManager({
    divId: "manager",
    event: "EVENT_ID",
    token: "MANAGER_EMBED_TOKEN",
    mode: "select",
    language: "de",
    colorScheme: "auto",
    maxSelectedObjects: 10,
    unavailableObjectsSelectable: false,
    isObjectSelectable: { byCategory: { Press: false } },
    onEventManagerRendered: (e) => console.log("ready", e.mode),
    onObjectSelected: (object) => console.log("selected", object.label),
    onSelectionChanged: (objects) => renderCart(objects),
  }).render();

  await manager.setHighlightedObjects(["A-1", "A-2"]);
  const selected = await manager.listSelectedObjects();
</script>

Event Manager configuration

OptionDescription
divId / containerWhere the iframe goes.
event (alias eventId), tokenThe event and the manager embed token.
modeselect (default), static, filter, filterSections, createOrder, editOrder, manageForSale, manageChannels, manageObjectStatuses, manageCategories, manageTableBooking, plus history, salesRules, manageListings.
languageOne of the 12 UI languages; default: the operator's saved or browser language.
colorSchemelight (default), dark or auto (follows the operator's OS).
isObjectSelectableDeclarative rules, because functions cannot cross the iframe: { byId, byLabel, rules: [{ match: { ids, labels, categories, statuses }, value }], byCategory, byStatus } with true / false. Wins over every other option.
unavailableObjectsSelectablefalse: only available objects can be selected.
selectableObjectsIds or labels that stay selectable anyway (seat swap).
selectedObjectsInitial selection: ids, labels or { label, ticketType }.
maxSelectedObjectsSelection cap (1–10 000). A refused selection shows a message to the operator and calls onSelectionInvalid.
ordereditOrder: the original order (preselected; listOrderChanges() compares against it).
filtersBox-office filters (seats.io filters, usable in createOrder and editOrder and every seat mode): categories, price: { min, max } (major units; a seat matches when one of its ticket types fits), accessibility / accessibilityTypes (aisleSeat, accessible, …), sections and ticketTypes, by name. Seats outside are dimmed and, in the order modes, cannot be added (seats already in the order stay selectable). categories, price and ticketTypes apply to general admission areas too (an area books at its category's prices; an area without a category never matches them): areas outside are dimmed on the map and in the list and cannot be booked; sections and accessibility describe seats and leave areas alone. The operator can refine them from Filters next to the search; changeConfig({ filters }) replaces them and { filters: null } clears them. listingType and partySize are buyer filters (warning).
height, title, className, parentOrigin, webBase, timeoutMsIframe and transport settings.
events, pricing, priceFormatter, selectionValidators, numberOfPlacesToSelect, partySize, ticketTypes, session, holdToken, objectPopover, popoverInfo, viewSettings, messages, objectColor, colors, style, …See Host options: several events, orders, view and styling below.

Event Manager callbacks and methods

CallbackWhen
onEventManagerRendered (onChartRendered)The manager rendered; commands are accepted.
onChartRenderingFailedThe event could not be loaded.
onObjectSelected / onObjectDeselectedWith the object { id, label, category, section, status, availability, forSale, channelId, orderId?, ticketType?, pricing?, events? } (pricing: { price, formattedPrice, fee? } in cents with host pricing, fee when the price entry sets one; events: with events).
onSelectionChangedThe whole selection after each change (mass selections included).
onSelectionInvalidmax_selected, not_selectable, unknown_object, orphan_seats (objectIds are the stranded seats) or minimum_selected_places (objectIds are the selection, minimum the places it needs).
onOrderChangededitOrder: the order changes after each edit.
onModeChangedThe operator switched tabs.
onSubmitSucceeded / onSubmitFailedA saved action (status, for sale, channel, hold, …).
onError{ code, message, action? } for rendering, loading and saving errors.
onObjectClickedAny click on an object, whether or not it changes the selection (same object as onObjectSelected).
onObjectMouseOver / onObjectMouseOutstatic mode: the pointer entered or left an object.
onSelectionValidAfter onSelectionInvalid, the next accepted selection change.
onFilteredCategoriesChangedThe category filter changed (the operator's Filters or your filters): [{ key, label, color, accessible?, totalCount, availableCount }], [] when cleared.
onFilteredSectionChangefilterSections mode: the labels of the sections the operator filtered on ([] = none).
onChartRerenderingStartedrerender() started (the selection, section filter and hold token are cleared); onChartRendered follows.
onFullScreenOpened / onFullScreenClosedThe operator opened or closed the map in full screen.
onSessionInitializedOrder modes: the first hold of a session created a hold token { token, expiresAt, expiresInSeconds }.
onHoldSucceeded / onHoldFailedOrder modes: (objects, ticketTypes) after a hold; a failure holds nothing (the reason also reaches onSubmitFailed).
onReleaseHoldSucceeded / onReleaseHoldFailedOrder modes: (objects, ticketTypes) after the hold token was released (or not).
onHoldTokenExpiredOrder modes: the hold token expired and its places are free again.
onHoldCallsInProgress / onHoldCallsCompleteOrder modes: a hold or release call started / every call finished (disable your checkout button in between).

Methods (promises, resolved once the manager rendered): listSelectedObjects(), selectObjects(objects) (aliases doSelectObjects, trySelectObjects), deselectObjects(objects), clearSelection(), setHighlightedObjects(objects) (highlights them and dims the rest; [] dims everything), clearHighlightedObjects(), listOrderChanges() (editOrder: [{ type: "PLACE_ADDED" | "PLACE_REMOVED" | "TICKET_TYPE_CHANGED", object, objectId, ticketType? }]), zoomToObjects(objects), zoomToSelectedObjects(), zoomToSection(label), resetView(), findObject(label) (the manager's object plus the seats.io object model: objectType, labels, categoryInfo, pricing, floor, center, …), listCategories(), selectCategories(keys) / deselectCategories(keys) (keys or labels, within maxSelectedObjects and the selectability rules), setSpotlightObjects(objects) / setSpotlightOnSelection() / clearSpotlightObjects() (the rest of the map is strongly dimmed), pulse(objects) / unpulse(objects?), setFilteredSection(label | labels) / clearFilteredSection() (filterSections mode), changeConfig(patch) (rules, cap, language, colorScheme; null resets a key), rerender(), destroy(). selectedObjects (ids), mode, holdToken and filteredSections are read synchronously. Messages only travel between your page origin and the SeatsChart origin (parent_origin, checked both ways, never "*"); without parent_origin the embed stays URL-only (buildManagerEmbedUrl).

React (@seatschart/react):

tsx
import { EventManager, type EventManagerHandle } from "@seatschart/react";

const ref = useRef<EventManagerHandle>(null);
<EventManager ref={ref} webBase="https://seatschart.com" event={eventId} token={managerToken}
  mode="editOrder" order={["A-1", "A-2"]} onOrderChanged={setChanges} />;
const changes = await ref.current?.listOrderChanges();

Vue (<EventManager> from @seatschart/vue) and Angular (<seatschart-event-manager> from @seatschart/angular) take the same options; see Vue and Angular components above.

Filter sections (filterSections)

Your page lists the tickets; the operator picks the sections on the map. In filterSections mode a click on a seat adds or removes its whole section (drawn in the selection colour), and a list of section buttons above the map does the same from the keyboard. Every change calls onFilteredSectionChange with the section labels, in the order they were picked. Selection commands are refused in this mode (wrong_mode).

js
const manager = new SeatsChartEventManager({
  divId: "manager",
  event: "EVENT_ID",
  token: "MANAGER_EMBED_TOKEN",
  mode: "filterSections",
  onFilteredSectionChange: (sections) => showTickets(sections.length === 0 ? allTickets : allTickets.filter((t) => sections.includes(t.section))),
}).render();

await manager.setFilteredSection(["Orchestra", "Balcony"]); // replaces the filter
await manager.clearFilteredSection();

Host options: several events, orders, view and styling

The manager page validates these options and sends what it dropped back with onChartRendered (printed in the console on development hosts). changeConfig applies all of them live except events (use rerender).

OptionModesDescription
eventsallSeveral events of the same chart (up to 20). The map shows event (or the first one) with the availability of all of them; status changes apply to every event in one all-or-nothing call (labels resolved per event) and holds hold the same places in every event with one hold token. Needs a manager token minted without eventId.
pricingselect, order modesYour prices, same shape as the buyer chart's pricing ([{ category, price }] or [{ objects, price }], with ticketTypes and fee; major units). The legend and tooltips show them, the order panel totals the selection and every object carries pricing: { price, formattedPrice } (cents to pay): book the hold token at these prices from your server.
priceFormatterselect, order modesDeclarative format of those prices: { locale, currencyDisplay, minimumFractionDigits, maximumFractionDigits, prefix, suffix }.
selectionValidatorsselect, order modesnoOrphanSeats (true or { mode, ignoreCategories, ignoreResale, disallow }): a selection that leaves one seat on its own is reported with onSelectionInvalid (orphan_seats) and cannot be held until the operator fixes it; with disallow: true the click itself is refused. minimumSelectedPlaces ({ type: "minimumSelectedPlaces", minimum } in the seats.io list form, or the top-level minimumSelectedPlaces, 1–50): the selection cannot be held until that many places are selected, and every change that leaves 1 to minimum − 1 places selected reports onSelectionInvalid (minimum_selected_places, with minimum); onSelectionValid follows once the minimum is met or the selection is cleared. The seats.io list form and enabled: false work as on the buyer chart, and both forms report the same events.
objectWithoutPricingSelectableselect, order modesfalse (with pricing): objects without a price cannot be selected.
availableCategories / unavailableCategoriesselect, order modesOnly / never objects of these categories (names or keys).
channelsselect, order modesOnly objects of these channels (ids or names); NO_CHANNEL adds the public objects.
numberOfPlacesToSelectselect, order modesExactly this many places: the selection stops there and Hold waits until it is reached.
partySizeselect, order modesPlaces picked together: only seats with that many free seats in their row stay selectable (also the number to select unless numberOfPlacesToSelect is set).
selectionByselect, order modesWhat maxSelectedObjects and numberOfPlacesToSelect count: places (default) counts every seat; objects counts a table booked as a whole once, however many seats it has (seats.io counts a general admission pick once the same way; general admission places are not part of the manager's selection).
ticketTypesselect, order modesTicket types the operator gives to the selected objects (["Adult", { ticketType: "Child", label: "Kid" }]); objects report their ticketType.
session, holdTokenorder modescontinue (default) or manual: Hold continues holdToken when you pass one (its places are preselected); start: always a new hold token; none: no Hold controls, your system books the selection it receives.
showLegend, legendallThe category legend on the map (legend: { hidePricing, hideCategoryName, hideNonSelectableCategories, hideUnavailableLegendItem }).
objectPopover (objectTooltip)object popovershowOrderId, showTechnicalLabel, showLabel, showCategory, showChannel, showActionHint (each default true).
popoverInfo (tooltipContents)object popover, map tooltipExtra lines, declarative because functions cannot cross the iframe: { lines, byCategory, rules } with the templates and [b] / [i] / [br] / [url=https://…] markup of Declarative rules v2; {extraData.key} shows any key of the object (staff see every key).
viewSettingsallshowSeatLabels, showRowLabels (default true; the buyer chart's showRowLabels, live with changeConfig), useChannelColors (default false; the operator can still switch it).
manageChannelsListmanageChannelsfalse hides the channel list and the create form (assignment stays).
messagesallYour texts for any eventManager.…, viewer.… or common.… key of the UI.
objectColor, objectIcon, objectLabel, isObjectVisible, sectionColor, colors, style, fitTo, showFullScreenButtonallSame shapes as the buyer chart: colour, icon, label, visibility and section colour tables (rules v2), colors.accent (buttons) and colors.selected, style: { font, cornerRadius, buttonShape }, fitTo, and the map's full screen button (default true). In React, style is the wrapper's: pass managerStyle.
extraConfigallData your rules read (match.extraConfig, {extraConfig.key}), as on the buyer chart.
js
const manager = new SeatsChartEventManager({
  divId: "manager",
  event: "EVENT_ID",
  token: "MANAGER_EMBED_TOKEN",
  mode: "createOrder",
  pricing: [{ category: "Orchestra", price: 80 }, { category: "Balcony", price: 45 }],
  priceFormatter: { prefix: "R$ ", currencyDisplay: "none" },
  selectionValidators: { noOrphanSeats: true },
  objectPopover: { showTechnicalLabel: false },
  colors: { accent: "#0f766e" },
  onHoldSucceeded: (objects) => fetch("/box-office/book", {
    method: "POST",
    body: JSON.stringify({ holdToken: manager.holdToken, seatPrices: Object.fromEntries(objects.map((o) => [o.id, o.pricing?.price ?? 0])) }),
  }),
}).render();

Migrating from seats.io EventManager

seats.ioSeatsChart
new seatsio.EventManager({...}).render()new SeatsChartEventManager({...}).render() (script seatschart-manager.js)
secretKeytoken (manager embed token; no secret in the page)
event, mode, languageSame names (eventId alias; 12 languages)
isObjectSelectable(object, default)isObjectSelectable rules object
unavailableObjectsSelectable, selectableObjects, selectedObjects, maxSelectedObjectsSame names
order (editOrder)Same name
filters (create / edit order)Same name and keys
onChartRendered, onChartRenderingFailedSame names (also onEventManagerRendered)
onObjectSelected, onObjectDeselectedSame names
onSubmitSucceeded, onSubmitFailedSame names
chart.selectedObjectsmanager.selectedObjects (ids) or listSelectedObjects()
setHighlightedObjects, clearHighlightedObjectsSame names
listOrderChanges()Same name (adds objectId)
listSelectedObjects, doSelectObjects, trySelectObjects, deselectObjects, clearSelection, zoomToObjects, zoomToSelectedObjects, rerender, destroySame names
mode: "filterSections", onFilteredSectionChange, setFilteredSection, clearFilteredSectionSame names (setFilteredSection also takes a list)
findObject, listCategories, selectCategories, deselectCategories, resetView, zoomToSection, setSpotlightObjects, setSpotlightOnSelection, clearSpotlightObjects, pulse, unpulseSame names
onObjectClicked, onSelectionValid, onFilteredCategoriesChanged, onChartRerenderingStarted, onFullScreenOpened, onFullScreenClosed, onObjectMouseOver, onObjectMouseOut (static)Same names
onHoldSucceeded, onHoldFailed, onReleaseHoldSucceeded, onReleaseHoldFailed, onHoldTokenExpired, onSessionInitialized, onHoldCallsInProgress, onHoldCallsComplete, holdTokenSame names (ticketTypes lists the ticket types among the objects)
events, pricing, selectionValidators, objectWithoutPricingSelectable, availableCategories, unavailableCategories, channels, session, holdToken, showLegend, legend, numberOfPlacesToSelect, partySize, selectionBy, ticketTypes, viewSettings, manageChannelsList, messages, objectColor, objectLabel, isObjectVisible, sectionColor, colors, style, fitTo, showFullScreenButton, extraConfigSame names (objectColor, objectIcon, objectLabel, isObjectVisible and sectionColor are declarative tables)
objectPopover / objectTooltip, popoverInfo / tooltipContents, priceFormatter, allowOrphanSeats, themeColorsMapped (functions become declarative options; a development warning names each one)
—onSelectionChanged, onSelectionInvalid, onOrderChanged, onModeChanged, onError, colorScheme, changeConfig

Embedded designer (host JavaScript API)

Embed the map designer in your own admin and drive it from your page, like seats.io's SeatingChartDesigner. Load /embed/v1/seatschart-designer.js (a separate script: storefronts never download it): it exposes window.SeatsChartDesigner (also SeatsChart.Designer when the buyer loader is on the page, and SeatsChartDesigner in @seatschart/embed). Mint a designer embed token on your server (POST /api/embed-tokens with { "purpose": "designer", "chartId": "…" } and your partner API key): a secret key never goes into the page. Without chartId, a token minted without chartId and eventId lets the designer create a new chart (onChartCreated). Your page origin must be an allowed embed origin of the workspace.

html
<div id="designer"></div>
<script src="https://seatschart.com/embed/v1/seatschart-designer.js"></script>
<script type="module">
  const designer = new SeatsChartDesigner({
    divId: "designer",
    token: "DESIGNER_EMBED_TOKEN",
    chartId: "CHART_ID",            // omit to create a chart
    mode: "safe",
    safeModeOptions: { allowEditingAreaCapacity: true },
    features: { disabled: ["images"], readOnly: ["chartName"] },
    language: "pt",
    colorScheme: "auto",
    canvasColorScheme: "light",
    onDesignerRendered: (designer, e) => console.log("ready", e.chartId),
    onChartCreated: (chartId) => saveChartId(chartId),
    onChartUpdated: (chartId) => markDirty(chartId),
    onChartPublished: (chartId, e) => console.log("version", e.version),
    onExitRequested: () => (location.href = "/charts"),
  }).render();

  await designer.whenReady();
  const { version } = await designer.publish("Summer layout");
</script>

Designer configuration

OptionDescription
divId / containerWhere the iframe goes.
chartId (alias chartKey), tokenThe chart (id or partner key) and the designer embed token. Without a chart, a new one is created.
modenormal (default: autosave, publish), safe (nothing that could affect bookings: published objects cannot be deleted or relabelled, tables keep their booking type, areas keep their type and capacity) or readOnly (changes are never saved: no autosave, save, publish or version actions).
safeModeOptions{ allowDeletingObjects, allowEditingAreaCapacity } (both false by default), read in safe mode.
features{ enabled } or { disabled } (not both) plus { readOnly }, with the seats.io feature names (rows, tables, tables.bookAsAWhole, tables.generalAdmission, tables.variableOccupancy, booths, areas, sections, shapes, texts, icons, images, nodes, focalPoint, 3D, venueType.simple, venueType.withSectionsAndFloors, venueType.withZones, contextActions, labeling, displayedLabels, multipleFloors, objectProperties, publishedSectionLabel, referenceChart, firstTimeTutorial, viewFromYourSeat, categoryList, publishing, …); what each one gates is in Map designer.
languageOne of the 12 UI languages, from the first render; default: the user's saved or browser language.
colorSchemeDesigner chrome: light (default), dark or auto (follows the OS, resolved before the first paint).
canvasColorSchemeCanvas: light or dark fixes it; auto (default) shows the light / dark switch.
openDraftDrawing / openLatestDrawingSeatsChart saves every change to a draft and opens the latest version by default (seats.io openLatestDrawing: true). openDraftDrawing: true opens the draft; openLatestDrawing: false opens the published version — while newer draft changes exist it opens as a view (nothing is saved) with an "Open the latest draft" button.
height, title, className, parentOrigin, webBase, timeoutMsIframe and transport settings.

Designer callbacks and methods

CallbackWhen
onDesignerRendered(designer, event)The chart loaded; commands are accepted. event: { chartId, chartKey, mode, drawing, objectCount }.
onDesignerRenderingFailed(designer, event)The chart could not be loaded or created ({ message, code }: chart_not_found, create_forbidden, create_failed, …).
onChartCreated(chartId, event)A chart was created because no chartId was given. The id is accepted wherever seats.io takes a chart key.
onChartUpdated(chartId, event)A save succeeded (each autosave or Save; saves that follow each other are reported once). event.draftUpdatedAt is the saved draft version.
onChartPublished(chartId, event)The chart was published (Publish, auto-publish or publish()); event.version is the new chart version.
onExitRequested()The user pressed Exit. The button is shown only when you set this callback; pending edits are saved first.
onActionRefused(event)Safe mode refused an edit (delete_published, relabel_published, table_type_published, area_type_published, area_capacity_published). The user sees why.
onError(error){ code, message } for saving, publishing and loading errors.
onStatusChanged(status, chartId, event)Saving status: saving when a save starts, saved when it succeeded, error when it failed or hit a draft conflict; once per change.

Methods (promises, resolved once the designer rendered): save() ({ draftUpdatedAt }), publish(note?) ({ version, publishedAt }; rejects when validation fails or when the user must confirm warnings in the designer), getState() ({ chartId, chartKey, mode, hasUnsavedChanges, hasUnpublishedChanges, isPublished, objectCount }), changeConfig(patch) (language, colorScheme, canvasColorScheme, mode, safeModeOptions; null resets a key), whenReady(), getIframe(), rerender(), destroy(). chartId and mode are read synchronously. save() and publish() reject in readOnly. Messages only travel between your page origin and the SeatsChart origin (parent_origin, checked both ways, never "*"); without parent_origin the embed stays URL-only (buildDesignerEmbedUrl).

The mode option only adapts the designer's UI, as in seats.io. To make it binding, mint the token with it: { "purpose": "designer", "chartId": "…", "mode": "safe", "safeModeOptions": { "allowEditingAreaCapacity": true } } (or "mode": "readOnly"). The API then refuses every save, publish or version restore of that token that breaks safe mode (403 forbidden, code ERR_DESIGNER_SAFE_MODE with reason and objectIds), and every write of a readOnly token (ERR_DESIGNER_READ_ONLY). The designer runs the stricter of the token's mode and the mode option, so it hides what the token cannot do; a refusal from the API is shown to the user and reported through onActionRefused. Tokens minted without a mode are normal. Mint a token for one chart (chartId) when the page must not reach other charts.

React (@seatschart/react):

tsx
import { Designer, type DesignerHandle } from "@seatschart/react";

const ref = useRef<DesignerHandle>(null);
<Designer ref={ref} webBase="https://seatschart.com" token={designerToken} chartId={chartId}
  mode="safe" canvasColorScheme="dark" onChartPublished={(id, e) => notify(e.version)} onExitRequested={close} />;
await ref.current?.save();

Vue (<Designer> from @seatschart/vue) and Angular (<seatschart-designer> from @seatschart/angular) take the same options; the Exit button shows with an @exit-requested handler / an (exitRequested) subscriber; see Vue and Angular components above.

Migrating from seats.io SeatingChartDesigner

seats.ioSeatsChart
new seatsio.SeatingChartDesigner({...}).render()new SeatsChartDesigner({...}).render() (script seatschart-designer.js)
secretKeytoken (designer embed token; no secret in the page)
chartKey (omit to create a chart)chartId or chartKey (id or partner key; omit to create a chart)
mode: "normal" | "safe" | "readOnly", safeModeOptionsSame names and semantics
features.enabled / features.disabled / features.readOnlySame names (SeatsChart tool names)
languageSame name (12 languages)
canvasColorSchemeSame name and values
openDraftDrawing, openLatestDrawingSame names (the latest version opens by default)
onDesignerRendered(designer), onDesignerRenderingFailed(designer)Same names (the event follows the designer)
onChartCreated(chartKey), onChartUpdated(chartKey), onChartPublished(chartKey)Same names (chart id, then the event)
onExitRequested()Same name (Exit shown only when set)
onStatusChanged(status, chartKey)Same name: onStatusChanged(status, chartId, event) (saving, saved, error)
features names, viewFromYourSeatSame names; viewFromYourSeat is accepted for seatViews
designer.destroy()Same name
—colorScheme, onActionRefused, onError, save(), publish(), getState(), changeConfig(), whenReady()

Map designer

Partner floor-plan editor, templates, categories with ticket types, multi-floor maps, draft/publish, and save API: Map designer.


Generate a server SDK

First-party Node/Bun: @seatschart/sdk. PHP / Java / .NET / Go: generate from OpenAPI (do not hand-maintain): Server SDKs.


Server-to-server checkout (reserve → pay → confirm)

Copy-paste TypeScript (@seatschart/sdk) + curl: Server integration. Uses x-api-key / Idempotency-Key; never confirm with the iframe buyer JWT.


Operational inventory (box office)

Status changes with preconditions, labels, hold tokens and multi-event fan-out, extraData updates, best-available preferences, event overrides, for-sale/channel bulk operations and partial seasons: Operational inventory.

Event keys, creation-time configuration, chart switching, administrative GA hold/release, chart validation and categories, workspaces, lifecycle webhooks and ticket buyer ids: Events, charts & workspaces.


Reservation lifecycle (B2B server-to-server)

  1. POST /api/seats/reserve — Applies a Redis TTL hold for userId (see hold_ttl_seconds in the JSON response). Seats remain available in Postgres. Clients receive SSE locked. The Idempotency-Key header is strictly required on every attempt: send a fresh UUID v4; reusing the same key within ~24h returns the cached HTTP response and prevents duplicate holds and duplicate billing side effects under client retries.

  2. After payment succeeds — Your backend calls POST /api/seats/confirm with the same userId, eventId, and seatIds, and a required Idempotency-Key header. The API checks that KeyDB still holds those locks, then enqueues commit_sold jobs (replays the same HTTP response when the key repeats within ~24h). The reservation worker writes sold in Postgres, publishes SSE sold, and POSTs outbound webhooks.

    Quote first: before charging, create a quote for the hold (POST /api/quotes), charge exactly its totalCents / currency, then confirm with quoteId, externalPaymentId, paidAmountCents, paidCurrency and holdToken. Without a quote the API answers 409 quote_required; a quote or payment that does not match answers 409 quote_mismatch. Full flow: Server integration.

  3. Checkout abandoned / payment failed — POST /api/seats/release requires Idempotency-Key as well; it removes the Redis locks when still owned by the same userId, publishes SSE available, and invalidates the cached seat map (~24h response replay like confirm).


Outbound webhooks — x-seatschart-signature-v2

Every SeatsChart → partner POST includes:

text
x-seatschart-timestamp: 1792677600
x-seatschart-signature-v2: t=1792677600,v1=<hex>
x-seatschart-signature: sha256=<hex>
X-SeatsChart-Event-Id: <uuid>
  • x-seatschart-signature-v2 (recommended): v1 is HMAC-SHA256 of the string "<t>.<raw body>" (the timestamp, a dot, then the exact raw UTF-8 body), keyed with the endpoint's own signing secret (returned once by POST /api/webhooks and POST /api/webhooks/:id/rotate-secret). Reject the request when |now − t| > 300 seconds — this is the replay protection.
  • x-seatschart-signature (legacy): HMAC-SHA256 of the raw body alone, still sent unchanged for existing receivers. Migrate to v2.
  • Each attempt (retry, replay, test event) is signed with a fresh timestamp; X-SeatsChart-Event-Id stays stable, so keep deduplicating on it.

Endpoints created before per-endpoint secrets use WEBHOOK_SIGNING_SECRET until you rotate them. JWT_SECRET is never used for webhooks. Without any secret, calls are unsigned (warning in server logs). Endpoint URLs must be public HTTPS addresses; internal targets are refused at registration and at send time (see Webhooks, scopes & usage).

Node.js — verify the signature

javascript
const crypto = require("node:crypto");

/**
 * @param {string} rawBody - exact body string (use express.raw or read stream before JSON parse)
 * @param {string} header - value of x-seatschart-signature-v2
 * @param {string} secret - the endpoint signing secret (whsec_…)
 */
function verifySeatsChartWebhook(rawBody, header, secret) {
  const parts = Object.fromEntries(String(header || "").split(",").map((p) => {
    const i = p.indexOf("=");
    return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
  }));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 || "")) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest();
  return crypto.timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}

Dashboard integration test

Inside SeatsChart itself, /dashboard/viewer/<eventId> runs the same strict postMessage flow against window.location.origin and shows a short English toast when a reservation succeeds — useful to prove the embed channel end-to-end.

Chart events and host commands

Prefer @seatschart/react SeatChart (or @seatschart/embed parsers) over hand-rolled listeners:

DirectionMechanism
Viewer → hostTyped chart events (chart.rendered, object.selected, session.hold, hold.failed, …) via postMessage
Host → viewersendChartCommand / SeatChart ref sendCommand (selectObjects, clearSelection, zoomToSelected, startNewSession, …)
Host → viewer (with reply)requestChartCommand / SeatChart ref requestCommand — resolves with the matching chart.command.result

Renderer commands

Every command carries a requestId; the viewer answers chart.command.result (ok: true + payload, or ok: false + message). Commands are accepted only from the validated parent_origin.

Command (chart.command.*)PayloadResult payloadReact handle
zoomToObjectsobjectIdscamerazoomToObjects(ids)
zoomToSelected—camerazoomToSelected()
zoomToSectionsection (id or label)camerazoomToSection(section)
resetView—cameraresetView()
selectBestAvailablenumber (1–50), categories?, sectionId?, ticketTypes?, zone?, sections?, accessibleSeats?objectIdsselectBestAvailable({ number, categories, sectionId })
holdBestAvailablenumber (1–50; the sum of ticketTypes when given), categories?, ticketTypes?, zone?, sections?, accessibleSeats?objects, nextToEachOther, camerachart.holdBestAvailable(options) → Promise
zoomToZonezone (id or name)camera, objectIdschart.zoomToZone(zone) → Promise
camera3daction: isometric, stage, getState or setState (+ state); enter3d: true with isometric / stage opens 3D firstcamera3d ({ position, target, fov }, null outside 3D and for the 2D floor perspective)chart.isometricView(), stageView() (2D: floor perspective; 3D: camera preset), set3DCameraPreset(preset), get3DCameraState(), set3DCameraState(state) → Promise
setFilteredSectionsection (id or label; null clears)objects (the section)chart.setFilteredSection(section) / clearFilteredSection() → Promise
clickConfirmationButton—— (refused when nothing is selected or holds are still running)chart.clickConfirmationButton() → Promise
previewObjectChangeschanges: up to 64 { objects, objectType?, color?, label?, visible? } ([] clears)warnings (dropped fields)chart.previewObjectChanges(changes) / resetObjectChanges() → Promise
findObjectobject (id or label)object (objectId, label, category, status, price, forSale, objectType, categoryInfo, pricing, selectedTicketType, …)findObject(query) → Promise
listCategories—categories (key, label, color, totalCount, availableCount)listCategories() → Promise
setFilteredCategoriescategories (names or keys)filteredCategoriessetFilteredCategories(list)
clearFilteredCategories—filteredCategories: []clearFilteredCategories()
setFiltersfilters (price? { min?, max? } in major units, sections?, ticketTypes?, accessibility?, categories?, accessibilityTypes?, listingType?, partySize?)filterssetFilters(filters) → Promise
clearFilters—filters: {}clearFilters() → Promise
zoomToFilteredCategories—camera, objectIdszoomToFilteredCategories()
goToFloorfloor (id or name)floor (id, label, active)goToFloor(floor) → Promise
listFloors—floors[] (empty on single-floor charts)listFloors() → Promise
goToAllFloorsView—— (refused on single-floor charts and with lockActiveFloor)goToAllFloorsView() → Promise
showViewFromSeatobject (seat id or label)viewFromSeat: "3d" or "photo" (refused for an unknown seat, or when the device has no 3D and the seat no photo)showViewFromSeat(object) → Promise
getReportBySelectabilityincludeObjectIds?, groupBy? (category, section, zone, floor, status)report: total, selectable, notSelectable, byStatus, byCategory[], optional objectIds { selectable, notSelectable }, with groupBy groupedBy + groups[]getReportBySelectability() / getReportBySelectabilityGroupedBy(field) → Promise
trySelectObjects / doSelectObjectsobjects: ids, labels or { id | label, ticketType } (max 50)objectIds (the selection)trySelectObjects(objects) / doSelectObjects(objects)
selectCategories / deselectCategoriescategories (names or keys)objectIdsselectCategories(list) / deselectCategories(list)
listZones—zones[]: id, name, color, floorId, totalCount, availableCountlistZones()
setSpotlight / setSpotlightOnSelection / clearSpotlightobjectIds / — / —objectIds (spotlit)setSpotlight(ids), setSpotlightOnSelection(), clearSpotlight()
pulse / unpulseobjectIds / objectIds?animated (false with reduced motion)pulse(ids) / unpulse(ids?)
resolvePromptpromptId, value or cancelled: true—resolvePrompt(id, valueOrNull)
getHoldToken—session: { holdToken, expiresAt, objectIds } or nullgetHoldToken() → Promise
changeConfigmaxSelectedObjects?, pricing?, channel?, config? (host customisation patch; null resets a key)warnings?changeConfig(patch) / chart.changeConfig(patch) → Promise

"Selectable" in the report is exactly what a buyer click accepts: available, for sale, allowed category, on the visible floor. SeatChart also exposes the whole promise API as ref.current.chart (same methods as the script-tag loader) and rerender().

Zoom commands fit the target (zoom + center with padding, clamped to the viewer zoom limits) with a ~300 ms eased animation; with prefers-reduced-motion: reduce the camera jumps. Dragging, pinching or scrolling cancels a running animation. camera is the target { scale, centerX, centerY } in world units. status is one of available, locked, sold, notForSale.

selectBestAvailable uses POST /api/seats/best-available; categories are tried in order and the first that can satisfy the request wins. Found seats are selected (then held through the normal hold-on-select session) and pulse briefly. A category filter dims every other seat and shows a "Show all" chip the buyer can use to clear it (the host is notified either way).

Renderer events

Event typePayloadSeatChart / listener callback
hold.pending / hold.settled— first call in flight / every call finishedonHoldCallsInProgress / onHoldCallsComplete
hold.succeededobjectIds, holdToken, expiresAt, objects, ticketTypesonHoldSucceeded
hold.confirmedobjectIds, holdToken — the partner confirmed the buyer's hold (seats now sold to this buyer)onHoldConfirmed
release.succeeded / release.failedobjectIds, holdToken / message, objectsonReleaseHoldSucceeded / onReleaseHoldFailed
selection.lostobjectIds, reason (taken, not_for_sale, channel_locked, expired)onSelectedObjectUnavailable
chart.rerendering—onChartRerenderingStarted
prompt.requestid, kind (places, ticketType, placesWithTicketTypes), object, options, timeoutMsonPromptRequest
object.statusChangedchanges[]: objectId, label, previousStatus, nextStatus — realtime (SSE) changes, one message per animation frameonObjectStatusChanged
selection.validselectedCount — the selection is valid again after a selection.invalidonSelectionValid
bestAvailable.selectedobjectIds, adjacent, requestId?onBestAvailableSelected
bestAvailable.failedreason (none_available, max_selected, request_failed, not_ready), message, requestId?onBestAvailableFailed
filteredCategories.changedcategories ([] = no filter)onFilteredCategoriesChanged
accessibilityFilter.changedfilters: wheelchair, companion, semiAmbulatory, plusSize, hearing, liftUpArmrests, hideLimitedView, hideVertigo, hideLimitedLegroom ([] = off) — buyer used the accessibility options of the Filters controlonAccessibilityFilterChanged
filters.changedfilters (price?, sections?, ticketTypes?, accessibility?, categories?, listingType?, partySize?; {} = none), matchingCountonFiltersChanged
chart.renderedobjectCount, optional metrics: timeToFirstSeatPaintMs, mapLoadMs, geometryParseMs (absent when cached), stateDecodeMs, stateApplyMs, seatCount, longTasks { count, totalMs, maxMs, windowMs } (first 10 s) — local timings only, no third-party trackeronChartRendered
session.initializedholdToken, expiresAt — a hold session exists at start (created, resumed, or after startNewSession)onSessionInitialized
orphanSeats.changedobjectIds (the free seats the selection strands now, [] when none is left), added, removed — while noOrphanSeats is on, after selection and live status changesonOrphanSeatsChanged
holdToken.creationFailedmessage, status (HTTP status of POST /api/hold-tokens, 0 when the request never reached the API) — the buyer's hold session could not be createdonHoldTokenCreationFailed
realtime.lost / realtime.restored— the live status stream (SSE) dropped / is back and the map caught up; the viewer reconnects by itself, once per outageonRealtimeConnectionLost / onRealtimeConnectionRestored
selection.invalidreason: max_selected, max_selected_per_category (max, category), max_selected_per_ticket_type (max, ticketType), orphan_seats (objectIds = the orphans; the click is accepted, checkout waits), places_unavailable (required), consecutive_seats (objectIds, advisory), companion_seat (objectIds = companion seats without an accessible seat beside them, advisory), listing_unavailable (objectIds, listingId: a resale listing selected as a group has an unavailable object)onSelectionInvalid
floor.changedfloorId, label — buyer used the floor picker or the host called goToFloor; allFloors: true (empty floorId) for the all-floors overviewonFloorChanged
fullScreen.opened / fullScreen.closedmode: native (Fullscreen API), expanded (fills the frame when fullscreen is not allowed) or host (the host page filled its window, fullScreenTarget: "host")onFullScreenOpened / onFullScreenClosed
fullScreen.requestopen — fullScreenTarget: "host": the buyer pressed the fullscreen button (or Escape); expand / restore the iframe and answer with chart.command.setFullScreen { active } (the loader does this)onFullScreenRequest

Host listeners must accept messages from the viewer origin (new URL(webBase).origin) and the iframe's window only — the script-tag loader and SeatChart do this; createChartEventListener takes viewerOrigin and source for hand-rolled hosts.

ts
import { requestChartCommand } from "@seatschart/embed";

const result = await requestChartCommand(iframe, "https://your-tenant.seatschart.com", {
  type: "chart.command.selectBestAvailable",
  requestId: crypto.randomUUID(),
  number: 2,
  categories: ["VIP", "Balcony"],
});
if (result.ok) console.log("selected", result.objectIds);

Useful query / SDK props (also on SeatChart):

Param / propPurpose
maxSelectedObjectsCap selection (1–1000)
selectedObjectsInitial highlight by seat UUID (does not auto-hold)
holdTokenResume an existing hold session (UUID, not JWT)
languageViewer UI locale override
showLegendCategory legend on/off
multiSelectEnabledWhen false, selection is capped at 1
currencyISO 4217 code for prices (e.g. BRL), formatted in the viewer language; the event catalog currency wins once the map payload carries it (default USD)
themeOverlay theme: light, dark or auto (follows the buyer's OS). Without it a dark bg switches overlays to dark
statusPaletteaccessible starts with the colour-blind-safe status palette (buyers can toggle it in the toolbar)
debug=perfViewer URL only: render-timing overlay for developers

Viewer toolbar (buyer): zoom in / out, fit, full screen (Fullscreen API; inside an iframe without allow="fullscreen" the viewer expands to fill the frame), colour-blind-safe palette, and an Accessibility menu (wheelchair spaces, companion seats, hide restricted/obstructed view) when the map has accessibility data. Large maps (> 1,500 seats, or zoomed past 2× the fit) show a minimap; click or drag it to move the view. On touch screens the first tap on a seat shows its details, the second tap (or Select seat) selects it.

There is no secretKey in the browser. Confirm remains partner/server-only (POST /api/seats/confirm).

Inventory Apply (NFS / channels) publishes kind: inventory on the same seat-map SSE stream so connected buyers update live.

3D venue view and static export (PNG / PDF)

3D view (opt-in)

The viewer toolbar has a 3D toggle (box icon) next to the accessibility tools. It renders the same published geometry with three.js (WebGL2), loaded as a separate chunk only when 3D is entered:

  • floor slab with a soft contact shadow; sections, GA areas and scenery as floor plates; the stage (the shape that contains the focal point, or one named Stage) and tables extruded;
  • terraced seating: inside each section, rows are ranked by distance from the focal point and rise one step per row (≈ 21° rake); a section behind another (same bearing from the stage) starts raised and overhangs it like a balcony, while side-by-side sections of a bowl stay level. Charts without a focal point face the top edge;
  • seats are one InstancedMesh (one draw call) facing the stage; colours come from the same state pipeline as 2D (category tint, held / sold / not-for-sale, selection, host highlights, category and accessibility filters, colour-blind palette), so realtime SSE deltas, holds and selection keep working while 3D is shown;
  • orbit (drag / one finger), pan (right-drag / two fingers), zoom (wheel / pinch, towards the cursor); the toolbar zoom and fit buttons drive the 3D camera; click or tap a seat to select it (touch shows the same preview sheet as 2D);
  • entering 3D tilts from a top-down view into the bowl (~1 s); with prefers-reduced-motion: reduce the camera jumps and damping is off;
  • rendering is on demand (no frames while idle or when the tab is hidden);
  • chairs around GA tables (bistro tables, boxes) are coloured per place from the live GA counts and the buyer's GA selection, with the seats' palette (category colour, selection, held / sold / not for sale); only changed chairs are re-uploaded;
  • the chart's icons (entrance, bar, toilets, first aid…) are camera-facing markers from one shared icon atlas, sized for the screen and hidden up close when they would cover seats; Places lists the active floor's markers by name and moves the camera to the one chosen (keyboard and screen readers);
  • host zooms frame the 3D camera too: zoomToObjects, zoomToSection (the whole section), zoomToZone, zoomToSelectedObjects, zoomToFilteredCategories and holdBestAvailable look at the objects from behind them, towards the stage, and resetView returns to the overview; while the 3D view is open isometricView() and stageView() show the whole venue from an isometric angle or from the stage (in 2D they switch the floor perspective, see Floors), set3DCameraPreset("isometric" | "stage") does the same from 2D by opening 3D on devices that can show it, get3DCameraState() returns { position, target, fov } (null outside 3D) and set3DCameraState(state) restores it;
  • multi-floor charts get a floor elevator on the left (floor names, keyboard accessible): the camera glides to the chosen floor, the floors above it fade and take no clicks, and 3D opens on the floor of the buyer's selection, else the lowest floor. goToFloor and the 2D floor picker drive the same floor.
Param / propPurpose
defaultRenderer=3d (URL) / defaultRenderer: "3d" (SeatMapEmbed, SeatChart, buildViewerEmbedUrl)Open in 3D. Default 2d.
settings3d: { lightPreset } (config: loader, SeatMapEmbed, SeatChart, React Native, Vue, Angular; Flutter lightPreset)Light of the 3D view: exteriorDaytime (default), exteriorSunset, exteriorNight or interior (seats.io settings3d.lightPreset); wins over the chart's preset and changeConfig applies it live.

GPU check: without WebGL2 the toggle is disabled and the viewer stays 2D. Software rasterisers (SwiftShader, llvmpipe, …), a major performance caveat, a max texture size below 4096 or ≤ 2 CPU cores / < 2 GB memory count as a low tier: defaultRenderer=3d then opens in 2D with a dismissible notice, and the buyer can still switch to 3D by hand. If the WebGL context is lost the viewer returns to 2D. The keyboard seat list stays available in both modes.

Authored 3D

Charts can author their 3D in the designer (Chart properties → 3D view, the Section and Zones panels; see the map designer guide): per-section, per-zone or chart-wide elevation and rake (per row or front to back, in metres), a stand or a floating tier, floor heights for multi-floor charts and 3D tables. The viewer then uses those values: rows rise by the authored rake, floors are stacked by height (the floors below the active one stay visible, dimmed and not selectable; the floors above fade to a translucent outline), and table seats sit level and face their table. Seats outside authored areas, and every chart without layoutMeta.settings3d, keep the automatic tiering above. Selection, availability colours, host highlights, best available and the export work the same; the GPU check and the 2D fallbacks are unchanged.

View from a seat. View from seat (the seat card, a cart line, or chart.showViewFromSeat(object)) moves the 3D camera to a seated spectator's eye at that seat, looking at the chart's spectator target or the stage; drag or the arrow keys look around, the wheel or +/− zoom, Back to overview or Esc returns. The 3D view opens first when needed and reuses the page's WebGL renderer (no new context). Devices without 3D show the seat's photo / 360° view, or a short notice; public chart preview links have a 3D button with the same view. Charts can also set a light preset, a spectator target, sunken areas (negative heights), floors that push the floor above and chairs around GA tables (see the map designer guide).

Static export

Dashboard. Export on the event viewer page, Export PNG / PDF in the event row menu and in the chart library card menu. The dialog shows a live preview rendered by the same pipeline as the download:

  • PDF — A4, A3 or Letter; portrait, landscape or auto (follows the map aspect); 150 dpi (draft) or 300 dpi (print). Header with title, timestamp and a Live availability / Seating chart badge; legend with category colours, price range and counts (available of total for events), held / sold / not-for-sale, wheelchair / companion badges and restricted view; footer with page numbers. With Seat labels on, maps whose seats would print smaller than 3.4 mm get numbered detail pages (outlined on page 1; overlapping tiles, empty tiles skipped, at most 24).
  • PNG — 1×–4× of the map's world size (exact pixel size shown before download), with header and legend bands. Sizes are clamped to browser canvas limits (16 384 px per side; ~16.7 MP on iOS) and the dialog says so.

Events export their availability at that moment (sold / held / not for sale in print-safe greys); charts export the draft or published geometry by category (a note appears when the chart has unpublished changes). Multi-floor charts export one floor at a time.

Renderer API. chart.command.exportImage renders inside the viewer (live availability, active floor) and replies with image:

PayloadResult imageReact handle
format (png | pdf), scale? (1–4, PNG), paper? (A4 | A3 | Letter), orientation? (portrait | landscape | auto), dpi? (150 | 300), seatLabels?, legend?, glyphs?, output? (blob | dataUrl), title?mimeType, width, height (PNG size or first-page pixels), pages, scale (effective PNG scale), blob or dataUrlexportImage(options) → Promise<ChartExportImage> (120 s default timeout)
ts
const image = await chartRef.current!.exportImage({ format: "pdf", paper: "A4", dpi: 300, seatLabels: true });
const url = URL.createObjectURL(image.blob!);

Rendering is client-side (Canvas 2D at the chosen resolution): seats are drawn in colour-grouped chunks that yield to the main thread, so a 50,000-seat export keeps the page responsive and reports progress. The PDF is a minimal PDF 1.4 written in-house (one full-page JPEG per page, ~100 lines) — no PDF dependency.

Renderer filters: seats.io names, party size and listing type

js
await chart.setFilters({
  categories: ["VIP", "balcony"],           // category keys or labels (same filter as setFilteredCategories)
  accessibilityTypes: ["accessible", "aisleSeat"],
  listingType: "resale",                    // "resale" | "primary" | "both"
  partySize: 4,                             // seats that fit 4 adjacent free seats in one row or table
});
  • setFilters (and the filters.changed payload) also accepts the seats.io names: categories (category keys or labels; the same filter as setFilteredCategories; when absent, a category filter set with setFilteredCategories is kept), accessibilityTypes (accessible, semiAmbulatorySeat, hearingImpaired, signLanguageInterpretation, cartServices, plusSize, liftUpArmrests, aisleSeat, or our option names; merged into accessibility), listingType (resale keeps the objects of the host resaleListings, primary the others) and partySize (2–20). Old names keep working; unknown values are refused. With server-side listings, listingType follows their kind (resale = untyped and resale-type listings).
  • Party size. Keeps the seats that can be part of N adjacent available seats (the buyer's own selection counts as available) in the same row chain — designer rows split at aisles, legacy rows by position — and dims the others. It is computed in the browser in one pass over the seats and recomputed after live status batches only while it is on.
  • Filter panel. The lazy Filters panel adds a "Seats together" stepper (maps with seats), category chips with live availability, and the listing type switch when the host passes resale listings. Reset clears every filter. The new accessibility option aisle lists aisle seats (designer flag "Aisle seat").
  • filters.changed reports categories, listingType and partySize when they are set; accessibilityTypes is an input alias and is reported as accessibility.

Migrating from seats.io (renderer)

A seats.io renderer config works as it is: the script-tag loader, the React / Vue / Angular components and the React Native view map the seats.io option names and shapes (@seatsio/seatsio-types 6.34) onto SeatsChart's. In development (a bundler's NODE_ENV other than production, or a localhost / *.localhost / *.test page) each mapped key logs one console warning with the SeatsChart name to use; production pages stay silent. The mapping runs on your page, so the buyer viewer stays as small as before.

js
new SeatsChart({
  divId: "chart", event: "…", token: "…",
  colorScheme: "dark",                          // → theme
  showSectionContents: "onlyAfterZoom",         // → "onZoom"
  maxSelectedObjects: [
    { total: 6 },
    { category: "VIP", quantity: 4 },
    { category: "Plateia", ticketTypes: [{ ticketType: "meia", quantity: 2 }] },
  ],
  messages: { notAvailable: "Esgotado", maxSelectionReachedWithNumber: "Máximo de $1 lugares" },
  defaultRenderingEngine: "auto",               // → defaultRenderer
}).render();
seats.ioSeatsChart
colorSchemetheme (light, dark; SeatsChart also has auto)
showZoomOutButtonOnMobileshowZoomOutButton (every device)
defaultRenderingEngine (auto, 2d)defaultRenderer; auto opens the 3D view when 3D seat picking is on for the chart (the designer's Allow 3D seat picking, on by default for charts with authored 3D) and the GPU can show it, else 2D
showSectionContents: "onlyAfterZoom""onZoom"
alwaysShowSectionContents: trueshowSectionContents: "always"
maxSelectedObjects array ({ total }, { ticketType, quantity }, { category, quantity }, { category, ticketTypes })Same (quantities 1–1000); enforced by the viewer (a category matches its key or its name). A limit reached fires selection.invalid (max_selected_per_category / max_selected_per_ticket_type, with category for a { category, ticketTypes } limiter)
objectTooltipobjectPopover (confirmSelectionOnMobile → confirmSelection)
categoryFilterfilterControls.category
showAccessibilityFilterfilterControls.accessibility.enabled
filteredCategoriesfilters.categories
filtersSame keys, at render time and in changeConfig
holdOnSelect, reserveOnSelectsession (true → continue, false → none)
listings (id, objects, listingType, selectAsGroup)resaleListings.listings; a listingType other than resale makes a primary listing; selectAsGroup per listing overrides the config-wide one
allowOrphanSeatsselectionValidators.noOrphanSeats (inverted)
channelschannelKeys; as in seats.io, without NO_CHANNEL only the channels' objects can be selected (objectWithoutChannelSelectable: false), with it public objects too
colors.availableObjectColorobjectColor.byStatus.available
colors.colorSelected, selectedObjectColor, cursorTooltipBackgroundColor, colorTitle, errorColorcolors.accent (and colors.selected unless selectedObjectColor is set), colors.selected, colors.tooltipBackground, colors.title, colors.error
themeColorscolors (colors wins)
selectableObjects { label, amount }Same: the amount caps the places of that general admission area
onWebsocketConnectionClosedonRealtimeConnectionLost (SeatsChart streams over SSE)
loading (HTML)loading.text (plain text)
selectedSeats, maxSelectedSeats, selectedSeatsInputNameselectedObjects, maxSelectedObjects, selectedObjectsInputName
reservationToken, reservationTokenInputNameholdToken, holdTokenInputName
onSeatSelected, onSeatDeselected, onReservationSucceeded, onReservationFailedonObjectSelected, onObjectDeselected, onHoldSucceeded, onHoldFailed
Event Manager mode: "manageForSaleConfig"mode: "manageForSale"
Every other documented key (pricing, session, legend, style, partySize, settings3d, the 25 callbacks, …)Same name

messages. seats.io renderer string keys are mapped onto the SeatsChart texts they stand for (for example notAvailable → the legend's "Not available" and unavailable ticket types, sessionExpired → the expired hold notice, sectionAvailability.none → "Sold out", select-lasso → the lasso tool); positional placeholders ($1, {0}, %s, seats.io's %a) become the SeatsChart placeholder; the fee strings (feeIncluded, feeExcluded, allFeesIncluded, excludingFees) and holdReleaseFailedModalBodyBecauseOther are mapped too. SeatsChart keys (viewer.…) keep working and win. Keys for text SeatsChart does not show (clickToSelect, holdFailedModalBody…, social distancing, …) are ignored with a development warning, and so are chart text translations ("Stage": "Palco").

changeConfig (seats.io ConfigChange). availableCategories, unavailableCategories, filters, filteredCategories, maxSelectedObjects (number, object or limiter list), numberOfPlacesToSelect, objectColor, objectLabel, pricing and partySize apply live, without a reload; null resets a key (filters: null clears every filter). chart.setAvailableCategories([...]) and chart.setUnavailableCategories([...]) do the same (null or [] resets). Categories outside availableCategories, or in unavailableCategories, are greyed out and cannot be selected (by key or name). channels / channelKeys reload the chart with the new sales channels (the hold session continues). extraConfig re-evaluates every rule that reads it.

Not supported yet. These keys are dropped with a development warning: functions (objectColor, objectLabel, objectIcon, sectionColor, isObjectVisible, isObjectSelectable, popoverInfo, priceFormatter) cannot cross the iframe and are never evaluated, so pass the declarative tables and specs above (Declarative rules v2); the warning names the match criteria that cover what your function reads. A declarative popoverInfo / tooltipInfo table is accepted as tooltip. ticketBuyerId belongs in the buyer token (POST /api/viewer-tokens) and listingTypes in the listing types API.

Not supported by design. themePreset (seats.io's named palettes): the chart follows the host instead, with theme (light, dark, auto; seats.io colorScheme), colors and objectColor. Function-valued isObjectSelectable, objectCategory and objectCategories: functions cannot cross the iframe, so use the declarative rules (selectableObjects, availableCategories / unavailableCategories, isObjectVisible: { rules: [{ match, value: false }] }); categories come from the chart and the event's own categories, and objectColor / objectLabel rules with match.categories restyle them. Each key is dropped with a development warning that names the alternative. Keys that have no effect in seats.io either (stylePreset, showViewFromYourSeatOnMobile / OnDesktop, unifiedObjectPropertiesInCallbacks, showRowLines, tooltipStyle, …) are ignored.

Host objects (seats.io object model)

onObjectSelected(object, selectedTicketType) and onObjectDeselected(object) receive the whole object, and findObject(idOrLabel) and listSelectedObjects({ detailed: true }) return the same shape ({ detailed: true } lists every selected seat and general admission area; without it you get the ids, as before). The viewer builds the object from what it already shows (map, live availability, prices): no extra request, at most 2 KB per object. The existing fields keep their names (objectId, label, category is the category name, status, price in cents, forSale, rowLabel, sectionId, displayedLabel, accessibility, entrance); the seats.io-shaped fields come on top:

js
{
  objectId: "…", label: "A-12", category: "Plateia", status: "available", price: 26000, forSale: true,
  objectType: "seat",                        // seat | generalAdmission | table | section
  labels: { own: "12", parent: "A", section: "Plateia" },
  sectionLabel: "Plateia",
  categoryInfo: { key: "plateia", label: "Plateia", color: "#5856d6" },
  pricing: { price: 13000, formattedPrice: "R$ 130,00", ticketTypes: [
    { ticketType: "inteira", label: "Inteira", price: 26000, formattedPrice: "R$ 260,00", primary: true },
    { ticketType: "meia", label: "Meia-entrada", price: 13000, formattedPrice: "R$ 130,00" },
  ] },
  selectedTicketType: { ticketType: "meia", label: "Meia-entrada", price: 13000, formattedPrice: "R$ 130,00" },
  selected: true, selectable: false, isOrphan: false,
  floor: { id: "f1", label: "Térreo" }, zone: { id: "z1", label: "Norte" }, center: { x: 412, y: 318 },
  entrance: "Portão 3", resaleListingId: "…",
  extraData: { vipLounge: true },           // only keys the workspace publishes to buyers
}

General admission areas (objectType: "generalAdmission") and GA tables ("table") add capacity, numFree, numBooked, numHeld, numSelected, selectionPerTicketType (places per ticket type id), bookAsAWhole and areaType; tables add tableType (round, rectangle) and, for tables with seats, seats (the seat ids); sections (findObject with a section id or name) add numberOfSelectableObjects, numberOfSelectedObjects and selectableCategories. Prices are in cents of the event currency, and formattedPrice is the text the buyer sees (your priceFormatter included). When your pricing sets fee (or a synced price table sets feeCents), pricing.fee, every pricing.ticketTypes[].fee and selectedTicketType.fee carry it in cents, as seats.io's fee: pricing.fee is the fee of the selected (else default) ticket type, and price stays what the buyer pays, so the fee is inside it with allFeesIncluded: true and already added otherwise. Through the script-tag loader and the components, objects also have the seats.io methods select(ticketType?), deselect(ticketType?), pulse(), unpulse() and isInChannel(channelKey).

Every object also has displayObjectType, the kind of object as a person reads it (Seat, Wheelchair space, General admission, Table, Section; publish an extraData.displayObjectType key to name your own, such as Booth). Seats add parent ({ type: "row", label }, or { type: "table", id, label } for a seat at a table) and, when the seat has a published view, viewFromSeatUrl (the image showViewFromSeat opens). Sections add isInteractive (a click zooms into the section or selects it whole; false for an outline without objects) and sectionCategory (the category set on the section in the designer, else the one category all its seats share, { key, label, color }). On season and multi-event charts, seats and general admission areas add dataPerEvent ({ [eventId]: { status } }, the live status in each event, at most 24 events) and, on a season, seasonStatusOverriddenQuantity (how many events differ from the season's status, that is the source event's). The viewer builds these fields from the map it already holds; to stay within 2 KB, the last events of dataPerEvent go first.

deselectObjects takes ids, labels, { label, ticketType } (deselects a seat only when it carries that ticket type) and { label, amount, ticketType? } (removes places of a general admission selection; onObjectDeselected then has quantity, the places removed, and numSelected, the places left). unpulse(ids) and unhighlightObjects(ids) act on those objects only. A host selectObjects refuses objects outside availableCategories or in unavailableCategories, like a buyer click.

Best available that holds, camera and fullscreen (host)

chart.holdBestAvailable(options) (seats.io holdBestAvailable) asks the server for the best seats and holds them in one atomic step on the chart's hold session: the seats are appended to the open session (session: "start" / "continue", or the token of an earlier hold) or a new session starts. It goes through the same hold as the API's best available (POST /api/seats/best-available with hold: true): two buyers never get the same seat, the organizer's booking limits apply, and the host's availableCategories / unavailableCategories are respected (a blocked category is never searched). The held seats are selected, onHoldSucceeded fires (with the objects and their ticket types) and the promise resolves with { objects, nextToEachOther }; when nothing can be held, onHoldFailed fires with no objects and the promise rejects. selectBestAvailable takes the same options and only selects.

js
const { objects, nextToEachOther } = await chart.holdBestAvailable({
  categories: ["Plateia"],            // tried in order (key or name)
  ticketTypes: { inteira: 2, meia: 1 }, // or number: 3
  zone: "Norte",                       // or sections: ["A", "B"]
  accessibleSeats: 1,                   // wheelchair seats the result must contain
});

Give number or ticketTypes (ticket type names or ids; only categories that offer every type are searched). Wrong options reject before anything is sent. Multi-event charts (events) hold one event at a time: use selectBestAvailable there.

chart.openFullScreen() / chart.closeFullScreen() expand the chart over the host page and restore it (Escape restores it too; onFullScreenOpened / onFullScreenClosed fire). chart.setFilteredSection(section) keeps one section bright (the buyer section filter) and clearFilteredSection() removes it. chart.zoomToZone(zone) fits a zone by id or name. chart.clickConfirmationButton() presses the viewer's checkout button, as the buyer would.

Preview object changes. chart.previewObjectChanges(changes) draws temporary changes over the map, for example to show an edit before you save it: each change names objects (ids or labels of seats, tables and general admission areas) and sets color (#rrggbb), label (an objectLabel template such as {seat}) and/or visible (false hides the objects; hidden objects cannot be selected). objectType: "section" previews a section's colour. Nothing is saved or sent to the server. A new call replaces the previous preview, chart.resetObjectChanges() and rerender() clear it, and a changeConfig during a preview changes your own colours and labels underneath it. seats.io's undocumented previewObjectChanges(objectType, params) form is accepted (chart.previewObjectChanges("seat", { objects: ["A-1"], color: "#ff3b30" })); seats.io uses it in its designer, here it is a renderer preview of colour, label and visibility only. The promise resolves with { warnings } for dropped fields and rejects when nothing valid is left.

js
await chart.previewObjectChanges([
  { objects: ["A-1", "A-2"], color: "#ff3b30", label: "VIP" },
  { objects: ["B-7"], visible: false },
  { objects: ["Balcony"], objectType: "section", color: "#5856d6" },
]);
await chart.resetObjectChanges();

Guided selection, seat search and colours

stepThrough: { enabled: true } (seats.io stepThrough, off by default) replaces the best-available panel with a guide for buyers who would rather answer two questions than read a map: where (a section, or a ticket category on charts without sections, with its colour and starting price) and how many. The viewer's best available then picks the seats, shows them on the map and lists them: Keep these seats hands over to the cart (focus moves to its checkout button), Change releases them and goes back. The guide never blocks the map: close it at any step (or press Escape) and reopen it from its Find your seats pill. Every step's heading takes focus, the options are a radio group (arrow keys) and results are announced; its code and strings load only on charts that use it.

List places (seats.io stepThrough browse list): on narrow screens (under 650 px, like seats.io) charts with stepThrough also get a List places button. It opens a sheet over the bottom of the map with a carousel that walks the chart level by level: floors (multi-floor charts showing all floors), then the floor's sections, GA areas and rows outside sections ("Choose a row or a section"), then a section's rows, then a row's seats. Previous / Next (or a swipe) move along the carousel; the place in the middle is highlighted on the map and kept in view above the sheet. Picking a section or a row zooms the map onto it, picking a GA area opens it, picking a seat selects or drops it exactly like a click on the map (every selection rule applies, whole rows included). Only places a buyer can take now are listed, sorted naturally; two rows with the same label read "A, Balcony". Back steps out one level (out of the floor too, unless lockActiveFloor) and lands on the place you came from; the button's label follows the level ("Browse floors", "List places", "Browse rows", or the section and row entered). The sheet is a dialog: its heading takes focus and is announced, arrow keys move along the carousel, Enter picks, Escape (or the pull handle) closes and focus returns to the button. Its code and strings load only when the chart uses stepThrough on a narrow screen.

showSearchButton: true adds a search button to the map controls; it opens the accessible seat list with its search field focused (search by seat label or category).

colors (seats.io colors, #rrggbb) restyles the chrome in light and dark alike: selected tints selected seats (the colour-blind-safe palette keeps its own colours), accent the buttons, focus rings and active controls (their text turns black or white for contrast), tooltipBackground the seat tooltip and the section summary (with readable text), title the cart, guide and card titles, and error the blocking warnings (orphan seats). The loader also takes the seats.io names (colorSelected, selectedObjectColor, cursorTooltipBackgroundColor, colorTitle, errorColor, availableObjectColor).

js
new SeatsChart({
  divId: "chart",
  event: "EVENT_UUID",
  stepThrough: { enabled: true },
  showSearchButton: true,
  colors: { selected: "#e11d48", accent: "#e11d48", tooltipBackground: "#111827", error: "#b91c1c" },
  inputDevice: "auto",
  onOrphanSeatsChanged: ({ objectIds }) => showGapHint(objectIds),
  onHoldTokenCreationFailed: ({ status }) => retryLater(status),
  onRealtimeConnectionLost: () => showBanner("Reconnecting…"),
  onRealtimeConnectionRestored: () => hideBanner(),
}).render();

onRealtimeConnectionLost fires once when the live status stream drops (or cannot connect) and onRealtimeConnectionRestored once it is back and the map caught up with every change it missed; recycling the stream when a background tab comes back is not an outage. Reserve and confirm stay authoritative on the server whatever the stream does.

Map-only mode (bringYourOwnBackend)

Map-only mode renders a published chart of your workspace with object statuses that your platform supplies. Use it when your inventory lives in your own system (an existing ticketing backend, a box office, a reservation engine you already trust) and you only need the SeatsChart map: the drawing, categories, zoom, accessibility, selection and the host API. It is the seats.io bringYourOwnBackend option.

When to use it. You already sell the seats elsewhere and want a better map; you migrate from another seat map and keep your backend for now; you show a venue for a sale SeatsChart does not run (a partner's allocation, a members' ballot). If SeatsChart should own the inventory, use a normal render with an event instead.

What you lose. SeatsChart creates no event, hold, reservation or order and never confirms a sale, so it cannot protect you from overbooking: two buyers can select the same seat, and your backend must decide who gets it (lock or reserve the seat on your side when the buyer checks out, and refuse a seat that is no longer free). There is no realtime stream either: a status changes on screen only when you call setObjectStatuses. Hold timers, hold tokens, best available (the panel, selectBestAvailable and holdBestAvailable search SeatsChart's inventory), sales rules and the checkout button are not available. Selection, filters, pricing (pricing), declarative rules, tooltips, the legend and every renderer command keep working.

html
<div id="chart"></div>
<script src="https://YOUR_SEATSCHART_WEB/embed/v1/seatschart.js"></script>
<script>
  const chart = new SeatsChart({
    divId: "chart",
    bringYourOwnBackend: true,              // or mapOnly: true
    chart: "main-hall",                      // chart id or chart key (published)
    workspaceKey: "pub_…",                  // your workspace public key
    objectStatuses: {                        // by object id or label
      "A-1": "booked",
      "A-2": "reservedByToken",
      "B-7": "notForSale",
      "C-3": { status: "free", category: "VIP", price: 120 },
      "Standing": "booked",                  // a general admission area by name
    },
    pricing: { prices: [{ category: "VIP", price: 120 }, { category: "Floor", price: 45 }] },
    onObjectSelected: (object) => cart.add(object.label, object.pricing.price),
    onObjectDeselected: (object) => cart.remove(object.label),
    onSelectedObjectUnavailable: ({ objectId }) => cart.removeById(objectId),
  }).render();

  // Your backend booked or freed seats: the map repaints them, nothing reloads.
  await chart.setObjectStatuses({ "A-1": null, "D-4": "booked" });
  // Replace the whole table (every unlisted object becomes free again).
  await chart.setObjectStatuses(await loadStatusesFromMyBackend(), { replace: true });
</script>

Statuses. Keys are object ids or labels (a label names every object that carries it; general admission areas by id or name). free (or available), booked (or sold), reservedByToken (or held), notForSale (or disabled); any other text is a custom status shown as unavailable. { status, category, price } also changes an object's category (colour, legend, filters) or price (major units; for ticket types use pricing). null resets an object to free. Unknown references are reported once in the browser console.

Updates. setObjectStatuses(statuses) merges, setObjectStatuses(statuses, { replace: true }) and changeConfig({ objectStatuses }) replace the table. The first table travels before the first paint, so buyers never see a booked seat as free; later updates repaint only the objects they name. A selected object that becomes unavailable leaves the selection and fires onSelectedObjectUnavailable. The table is kept by the loader and sent again whenever the viewer reloads (rerender).

Components and apps. React: <MapOnlyChart webBase chart workspaceKey objectStatuses … /> (a new objectStatuses prop replaces the table without a reload; the ref exposes setObjectStatuses and the full chart). Vue: <SeatChart :bring-your-own-backend="true" chart="main-hall" … :object-statuses="statuses" />. Angular: <seatschart-chart [bringYourOwnBackend]="true" chartKey="main-hall" [objectStatuses]="statuses"> (chartKey, because chart is the loader instance there). React Native: <SeatsChartView bringYourOwnBackend chart="main-hall" workspaceKey="pub_…" objectStatuses={statuses} /> and ref.current.setObjectStatuses(…).

Security and caching. The viewer reads only the public, CDN-cacheable map-only routes of your workspace: GET /api/public/{publicKey}/charts/{chart}/manifest (the published chart with every object free: catalog prices, general admission capacities, categories) and …/geometry/{hash} (immutable). No token is needed and none is sent; the iframe still only opens for your workspace's allowed embed origins, a deactivated workspace renders nothing, and drafts or archived charts are never served. No event data, holds or buyer data ever leave SeatsChart in this mode.

Billing. Map-only renders are allowed on every plan and count as used objects like seats.io: every object of the chart (seats and general admission areas) counts once per chart and calendar month (UTC), however many buyers open it; republishing the chart with more objects raises that month's count to the new size. They appear in Usage under the chart as Map-only renders (mapOnly: true in GET /api/usage, /usage/charts and /usage/events).