# SeatLayer Docs > Sell reserved-seat tickets with checkout, box office and door check-in, or add reserved seating to your platform with the SeatLayer SDK and API. Index: https://docs.seatlayer.io/llms.txt # Agent capabilities > Compare current Copilot and Designer MCP states, authority boundaries, and the evidence required before using each workflow. Source: https://docs.seatlayer.io/agents/capabilities/ · Markdown: https://docs.seatlayer.io/agents/capabilities/index.md Use this matrix before prompting a model or designing an agent workflow. The machine-readable source for the public release state is [`ai-capabilities.json`](https://seatlayer.io/ai-capabilities.json). | Capability | State | What is allowed | |---|---|---| | Copilot Ask | Released | Read-only answers about the open chart | | New conversation | Released | Start with fresh conversation context; chart is unchanged | | Coding-agent docs and toolkit | Released | Help implement SeatLayer in a repository using canonical docs | | Designer MCP | Released | Design charts and set up events through OAuth, on one chart or one workspace. No holds, bookings, or payments | | Copilot Edit | Released | Apply a reviewable revision to the open chart; publishing remains separate | | Multistep chart creation | Released | Create a reviewable draft from written requirements; never publish automatically | | Photo/reference creation | Released | Use a plan or reference image as input while disclosing uncertainty; never infer authoritative inventory | | AI-led 3D improvement | Off | Do not claim automatic height or 3D improvement | ## Authority does not move into the model | Concern | Authority | |---|---| | Authentication, role, workspace, and chart scope | SeatLayer API | | User confirmation and publish permission | SeatLayer API and human reviewer | | Tool availability and spend limits | SeatLayer product configuration | | Chart writes and canonical revision | SeatLayer chart service | | Idempotency, retry, and conflict handling | SeatLayer execution records | | Validation and rendered evidence | SeatLayer validators and renderers | | Usage, billing, and terminal state | Canonical SeatLayer records | | Narrative explanation and proposed plan | Copilot or connected model | A model cannot authorize itself, invent a user confirmation, mark an action complete, or publish by returning a persuasive message. ## Operating safeguards for write capabilities Released Edit and Create workflows keep these safeguards: 1. exactly-once or safely idempotent execution across retries and resumed runs; 2. revision conflict detection and a full-scope rollback plan; 3. renewed authorization and kill-switch checks when a run resumes; 4. deterministic completion validation against the canonical chart; 5. visible entitlement, token/model, rendering, and workspace spend controls; 6. privacy rules for prompts, references, traces, evidence, and retention; and 7. repeated real-user validation across representative chart types. AI-led 3D also needs actual WebGL before/after evidence from the same chart revision on desktop and mobile, plus a complete 2D fallback. Continue with [Copilot overview](/copilot/overview), [connect Codex](/agents/connect-codex), or [Designer MCP](/agents/designer-mcp). # Connect ChatGPT > Connect the SeatLayer Designer MCP to ChatGPT on the web or through Codex in the desktop app, and approve the OAuth grant. Source: https://docs.seatlayer.io/agents/connect-chatgpt/ · Markdown: https://docs.seatlayer.io/agents/connect-chatgpt/index.md ChatGPT on the web and Codex in the ChatGPT desktop app connect to MCP servers in different ways. Once connected, either can build and review seating charts and set up events in SeatLayer. Neither can hold, book, or sell seats; see [what the Designer MCP covers](/agents/designer-mcp/#what-it-covers). OpenAI documents its current setup in its [MCP documentation](https://learn.chatgpt.com/docs/extend/mcp). The server URL is on **Dashboard → Agent connections** in SeatLayer. In production it is `https://mcp.seatlayer.io/mcp`. For ChatGPT on the web, use `https://mcp.seatlayer.io/mcp?tools=build`: it lists the 63 tools for building, editing, pricing, checking and publishing, so each turn carries less than half the tool definitions. Add `,trace` to trace a floor-plan image. For Codex, use the plain URL. See [toolsets](/agents/designer-mcp/#toolsets). ## ChatGPT on the web ChatGPT web does not read a local Codex `config.toml`. It uses custom apps that a workspace admin allows. 1. A ChatGPT workspace admin turns on developer mode or custom MCP apps in the workspace settings. 2. Create a custom app and paste the SeatLayer server URL. 3. Connect the app. ChatGPT opens SeatLayer in your browser. 4. Sign in to SeatLayer and approve the grant (see [the consent screen](#the-seatlayer-consent-screen) below). 5. Turn the app on in a conversation, and review each confirmation before a write or publish action. Write-capable MCP apps on ChatGPT web depend on your ChatGPT plan and on OpenAI's rollout. That limit comes from ChatGPT, not SeatLayer. If your workspace cannot add the app, use Codex or Claude instead. ## ChatGPT desktop app with Codex The desktop app's Codex surface shares MCP configuration with the Codex CLI and IDE extension: 1. Open **Settings → MCP servers**. 2. Add a **Streamable HTTP** server named `SeatLayer` with the server URL. 3. Restart, choose **Authenticate**, and approve the grant in the browser. 4. Run `/mcp` to confirm the server is connected. Follow [connect Codex](/agents/connect-codex) for the CLI setup and a safe first prompt. ## The SeatLayer consent screen Whichever client you use, SeatLayer shows the same screen, and the person approving needs the SeatLayer admin role: - **Workspace**: the workspace the agent works in. - **One chart** or **Every chart in this workspace**. One chart is the default; a workspace grant lets the agent list and create charts there. - **Manage live events**: off by default. Turn it on only if the agent should close sections or block seats on events that are already selling. Never paste a SeatLayer secret key into ChatGPT. The connection uses OAuth tokens that expire hourly and refresh on their own. > **Same product boundary in every client** > > The client supplies the model. SeatLayer still owns OAuth scope, permissions, > chart writes, canonical revisions, validation, evidence, billing records, and > publish confirmation. Without a connection, ChatGPT can still write a [Venue Spec](/agents/venue-spec/#use-it-from-chatgpt) for you to paste into the Designer. Continue with [Designer MCP](/agents/designer-mcp) for the tool list and the chart-building workflow. # Connect Claude > Add the SeatLayer Designer MCP to Claude as a custom connector, approve the OAuth grant, and run a safe first prompt. Source: https://docs.seatlayer.io/agents/connect-claude/ · Markdown: https://docs.seatlayer.io/agents/connect-claude/index.md Claude and Claude Desktop can use remote MCP custom connectors on supported plans. Once connected, Claude can build and review seating charts and set up events in SeatLayer. It cannot hold, book, or sell seats; see [what the Designer MCP covers](/agents/designer-mcp/#what-it-covers). Anthropic describes connectors in its [custom connector guide](https://support.anthropic.com/en/articles/11175166-about-custom-integrations-using-remote-mcp). > **Plan and administrator controls apply** > > Availability depends on the Claude plan and workspace policy. On Team and > Enterprise, an Owner adds the connector for the organization first, then > each person connects it. ## Before you start - The person who approves the connection in SeatLayer needs the admin role. - Decide the scope: one chart, or every chart in one workspace. - Decide whether Claude may change live events. That permission is off by default. ## Add the connector 1. In SeatLayer, open **Dashboard → Agent connections** and copy the server URL. In production it is `https://mcp.seatlayer.io/mcp`. For chat, add `?tools=build` to the end: `https://mcp.seatlayer.io/mcp?tools=build`. Claude then sees the 63 tools for building, editing, pricing, checking and publishing, and each turn carries less than half the tool definitions. Use `?tools=build,trace` if Claude will trace a floor-plan image. See [toolsets](/agents/designer-mcp/#toolsets). 2. In Claude, open the **Connectors** settings. On Team or Enterprise, an Owner adds it under the organization's connector settings as a custom web connector. 3. Choose **Add custom connector**, name it `SeatLayer`, and paste the URL. 4. Select **Connect**. Claude opens SeatLayer in your browser. 5. Sign in to SeatLayer and review the consent screen: - pick the workspace; - choose **One chart** and select or create it, or choose **Every chart in this workspace**; - leave **Manage live events** off unless Claude should close sections or block seats on events that are already selling. 6. Approve. Claude now shows the SeatLayer tools. 7. Enable SeatLayer only in the conversations that need it, and keep tool approval on for writes. Do not configure this remote connector through a legacy local `claude_desktop_config.json` entry, and never paste a SeatLayer secret key into Claude. The connector uses OAuth tokens that expire hourly and refresh on their own. ## First safe prompt ```text Use the SeatLayer connector. Call get_capabilities and tell me which chart or workspace is authorized. Summarize the chart and list unresolved review questions. Do not create inventory, change the chart, approve, or publish. ``` The answer should name the chart or workspace you approved. If it names a different one, disconnect and connect again. ## Troubleshooting | What you see | What to do | |---|---| | The consent screen will not approve | Your SeatLayer role is not admin. Ask an admin to connect | | A tool you expected is missing | The grant does not include it, for example live event controls, or the `?tools=` set in the URL leaves it out. Reconnect with the right options or URL | | A write fails with a revision conflict | Someone else changed the chart. Ask Claude to read the chart again and retry | | Tools stop working after a while | Reconnect from Claude's connector settings to refresh the grant | To build a new venue from a description, Claude writes one [Venue Spec](/agents/venue-spec/) and builds it with `build_from_venue_spec`. Continue with [Designer MCP](/agents/designer-mcp) before enabling any write workflow. # Connect Codex > Add the SeatLayer Designer MCP to Codex in the app, IDE, or CLI, authenticate with OAuth, and verify the connection safely. Source: https://docs.seatlayer.io/agents/connect-codex/ · Markdown: https://docs.seatlayer.io/agents/connect-codex/index.md Codex can connect directly to a remote Streamable HTTP MCP server. The ChatGPT desktop app, Codex CLI, and Codex IDE extension share MCP configuration on the same machine. Once connected, Codex can build and review seating charts and set up events in SeatLayer. It cannot hold, book, or sell seats; see [what the Designer MCP covers](/agents/designer-mcp/#what-it-covers). OpenAI documents its configuration in its [MCP documentation](https://learn.chatgpt.com/docs/extend/mcp). > **Use the dashboard URL** > > Copy the server URL from **Dashboard → Agent connections** in the SeatLayer > environment you mean to use. In production it is > `https://mcp.seatlayer.io/mcp`. Do not copy a development URL into > production. ## Desktop app or IDE 1. Open **Settings → MCP servers**. 2. Select **Add server**. 3. Name it `SeatLayer`, choose **Streamable HTTP**, and paste the URL. 4. Save and restart the app or extension. 5. Select **Authenticate**. SeatLayer opens in your browser. 6. Approve the grant on the SeatLayer consent screen (below). 7. Use `/mcp` to confirm that the server is connected. ## CLI Add the remote server to `~/.codex/config.toml` or the trusted project's `.codex/config.toml`: ```toml [mcp_servers.seatlayer] url = "https://mcp.seatlayer.io/mcp" auth = "oauth" default_tools_approval_mode = "writes" ``` Then run `codex mcp login seatlayer` to authenticate and `codex mcp list` to check it. `default_tools_approval_mode = "writes"` keeps Codex asking before any tool that changes a chart. OpenAI documents the current configuration keys and CLI commands in its [MCP guide](https://learn.chatgpt.com/docs/extend/mcp). Do not place a SeatLayer secret API key in a prompt or configuration file. ## The SeatLayer consent screen The person approving needs the SeatLayer admin role. They choose: - the **Workspace** the agent works in; - **One chart** (default) or **Every chart in this workspace**; and - whether to allow **Manage live events**, which lets the agent close sections and block seats on events already on sale. It is off by default. Access tokens expire hourly and refresh on their own. ## Verify safely Start with a read and capability check: ```text Use the SeatLayer connector. Call get_capabilities first, state the authorized chart or workspace, and summarize the current chart. Do not change, approve, or publish anything. ``` The reply should name the chart or workspace you approved and list the tools the grant allows. Then follow the staged workflow in [Designer MCP](/agents/designer-mcp). Any write must use the current expected revision, and publishing still requires a human-approved unchanged revision. # Designer MCP > What the SeatLayer MCP server covers, every tool grouped by area, the OAuth boundary, and the staged workflow for building a chart. Source: https://docs.seatlayer.io/agents/designer-mcp/ · Markdown: https://docs.seatlayer.io/agents/designer-mcp/index.md The remote Designer MCP lets Claude, ChatGPT, another MCP host, or your own agent design seating charts and set up events in SeatLayer. SeatLayer supplies deterministic tools, validation, rendering, and review gates; the connected host supplies the model. ## What it covers | Covered | Not covered | |---|---| | Building and editing charts: sections, rows, seats, tables, booths, GA areas, floors, zones, text, shapes, categories, and theme | Holds, bookings, cancellations, or refunds | | Tracing a chart from a reference image, with confirmed counts and labels | Payments, checkout, or orders | | Validation, buyer previews, 3D structure, and sightline scores | Sales channels and buyer access | | Review and publication of an approved revision | Creating events or Seasons | | Event Configurations: create, review, publish, and bind to an event | Webhooks, API keys, members, and billing | | Live event controls, when allowed: open, close, or hide sections, block seats, and move an event to the latest chart | | Selling stays with the [Server API](/server-api/) and your checkout. An agent connected through this server can prepare and publish the map buyers use, but it cannot hold or sell a seat. **Server URL:** `https://mcp.seatlayer.io/mcp`, over Streamable HTTP. The dashboard's **Agent connections** page shows the URL for your environment. For chat hosts such as Claude and ChatGPT, use `https://mcp.seatlayer.io/mcp?tools=build`; see [toolsets](#toolsets). Choose a setup guide for [Codex](/agents/connect-codex), [Claude](/agents/connect-claude), or [ChatGPT](/agents/connect-chatgpt), and review the [current capability matrix](/agents/capabilities) before enabling tools. > **Environment-dependent access** > > Availability, connector URL, and publish permission are shown on the Agent > connections page in your SeatLayer dashboard. Do not copy a development URL > into production or present this workflow as a one-click image scanner. ## Security boundary A SeatLayer admin approves each connection on a consent screen and chooses what the agent can reach: - **One chart** (the default), or **every chart in this workspace**. A workspace connection can list, select, and create charts in that workspace only. - Chart work: build and edit, validate and review, and request publication. - **Manage live events** is off unless the admin turns it on. It allows closing sections and blocking seats on events that are already selling. The token cannot reach other workspaces, members, buyers, billing, or API keys. Event tools see only events created from the charts in scope. Access tokens expire after an hour and refresh tokens rotate. Each connection can make up to 300 tool calls per minute. Compatible hosts need: - remote Streamable HTTP MCP; - protected-resource discovery; - OAuth 2.1 authorization code flow with PKCE S256; - the `resource` parameter and rotating refresh tokens; and - MCP image content for reference and preview inspection. Never give the model a SeatLayer secret API key. ## Tools by area The server offers only the tools the connection is allowed to use and the [toolset](#toolsets) in its URL asks for, so a read-only, chart-only or `?tools=build` connection sees fewer. Call `get_capabilities` for the list your connection has. | Area | Tools | |---|---| | Start and navigate | `get_capabilities`, `get_chart` (a summary for big charts), `list_charts`, `select_chart`, `create_chart`, `get_venue_spec_format` | | Whole chart | `build_from_venue_spec` (a whole venue from one [Venue Spec](/agents/venue-spec/)), `build_simple_chart`, `build_semantic_venue`, `validate_current_chart`, `auto_fix_current_chart`, `render_preview`, `delete_chart_objects`, `manage_chart_floors` | | Rows and blocks | `add_row_block`, `add_curved_rows`, `insert_seating_block`, `update_rows`, `relabel_rows`, `merge_rows`, `split_row`, `set_row_spacing`, `update_row_block_style`, `group_segmented_rows`, `update_segmented_row`, `ungroup_segmented_row`, `fill_section_with_rows`, `fill_sections_with_rows`, `repeat_section_radially`, `mirror_section_assembly`, `divide_section_with_aisles` | | Seats | `get_row_seats`, `get_table_chairs`, `update_seats`, `reset_seats` | | Tables, booths, GA | `add_table_array`, `update_tables`, `add_booth_array`, `update_booths`, `add_ga_area`, `update_ga_areas`, `join_ga_areas` | | Sections and zones | `relabel_sections`, `update_sections`, `create_chart_zone`, `update_chart_zones`, `delete_chart_zone` | | Stage, landmarks and labels | `add_stage`, `add_landmark` (bar, entrance, exit, restrooms, screen and more, or a wayfinding icon), `add_text`, `add_area` (dance floor, desk) | | Categories and look | `update_chart_categories`, `create_chart_category`, `reorder_chart_categories`, `delete_chart_category`, `update_chart_theme`, `update_shapes`, `update_texts`, `update_stage_kinds`, `set_view_from_seat_fallback` | | Gates and step-free access | `list_gates`, `place_gate`, `update_gate`, `set_object_entrance`, `add_step_free_route` | | Section review | `get_section_work_queue`, `get_section_context`, `record_section_review` | | Reference images | `begin_vision_probe`, `confirm_vision_probe`, `upload_reference_image`, `list_reference_images`, `get_reference_set`, `configure_reference_set`, `set_active_reference`, `read_reference_image`, `get_reference_overview`, `get_reference_tile`, `classify_reference_source`, `get_reference_source_classification`, `get_reference_regions`, `get_reference_region`, `submit_region_semantics`, `list_unresolved_reference_questions`, `diff_vs_reference`, `restore_reference_sections` | | Blueprint and capacity | `propose_reference_blueprint`, `get_reference_blueprint`, `approve_reference_blueprint`, `compile_reference_skeleton`, `get_reference_capacity_draft`, `save_reference_capacity_draft`, `list_reference_capacity_questions`, `answer_reference_capacity_questions`, `plan_reference_capacity`, `render_reference_capacity_preview`, `apply_reference_capacity_plan`, `apply_reference_inventory` | | 3D and seat views | `get_venue_3d_structure`, `score_sightlines`, `apply_suggested_restrictions`, `set_confidence_evidence`, `clear_confidence_evidence`, `approve_confidence_evidence`, `retract_confidence_evidence`, `get_confidence_coverage`, `list_view_evidence_reports`, `create_panorama_manifest_job`, `get_panorama_manifest_job` | | Review and publish | `prepare_chart_review`, `get_chart_review`, `record_chart_review_decision`, `publish_reviewed_chart` | | Event Configurations | `list_event_configurations`, `get_event_configuration`, `create_event_configuration`, `update_event_configuration`, `submit_event_configuration`, `approve_event_configuration`, `publish_event_configuration`, `activate_event_configuration`, `rollback_event_configuration`, `set_shape_configurations` | | Events (read) | `list_chart_events`, `get_event_sections`, `get_event_section_inventory`, `get_event_configuration_binding`, `get_event_chart_freshness` | | Events (live controls) | `set_event_section_states`, `set_event_seat_blocks`, `update_event_chart`, `set_event_configuration_binding` | Live controls need **Manage live events**. They never change a hold or a booking: blocking and binding a configuration refuse the whole change if a seat is held or booked, and moving an event to the latest chart keeps every booking and hold. Publishing and approval tools need publish permission. Reference, blueprint, and capacity tools need the reference-chart feature, and [Event Configurations](/server-api/events/#event-configuration-binding) need that module on your account. ## Toolsets A host sends the name, description and inputs of every listed tool to the model on every turn. All 132 tools come to about 190 KB of definitions per turn. Most chat work (build a venue, change prices, add a bar, check and publish) needs far fewer, so the connection URL can pick a set: | URL | Tools listed | Covers | |---|---|---| | `https://mcp.seatlayer.io/mcp?tools=build` | 63, about 96 KB | Build, edit, price, check and publish charts, including `build_from_venue_spec`, the `add_` tools, `validate_current_chart`, `render_preview` and the review and publish tools | | `?tools=build,trace` | build plus 38 | Trace a floor-plan image section by section: reference images, regions, blueprints, capacity plans and section review | | `?tools=build,3d` | build plus 12 | Heights, sightlines and seat-view evidence | | `?tools=build,events` | build plus 19 | Events on sale and their Event Configurations, including live controls | | `?tools=all`, or the plain URL | All 127 | Everything. Connections made before toolsets keep every tool | Sets combine with commas, for example `?tools=build,trace,3d`. `trace`, `3d` and `events` always bring `build` with them. Unknown names are ignored. Sign-in works the same with the query on the URL. A toolset only changes which tools are listed; the grant approved on the consent screen still decides what a call may do. `get_capabilities` reports the sets. To add a set, reconnect with a different `?tools=` value. Use `?tools=build` in Claude and ChatGPT for everyday chart work. Use the plain URL in Codex, or add `,trace` when the agent will trace a floor plan. ## Pick a path `get_capabilities` returns a step-by-step playbook for each common job, and the server offers the same jobs as ready-made prompts that hosts such as Claude show in their prompt picker: | Job | Prompt | Starts with | |---|---|---| | A new venue from a description | `build_venue` | `get_venue_spec_format`, then `build_from_venue_spec` with `checkOnly: true`, then the build. See [Venue Spec](/agents/venue-spec/) | | A change to an existing chart | `edit_chart` | `get_chart`, then the matching `update_` tool | | A chart from a floor-plan image | `trace_floor_plan` | `begin_vision_probe`, then the reference steps below. Needs `?tools=build,trace` or the plain URL | | Checking and publishing | `check_and_publish` | `validate_current_chart`, `render_preview`, `prepare_chart_review` | Stages, landmarks such as bars, entrances and exits, text labels and named areas such as a dance floor are added with words: beside the layout, in its centre, or beside a named section, row or table. Gates and step-free routes have their own tools (see the table above). Not possible over MCP yet: drawing section outlines, or placing anything at an exact position. Agents are told to say so and send the user to the Designer for those steps. ## Tracing a floor plan, end to end 1. **Negotiate capabilities** Call `get_capabilities` and follow the features reported by this deployment instead of assuming a tool or schema from memory. 2. **Authorize a chart or workspace** Select an existing chart or create an empty draft during OAuth, or grant the workspace and let the agent list or create charts. 3. **Upload and classify private evidence** Keep immutable asset ids, inspect overview/detail tiles, classify every source, and configure one trace-ready geometry source plus role-limited supplemental evidence. 4. **Confirm semantics** Record region meaning, labels, counts, confidence, and open questions. Pixel inference remains evidence until an authoritative person/source confirms sellable facts. 5. **Compile deterministically** Select semantic intents and opaque region ids. SeatLayer code, not model coordinates, creates sections, paths, rows, aisles, and inventory. 6. **Render and refine** Validate every floor, inspect buyer previews and reference diffs, make a bounded change, and repeat. 7. **Review the exact revision** Prepare a packet bound to the current immutable chart hash. Inspect desktop and mobile buyer scenes plus reference evidence. 8. **Publish only an approved unchanged revision** Record the human decision. A `canPublish` session may publish only the exact approved revision; any change invalidates that approval. ## Why it is not an image converter A single vision response cannot guarantee: - authoritative seat count or labels; - correct accessibility and pricing; - containment-safe geometry; - readable buyer behavior at several zoom levels; - exact revision review; or - safe publication. The MCP therefore separates evidence, semantic confirmation, deterministic geometry, machine validation, visual review, and publication. ## Start with this prompt ```text title="designer-agent-prompt.txt" Use the SeatLayer Designer connector to build this seating chart with me. Start with get_capabilities and follow the staged reference workflow. Treat every uploaded image as evidence, not authoritative seat inventory. Inspect the overview and all required detail tiles before interpreting a dense plan. Record unresolved labels, counts, accessibility, and pricing as questions. Use semantic tools and SeatLayer's deterministic compilers; do not invent coordinates. After each bounded change, validate and render every floor. Show me the buyer preview and reference alignment evidence. Prepare the exact revision for review, but do not approve or publish without my explicit decision. ``` ## Agent implementation rules - Use the newest `expectedUpdatedAt` on mutations to avoid overwriting concurrent edits. - Split large semantic region sets into bounded, disjoint calls and read back the merged canonical state. - Do not let a supplemental source replace the configured geometry source. - Do not create inventory from unconfirmed image counts or labels. - Prefer semantic intents; public MCP tools intentionally exclude arbitrary geometry nudges and several Designer-only controls. - Treat machine validation as necessary but insufficient for visual approval. - Preserve published booking identities and safe-mode constraints. - Wait out a plan that is still being read (see below); never treat it as a failure. ## When a floor plan is still being read SeatLayer reads an uploaded plan in the background, and a first read takes up to a minute. Until it finishes, the tools that need the read answer with this error: ```json { "ok": false, "error": { "code": "reference_analysis_pending", "state": "reading", "retryable": true, "retryAfterSeconds": 5, "recoverWith": ["get_reference_regions"] } } ``` The tools that answer this way are `get_reference_regions`, `get_reference_region`, `submit_region_semantics`, `list_unresolved_reference_questions`, `diff_vs_reference`, `restore_reference_sections`, `propose_reference_blueprint` and `prepare_chart_review`. - Wait `retryAfterSeconds`, then call the same tool again. Nothing was changed, so the retry is safe. - `reference_analysis_failed` (with `retryable: false`) means the plan could not be read. Tell the person, and suggest uploading a clearer image or removing the plan. Don't loop on it. - HTTP callers get the same code in a `409` response, with a `Retry-After` header while the plan is being read. > **Upload first, then do other work** > > The read starts when the plan is uploaded. An agent that uploads, classifies > the source and prepares its questions before asking for regions usually > never sees the wait. ## Human review gate Before approval, verify: 1. every floor and buyer scene is present; 2. source overlays/diffs match the intended geometry; 3. labels, capacity, accessibility, categories, and prices are authoritative; 4. no private reference appears in buyer output; 5. mobile and desktop interactions are readable; 6. the revision hash still matches the prepared packet; and 7. the reviewer understands the effect on new and existing events. ## Definition of done - [ ] OAuth scope is the intended chart or workspace, and live event control is on only if needed. - [ ] Source classification and required tiles are complete. - [ ] All inventory facts are confirmed. - [ ] Deterministic validation passes on every floor. - [ ] Buyer 2D/3D and mobile/desktop evidence is reviewed. - [ ] Approval is attached to the exact unchanged revision. - [ ] Publication is an explicit human-authorized action. - [ ] The handoff records assumptions, open questions, and the published version. Continue with [reference plan import](/designer/reference-plan-import), [sections and seat plans](/designer/sections-and-seat-plans), or [publishing and versioning](/designer/publishing-and-versioning). # Integrate SeatLayer with an agent > A repository-first workflow and reusable instruction block for coding agents implementing SeatLayer safely in any application. Source: https://docs.seatlayer.io/agents/integrate-seatlayer/ · Markdown: https://docs.seatlayer.io/agents/integrate-seatlayer/index.md This workflow is for a coding agent working inside a user's project. It gives the agent enough SeatLayer context to make a correct plan while requiring it to adapt to the repository's existing architecture, checkout, tests, and UI. > **Use the portable skill** > > Install the [SeatLayer AI Toolkit](https://github.com/seatlayer/seatlayer-ai-toolkit) > to give Codex, Claude Code, GitHub Copilot, or another coding agent this > workflow plus read-only diagnostics and verification guidance. ## Give the agent these sources | Context | URL | |---|---| | Portable integration skill | `https://github.com/seatlayer/seatlayer-ai-toolkit/tree/main/skills/integrate-seatlayer` | | Documentation index | `https://docs.seatlayer.io/llms.txt` | | SDK release catalog | `https://docs.seatlayer.io/sdk-catalog.json` | | Full corpus when context allows | `https://docs.seatlayer.io/llms-full.txt` | | First integration | `https://docs.seatlayer.io/start/quickstart/index.md` | | Complete checkout | `https://docs.seatlayer.io/examples/complete-checkout/index.md` | | Production practices | `https://docs.seatlayer.io/integrations/best-practices/index.md` | | Exact SDK contract | `https://docs.seatlayer.io/buyer-sdk/seat-picker/index.md` | | Exact booking contract | `https://docs.seatlayer.io/server-api/booking/index.md` | Use one-page Markdown routes for focused work. Use the complete corpus for an agent that must choose between multiple integration surfaces. ## Copyable agent instruction ```text title="SEATLAYER_AGENT.md" Implement SeatLayer in this repository as a production integration. Read: - https://docs.seatlayer.io/llms.txt - https://docs.seatlayer.io/sdk-catalog.json - https://docs.seatlayer.io/start/choose-an-integration/index.md - https://docs.seatlayer.io/integrations/best-practices/index.md - https://docs.seatlayer.io/examples/complete-checkout/index.md Repository-first rules: 1. Read the repository's agent instructions and contribution guidance. 2. Inspect package manager, framework, routing, environment configuration, server/client boundary, payment/order code, error conventions, and tests. 3. Do not replace existing architecture or styling with a demo application. 4. State the chosen SeatLayer surface and why before editing. 5. Check sdk-catalog.json before naming a package version or choosing an SDK surface. 6. If a required business decision cannot be discovered, identify it clearly. SeatLayer invariants: - A chart is reusable geometry; an event has independent live inventory. - The browser selects and holds. A trusted server inspects and books. - SEATLAYER_SECRET_KEY is server-only. - Browser prices and checkout handoff values are not trusted payment input. - A chart-priced checkout uses fresh hold inspection line items. - A host-priced checkout recomputes its own price book on the server and records the SeatLayer inspected amount separately. - bookingRef is a stable host order id and is reused for retries. - HTTP 409 is a normal inventory conflict path. - A repeated successful booking can return booked: []. - Payment success and SeatLayer booking must have an explicit recovery policy. Implement: - the smallest complete integration in the existing application; - typed browser-to-server payloads; - server-only SeatLayer request helpers; - useful expired-hold and conflict UX; - idempotent order/booking coordination; - environment variable documentation; - tests matching this repository's conventions. Verify: - typecheck, tests, lint, and production build; - no secret appears in client output; - mobile, keyboard, loading, empty, expired, and error states; - test-mode select -> hold -> inspect -> book; - duplicate retry and payment/booking compensation behavior. Hand off: - changed files; - commands and results; - assumptions and unresolved production configuration; - manual end-to-end steps; - any follow-up for webhooks, observability, or deployment. ``` Save this block in the host repository only when the project owner wants a persistent agent instruction. Otherwise include it in the implementation task. ## Repository discovery checklist Before changing code, the agent should locate: - package manager and exact install command; - frontend entry point and client-only lifecycle boundary; - backend route/service pattern; - authenticated user and tenant context; - existing product/event/order identifiers; - payment provider and order state machine; - environment validation; - HTTP client and error normalization; - test fixtures and network mocking; - analytics adapter; - deployment platform and secret management. > **Do not generate a parallel app** > > The goal is to integrate SeatLayer into the user's product. A standalone demo > can look complete while bypassing their authentication, checkout, tenancy, > observability, and design system. ## Choose the implementation surface | Repository need | Agent should choose | |---|---| | Complete buyer flow and custom checkout | `SeatPicker` | | Fully custom cart and selection controls | `SeatingChart` | | Zero-code embed where the frame owns the whole journey | Hosted iframe | | Organizer chart editing inside a CMS | `EmbeddedDesigner` plus `dse_` session | | Operator live-event board | `SeatManager` plus `mse_` session | | Custom operator interface | `ManageApi` | | Multi-tenant provisioning | Workspaces plus server API | The agent must not choose the iframe for a buyer transaction. The production direct-book route is disabled, and the current iframe protocol covers resizing and fullscreen, not selection, hold, checkout, or booking messages. ## Required architecture ```text title="agent-target-architecture.txt" Browser SeatPicker / SeatingChart -> returns opaque holdId Host backend authenticate buyer authorize event and cart inspect hold with SEATLAYER_SECRET_KEY calculate trusted charge create/update host order coordinate payment book with stable bookingRef Webhook receiver verify raw-body signature deduplicate occurrenceId reconcile asynchronous facts ``` ## Test matrix | Case | Expected proof | |---|---| | Successful checkout | One payment/order and one booking reference | | Hold expired before payment | No charge; buyer can reselect | | Inventory conflict | No partial booking; useful recovery | | Duplicate request | Same booking reference; no duplicate charge | | Payment succeeds, booking conflicts | Explicit compensation/manual-recovery state | | Payment fails | Hold may expire/release; no booking | | Missing secret | Server startup or route fails safely | | Test/live mismatch | Clear configuration error | | Browser bundle scan | No secret-key value or server module | | Mobile and keyboard | Picker and surrounding checkout stay operable | ## Evidence the agent should return A strong handoff includes command output, not just “tests pass”; names the files changed; distinguishes automated from manual checks; reports skipped checks; and lists production values still needed without printing secrets. For chart-authoring agents, use the separate [Designer MCP workflow](/agents/designer-mcp). For product implementation, continue with [Build with agents](/agents/overview). # Build with agents > Give a coding agent the exact SeatLayer context, constraints, and verification criteria it needs to implement an integration. Source: https://docs.seatlayer.io/agents/overview/ · Markdown: https://docs.seatlayer.io/agents/overview/index.md You do not need to learn the Designer to make a seating chart. Describe the venue to any chat AI, or to Claude or ChatGPT connected to SeatLayer, and SeatLayer builds it: start with [your first chart with AI](/start/first-chart-with-ai/) and the [Venue Spec](/agents/venue-spec/) format behind it. SeatLayer publishes the same documentation in two forms: a designed site for people and clean Markdown for coding agents. You should not have to rewrite product knowledge into a prompt before an agent can help. For chart-aware product help, start with [Copilot overview](/copilot/overview). For an external agent, review the [capability matrix](/agents/capabilities), then use the client-specific setup for [Codex](/agents/connect-codex), [Claude](/agents/connect-claude), or [ChatGPT](/agents/connect-chatgpt). For a coding agent working inside your own repository, follow [integrate SeatLayer with an agent](/agents/integrate-seatlayer/). > **Best starting point for an agent** > > If the client supports remote MCP, connect the public, anonymous > `https://docs.seatlayer.io/mcp` endpoint. Otherwise give the agent > `/llms.txt`, the page for the task, and access to your repository. Ask it to > inspect your existing checkout architecture before it writes code. Call `product_overview` first when the agent needs the canonical short answer: what SeatLayer is, who it fits, embedded versus hosted ownership, the checkout trust boundary, current public SDK, pricing basis, agent surfaces, non-fit cases, and source URLs. Use the specialist tools only after that orientation. ## Install the SeatLayer AI Toolkit The open-source [SeatLayer AI Toolkit](https://github.com/seatlayer/seatlayer-ai-toolkit) packages the repository-first workflow as a portable skill. It adds focused live-doc routing, a read-only integration doctor, verification checklists, and optional Designer MCP configuration. The public Markdown documentation remains the source of truth. ### Codex ```bash git clone https://github.com/seatlayer/seatlayer-ai-toolkit.git cd seatlayer-ai-toolkit node scripts/install.mjs --target codex ``` Then ask Codex: `Use $integrate-seatlayer to add reserved seating to this repository.` ### Claude Code ```bash git clone https://github.com/seatlayer/seatlayer-ai-toolkit.git claude --plugin-dir /absolute/path/to/seatlayer-ai-toolkit ``` Start with `/seatlayer:setup`, then use `/seatlayer:integrate`. ### GitHub Copilot ```bash git clone https://github.com/seatlayer/seatlayer-ai-toolkit.git cd seatlayer-ai-toolkit node scripts/install.mjs --target github --project /path/to/project ``` ### Any agent Give the agent `https://docs.seatlayer.io/llms.txt` and the toolkit's `skills/integrate-seatlayer/SKILL.md`. Run the deterministic doctor without installing anything: ```bash node scripts/doctor.mjs /path/to/project ``` ## Agent-readable surfaces | Resource | Use it for | |---|---| | `/llms.txt` | Compact index with the right page for each task | | `/llms-full.txt` | Complete documentation for a large context window | | `/sdk-catalog.json` | Exact SDK packages, published versions, primary surfaces, and inventory-model support | | `//index.md` | Clean Markdown for one rendered page | | `/
/llms.txt` | All pages in one section | | `https://docs.seatlayer.io/mcp` | Public, anonymous, read-only product knowledge and documentation search | | `/.well-known/mcp.json` | Discovery list with the public knowledge MCP first and the authenticated Designer MCP second | | `/openapi.json` | OpenAPI 3.1 discovery description for the public server API | | `/.well-known/api-catalog` | RFC 9727 links to the API, OpenAPI description, docs, health, and MCP | | `/.well-known/mcp/server-card.json` | Singular Designer MCP card, capabilities, and OAuth discovery | | `/.well-known/agent-card.json` | Public read-only A2A documentation router | | `/.well-known/agent-skills/index.json` | Integrity-pinned SeatLayer integration skill | | `/auth.md` | Agent-readable OAuth registration and chart-scoped consent boundary | | [SeatLayer SDK](https://github.com/seatlayer/seatlayer-sdk) | Inspect supported JavaScript and React package source | | [SeatLayer for Flutter](https://pub.dev/packages/seatlayer) | Install the Flutter package; inspect source in [seatlayer/seatlayer-flutter](https://github.com/seatlayer/seatlayer-flutter) | | [SeatLayer iOS SDK](https://github.com/seatlayer/seatlayer-ios) | Integrate the SwiftUI/UIKit picker or raw Swift map API | | [SeatLayer React Native SDK](https://github.com/seatlayer/seatlayer-react-native) | Integrate the typed Expo, iOS, and Android picker or raw-map API | | [SeatLayer Android SDK](https://github.com/seatlayer/seatlayer-android) | Integrate the Compose/View picker, headless state, or raw Kotlin map API | | [SeatLayer AI Toolkit](https://github.com/seatlayer/seatlayer-ai-toolkit) | Install the portable agent workflow and diagnostics | | [SeatLayer GitHub](https://github.com/seatlayer) | Discover all public SeatLayer repositories | The Markdown pages are generated from the same source as the human docs. They do not drift into a separate, abbreviated “AI version.” Browsers that implement WebMCP also receive one read-only `find_seatlayer_resource` tool. It returns authoritative documentation URLs; it cannot authenticate, edit charts, change inventory, book seats, or publish. ## What an agent must understand first Before implementation, the agent should be able to state these invariants: 1. A **chart** is reusable venue geometry; an **event** is a published chart with independent live inventory. 2. The **browser holds** seats. **Your server books** them after it validates the hold and runs payment or order logic. 3. A secret key is server-only. It must never appear in a browser bundle, mobile app, log, screenshot, or generated example. 4. Prices and item details used for charging come from the server-side hold inspection, never from browser input. 5. `bookingRef` is a stable order reference and makes booking retries idempotent. 6. A `409` is a normal inventory conflict path and must have an intentional buyer experience. ## A prompt that produces a better implementation ```text title="seatlayer-agent-prompt.txt" Implement SeatLayer reserved seating in this repository. Read first: - https://docs.seatlayer.io/llms.txt - https://docs.seatlayer.io/start/quickstart/index.md - https://docs.seatlayer.io/examples/complete-checkout/index.md - https://docs.seatlayer.io/integrations/best-practices/index.md Before changing code: 1. Inspect the existing frontend, backend, checkout, payment, environment-variable, error-handling, and test patterns. 2. Explain where the SeatLayer browser picker and server booking call belong. 3. Identify any decision you cannot safely infer. Implementation constraints: - Browser holds; server books. - Never expose SEATLAYER_SECRET_KEY to client code. - Inspect the hold server-side and calculate the charge from its items. - Use the existing order id as bookingRef and safely retry with the same value. - Treat HTTP 409 as a recoverable inventory conflict. - Preserve the repository's framework conventions and styling. - Add tests for success, expired hold, booking conflict, duplicate retry, and missing server credentials. - Do not invent SDK methods or response fields. Use the documented HTTP contract. When finished: - Run the repository's typecheck, tests, lint, and build. - Report changed files, assumptions, and any production configuration still needed. ``` Replace the URLs with local Markdown routes if the agent can browse the running docs site. ## Recommended agent workflow 1. **Orient** Read the overview and inspect the host repository. Locate the checkout boundary, payment authority, order identifier, environment handling, and current tests. 2. **Plan against contracts** Name the browser-to-server payload, the server-to-SeatLayer requests, every credential location, and the failure behavior before editing. 3. **Implement the smallest complete loop** Render → select → hold → inspect → pay → book. Avoid unrelated refactors and avoid hiding essential behavior inside a demo-only abstraction. 4. **Test failure paths** Prove expired holds, `409` conflicts, payment failures, duplicate booking retries, and missing credentials, not only the happy path. 5. **Hand off** Return changed files, verification output, assumptions, environment variables, webhook work, and the exact manual journey a human should test. ## Designer agents The remote Designer MCP lets an authorized agent inspect a private venue reference, create deterministic geometry, validate the result, and submit evidence for review. > **Publication boundary** > > An agent may propose and edit chart geometry, but it does not publish automatically. Authoritative seat labels, capacity, accessibility data, pricing, and a human review are still required. To have any chat AI describe a venue as JSON that SeatLayer builds, see [Venue Spec](/agents/venue-spec/). Connect from **Dashboard → Agent connections**. The production endpoint is `https://mcp.seatlayer.io/mcp`, and an admin approves each connection for one chart or one workspace through OAuth 2.1 with PKCE. The server designs charts and sets up events; it does not hold, book, or sell seats. See [what the Designer MCP covers](/agents/designer-mcp/#what-it-covers). ## Definition of done An agent-assisted integration is not complete until: - the secret key is read only on the server; - the backend ignores buyer-supplied pricing; - the same `bookingRef` can be safely retried; - conflict and expiry paths return the buyer to a useful state; - responsive, keyboard, and mobile behavior have been checked; - test-mode inventory completed an end-to-end booking; - generated code follows the host application's conventions; and - the human owner can explain the resulting data flow. Continue with the [complete checkout example](/examples/complete-checkout) or the [platform integration guide](/integrations/platforms). # Venue Spec > A short JSON file that says what a venue has and where, in words. Any chat AI can write one; the Designer or the Designer MCP builds the chart from it. Source: https://docs.seatlayer.io/agents/venue-spec/ · Markdown: https://docs.seatlayer.io/agents/venue-spec/index.md A Venue Spec is a short JSON file that says what a venue has and where it is, in words. It lists ticket categories and prices, a stage, straight and curved rows, tables, standing areas, booths, landmarks, signs and named areas. SeatLayer places everything, so a Venue Spec never carries coordinates and an AI cannot draw overlapping seats. New to this? [Your first chart with AI](/start/first-chart-with-ai/) walks through each route step by step. The format is public: - JSON Schema (draft 2020-12): [`https://docs.seatlayer.io/schemas/venue-spec-v1.json`](/schemas/venue-spec-v1.json) - Worked examples: [theatre](/schemas/venue-spec-examples/theatre.json), [wedding](/schemas/venue-spec-examples/wedding.json), [club](/schemas/venue-spec-examples/club.json), [arena](/schemas/venue-spec-examples/arena.json), [expo](/schemas/venue-spec-examples/expo.json) and [comedy club](/schemas/venue-spec-examples/comedy.json). Each one is built and checked in SeatLayer's tests. ## Three ways to use it | You have | What to do | What happens | |---|---|---| | Any chat AI: ChatGPT, Claude, Gemini or another | In the Designer, open **New from → Paste from an AI…**, copy the prompt, describe your venue to your AI, and paste its reply back | The Designer checks the reply as you paste, shows a preview, and builds the chart when you choose **Build** | | Claude, ChatGPT or another MCP host connected to SeatLayer | Ask it to build the venue | The agent calls `build_from_venue_spec` and builds the whole venue in one call | | A `.json` file | Open **New from → Import venue file (JSON)…** | The same dialog opens with the file loaded. A full chart exported from SeatLayer opens there too | Building from a Venue Spec replaces the current draft. The result is a normal chart you can edit like any other. ## Paste from any AI This works without an account in the free demo Designer at [app.seatlayer.io/demo/designer](https://app.seatlayer.io/demo/designer), and in the Designer in your dashboard. 1. **1 · Ask your AI** Open **New from → Paste from an AI…** and choose **Copy prompt for your AI**. Paste it into your chat AI and replace the line in brackets with your venue in your own words. 2. **2 · Paste its reply** Paste the whole reply. You do not need to cut out the JSON: SeatLayer takes the code block, or the outermost braces if there is no code block. You can also choose **Choose file…** or drop a `.json` file on the box. 3. **3 · Check** The check runs as you paste. A good file shows a preview and a **Build N-place chart** button. A file with mistakes lists every problem by field. Choose **Copy fixes for your AI**, paste that into the same chat, and paste the corrected reply back. Building replaces the draft. One **Undo** brings the old draft back. Chart warnings do not stop the build; the Designer's chart checks list them afterwards. A venue with more places than your account's per-chart limit is refused before anything changes. ### The prompt to copy This is the text **Copy prompt for your AI** puts on your clipboard: ```text title="venue-spec-prompt.txt" Write a SeatLayer Venue Spec (JSON) for this venue: [describe your venue: size, layout, prices, bars, entrances] Rules: - Follow the JSON Schema at https://docs.seatlayer.io/schemas/venue-spec-v1.json exactly. Start with "seatlayerVenueSpec": 1. - Give "name", "categories" (ticket types with prices, most expensive first) and "items" listed from the stage outward. - Item types: rows, curvedRows, tables, standing, booths, landmark, text, area. Say where in words (front, rear, left, right); never give coordinates. - One category sells one kind of thing: row seats, table chairs, booths or standing. - Reply with only the JSON, in one code block. ``` A good description names the counts: how many rows and seats per row, how many tables and chairs, standing capacity, booth rows and columns, the prices, and where the bar, entrances and exits are. ## Build it over MCP When Claude, ChatGPT or another host is connected to the [Designer MCP](/agents/designer-mcp/), the agent can skip the copy and paste. The server's `build_venue` prompt starts here. 1. **Read the chart** `get_chart` returns `meta.updatedAt`, which the save needs. On a workspace connection, `create_chart` makes a new draft first. 2. **Read the format** `get_venue_spec_format` returns the JSON Schema, the rules, and the six worked examples. Pass `examples: "none"` or `"one"` for a shorter answer. 3. **Check without saving** `build_from_venue_spec` with `spec` and `checkOnly: true` returns a summary (seats, tables, booths, standing, sections, categories, capacity), notes, and validation errors and warnings. Nothing is saved. 4. **Build** Fix what the check listed, then call `build_from_venue_spec` again with `expectedUpdatedAt`. It replaces the draft. If the chart changed since it was read, the call returns `conflict`; read the chart again and retry. 5. **Validate and look** `validate_current_chart`, then `render_preview` on desktop and mobile. Both tools are in the `build` [toolset](/agents/designer-mcp/#toolsets), so a connection made with `https://mcp.seatlayer.io/mcp?tools=build` has them. `build_from_venue_spec` needs a connection that can edit the chart. `get_venue_spec_format` only reads. Every call to `build_from_venue_spec`, including `checkOnly`, counts toward 60 venue builds per hour per connection, shared with `build_semantic_venue`. After the build, the agent can keep editing with the usual tools, such as `update_rows`, `add_landmark` or `update_chart_categories`. ## The format ### Top-level fields | Field | Required | Rules | |---|---|---| | `seatlayerVenueSpec` | Yes | Always `1` | | `name` | Yes | Venue or chart name, 1 to 160 characters | | `categories` | Yes | 1 to 7 ticket types, most expensive first. Every name must be different | | `items` | No | Up to 80 items, listed from the stage outward | | `stage` | No | Leave out for a plain stage. `false` for no stage. Not used with `base` | | `base` | No | Start from a ready-made venue, then add items. For arenas and several floors | | `$schema` | No | The schema URL | Each entry in `categories`: | Field | Required | Rules | |---|---|---| | `name` | Yes | For example `"Premium"` or `"Standing"`, up to 80 characters | | `price` | No | 0 to 1,000,000, in the event currency. Leave out to set it later | | `color` | No | Six-digit hex such as `#3b82f6`. Leave out for a readable default | `stage`, when it is an object (all fields optional): | Field | Rules | |---|---| | `kind` | `rect` (default), `rounded`, `arc`, `thrust`, `runway`, `round`, or a sports surface: `pitch-football`, `rink-hockey`, `track-athletics`, `oval-cricket`, `pitch-rugby`, `diamond-baseball`, `court-basketball`, `court-tennis`, `ring-boxing` | | `label` | Its name, for example `"Main Stage"` | | `widthM`, `depthM` | Size in metres, 1 to 400 | ### Items Every item has a `type`. Fields marked **required** must be present; the rest have the default shown or can be left out. Names such as `label`, `section` and `category` are up to 80 characters. **`rows`**: straight rows, optionally split into side-by-side blocks with aisles between them. | Field | Rules | |---|---| | `rowCount` | **Required.** 1 to 100 | | `seatsPerRow` | **Required.** 1 to 300, seats in each whole row, counted across all blocks. 24 with `blocks: 2` is two blocks of 12; an odd seat goes to the first block | | `blocks` | 1 to 8, default 1. `2` gives one centre aisle | | `category`, `placement`, `section` | See [placement and names](#placement-and-names) | **`curvedRows`**: rows that curve around the stage, as in a theatre. They go behind any seats already placed, so they take no `placement`. | Field | Rules | |---|---| | `rowCount` | **Required.** 1 to 60 | | `spreadDegrees` | 20 to 360. 100 is a theatre, 180 a semicircle, 360 all the way round. Left out, it is just wide enough for `seatsPerRow` (or 100 without it). With both set, the rows sit far enough from the stage for the seats to fit | | `seatsPerRow` | 2 to 300. Leave out to fit seats at the standard spacing, so outer rows get more | | `aisles` | `none` (default), `center` or `two` | | `firstRowDistanceM` | 1 to 200, default 4. Metres from the stage to the first row when nothing is there yet | | `category`, `section` | See [placement and names](#placement-and-names) | **`tables`**: a grid of tables with chairs. | Field | Rules | |---|---| | `tableRows` | **Required.** 1 to 20 | | `tableColumns` | **Required.** 1 to 20, tables in each row | | `seatsPerTable` | 1 to 20, default 8 | | `shape` | `round` (default) or `rect` | | `category`, `placement`, `section` | See [placement and names](#placement-and-names) | **`standing`**: a general admission area sold by capacity. | Field | Rules | |---|---| | `capacity` | **Required.** 1 to 100,000 | | `label` | Default `"General Admission"` | | `category`, `placement` | See [placement and names](#placement-and-names) | **`booths`**: a grid of expo or market booths, each sold whole. | Field | Rules | |---|---| | `boothRows` | **Required.** 1 to 30 | | `boothColumns` | **Required.** 1 to 30, booths in each row | | `category`, `placement`, `section` | See [placement and names](#placement-and-names) | **`landmark`**: something buyers find their way by. Give exactly one of `role` or `icon`. | Field | Rules | |---|---| | `role` | A drawn landmark: `bar`, `entrance`, `exit`, `restroom`, `screen`, `sound`, `concession`, `coat`, `wall`, `rail`, `suite`, `obstruction` | | `icon` | A small wayfinding icon: `restroom-men`, `restroom-women`, `restroom-accessible`, `restrooms`, `first-aid`, `coat-check`, `atm`, `info`, `lost-found`, `charging`, `smoking`, `no-smoking`, `food`, `bar`, `coffee`, `water`, `merch`, `screen`, `sound-booth`, `entrance`, `exit`, `emergency-exit`, `stairs`, `elevator`, `parking`, `wheelchair`, `hearing`, `box-office`, `escalator`, `ramp`, `taxi`, `transit`, `bike-parking`, `meeting-point`, `security`, `lockers`, `baby-change`, `prayer-room`, `quiet-room`, `wifi`, `vip-lounge`, `accessible-entrance`, `assistance-dog` | | `label` | Its name on the map, for example `"Main bar"` | | `placement`, `near`, `side` | See [placement and names](#placement-and-names) | **`text`**: a sign on the map, such as `"Gate C"`. | Field | Rules | |---|---| | `text` | **Required.** Up to 120 characters | | `size` | `small`, `medium` (default) or `large` | | `showAt` | `always`, `when-it-fits` (default) or `close-up` | | `placement`, `near`, `side` | See [placement and names](#placement-and-names) | **`area`**: a named space nobody books, such as a dance floor, registration desk, mixing desk or lounge. | Field | Rules | |---|---| | `label` | **Required.** Its name on the map | | `widthM`, `depthM` | **Required.** 0.5 to 400 metres | | `shape` | `rect` (default) or `ellipse` | | `placement`, `near`, `side` | See [placement and names](#placement-and-names) | ### Placement and names | Field | Used by | Rules | |---|---|---| | `placement` | `rows`, `tables`, `standing`, `booths` | `front` (toward the stage), `rear` (default), `left` or `right`, against everything placed so far. Two `rear` items stand one behind the other; use `near` to put one next to another | | `placement` | `landmark`, `text`, `area` | The same, plus `center`. `front` puts it between the stage and the seats | | `near` | `landmark`, `text`, `area` | Place it next to something named by an earlier item: a section, landmark, area or standing area, for example `"Bar"` | | `nearSection` | `landmark`, `text`, `area` | Older name for `near`, for a section | | `side` | `landmark`, `text`, `area` | Which side of `near`: `front`, `rear` (default), `left` or `right`. Only with `near` | | `section` | `rows`, `curvedRows`, `tables`, `booths` | Put them in a section with this name, as buyers will see it, for example `"Stalls"`. When the venue has sections, tables and booths without one get a section named after their category: phones open the map on section blocks, so anything outside a section would not show there | | `category` | `rows`, `curvedRows`, `tables`, `standing`, `booths` | The name of one entry in `categories`. Can be left out only when there is one category | ### `base`: a ready-made venue Use `base` for an arena bowl or a venue with several floors. It brings its own stage, so leave `stage` out and set `base.stageKind` instead. Categories are shared across the generated blocks in order. Add more with `items`. | Field | Rules | |---|---| | `layout` | **Required.** `rows` (theatre), `bowl` (arena ring of stands), `tables` (banquet), `ushape`, `booths` (expo), `nightclub` (standing and VIP), `runway`, `restaurant`, `hybrid` (seats and standing) | | `rowCount` | 1 to 40, rows per block or stand | | `seatsPerRow` | Seats in each row: the whole row across all blocks for rows and hybrid (it must split evenly, at most 100 per block), one stand's row for bowl and runway | | `blocks` | 1 to 8, side-by-side blocks | | `sectionCount` | 2 to 16, stands around the ring. Default 4 | | `tableRows`, `tableColumns` | 1 to 20 | | `boothRows`, `boothColumns` | 1 to 30 | | `standingCapacity` | 1 to 100,000 | | `floors` | 1 to 5, default 1 | | `stageKind` | `rect` (default), `rounded`, `arc`, `thrust`, `runway`, `round` | | `sectionNames` | Names for the generated sections, clockwise from the stage end. For a four-stand bowl: North, East, South, West | Each layout reads only some fields, and a field the layout does not read is reported as a problem: | Layout | Reads | |---|---| | `rows` | `rowCount`, `seatsPerRow`, `blocks`, `floors` | | `bowl` | `rowCount`, `seatsPerRow`, `sectionCount`, `standingCapacity`, `floors` | | `tables`, `ushape` | `tableRows`, `tableColumns` | | `booths` | `boothRows`, `boothColumns` | | `nightclub` | `standingCapacity` | | `runway` | `rowCount`, `seatsPerRow` | | `restaurant` | nothing extra | | `hybrid` | `rowCount`, `seatsPerRow`, `blocks`, `standingCapacity` | Every layout also reads `layout`, `stageKind` and `sectionNames`. The `nightclub`, `runway` and `restaurant` layouts, and a `bowl` with `standingCapacity`, need at least two categories: the last one sells the standing places (the bar stools, for `restaurant`). ## The rules 1. **List items from the stage outward.** Each item is placed beside everything placed before it, behind by default. 2. **Say where in words.** Use `placement`, `near` and `side`. There are no coordinates in the format. 3. **One category sells one kind of thing:** row seats (`rows` and `curvedRows`), table chairs, booths, or standing. If the same price covers tables and rows, make two categories. 4. **Name the category on every sellable item** when there is more than one category. 5. **Use `base` for arenas and several floors,** then add items. 6. **Without `base`, a plain stage is added** unless `stage` is `false`, as in an expo hall. ## Example: a 400-seat theatre Two blocks of stalls with a centre aisle, curved rear stalls with two aisles, two prices, and exits at the sides. ```json title="theatre.json" { "$schema": "https://docs.seatlayer.io/schemas/venue-spec-v1.json", "seatlayerVenueSpec": 1, "name": "Riverside Theatre", "categories": [ { "name": "Premium", "price": 80 }, { "name": "Standard", "price": 55 } ], "stage": { "kind": "arc", "label": "Stage" }, "items": [ { "type": "rows", "rowCount": 10, "seatsPerRow": 24, "blocks": 2, "category": "Premium", "section": "Stalls" }, { "type": "curvedRows", "rowCount": 5, "seatsPerRow": 32, "spreadDegrees": 90, "aisles": "two", "category": "Standard", "section": "Rear Stalls" }, { "type": "landmark", "role": "exit", "label": "Exit", "placement": "left" }, { "type": "landmark", "role": "exit", "label": "Exit", "placement": "right" }, { "type": "landmark", "role": "entrance", "label": "Main entrance", "placement": "rear" } ] } ``` The other examples: | Example | What it shows | |---|---| | [wedding.json](/schemas/venue-spec-examples/wedding.json) | 20 round tables of 10, a dance floor, a bar and restroom icon, one category | | [club.json](/schemas/venue-spec-examples/club.json) | A 500-person dance floor and VIP tables, each with its own category | | [arena.json](/schemas/venue-spec-examples/arena.json) | `base` with a four-stand bowl, named stands and a 2,000-person floor | | [expo.json](/schemas/venue-spec-examples/expo.json) | 40 booths, `stage: false`, a registration desk beside the entrance (`near`) | | [comedy.json](/schemas/venue-spec-examples/comedy.json) | Cabaret tables near the stage and rows behind, in two categories | ## When a file has mistakes The Designer and the MCP server report every problem they find at once, each with the field that needs fixing. The shape of the file is checked first: unknown fields, missing fields, and values out of range. Once the shape is right, category names are checked. A fixed file can therefore show a second, shorter list. Real messages: | Field | Message | What it means | |---|---|---| | `items[0].rows` | is not a field here. Did you mean "rowCount"? | The AI used a word the format does not have. The checker suggests the closest field | | `items[0].type` | must be one of rows, curvedRows, tables, standing, booths, landmark, text, area, not "row". Did you mean "rows"? | The item type is misspelled | | `items[2].category` | "Gold" is not in categories. Use one of: Premium, Standard, or add it to categories | The item names a category the file does not define | | `items[1].category` | "Premium" already sells row seats (items[0]); table chairs need a category of their own. … | Rows and tables share a category. Add a second one, such as "Premium Tables" | In the Designer, **Copy fixes for your AI** copies the list as a message the AI can act on: ```text title="copied-fixes.txt" SeatLayer could not build this venue file yet. Please fix these problems and reply with the whole corrected JSON in one code block: - items[0].type: must be one of rows, curvedRows, tables, standing, booths, landmark, text, area, not "row". Did you mean "rows"? The format is at https://docs.seatlayer.io/schemas/venue-spec-v1.json ``` Over MCP, `build_from_venue_spec` returns the same list: ```json title="build_from_venue_spec error" { "ok": false, "error": { "code": "invalid_venue_spec", "message": "The spec has 2 problems; fix each field listed and try again", "issues": [ { "path": "items[1].category", "message": "\"Premium\" already sells row seats (items[0]); table chairs need a category of their own. Add one to categories, for example {\"name\": \"Premium Tables\"}" }, { "path": "items[2].category", "message": "\"Gold\" is not in categories. Use one of: Premium, Standard, or add it to categories" } ], "format": "get_venue_spec_format" } } ``` ## Use it from Claude Claude connected to SeatLayer through the [Claude connector](/agents/connect-claude/) builds the venue over MCP. Without the connector, Claude can still write a Venue Spec for you to paste into the Designer. The [SeatLayer Venue Spec skill](/skills/seatlayer-venue-spec/SKILL.md) teaches Claude the format, the rules and both paths. Its six examples are in [examples.md](/skills/seatlayer-venue-spec/examples.md). Save both files in a folder named `seatlayer-venue-spec`. In Claude Code, put the folder in `~/.claude/skills/` or in your project's `.claude/skills/`. In the Claude apps, add it as a custom skill where your plan supports skills. ## Use it from ChatGPT Paste the text below into a Custom GPT's **Instructions**, or into a ChatGPT Project's instructions. ChatGPT will then answer a venue description with a Venue Spec you can paste into **New from → Paste from an AI…**. The text is under 8,000 characters. ```text title="chatgpt-instructions.txt" You write SeatLayer Venue Specs: short JSON files that describe a venue's seating. SeatLayer builds a seating chart from the file. When the user describes a venue, reply with one Venue Spec in a single json code block. After it, add one line: "In the SeatLayer Designer, choose New from, then Paste from an AI, and paste this whole reply." Ask a question first only if you cannot tell how many seats, tables, booths or standing places there are. Format (JSON Schema): https://docs.seatlayer.io/schemas/venue-spec-v1.json Examples: https://docs.seatlayer.io/schemas/venue-spec-examples/theatre.json (also wedding.json, club.json, arena.json, expo.json, comedy.json) RULES - Start with "seatlayerVenueSpec": 1. Give "name" and "categories", and usually "items". The only other top-level fields are "$schema", "stage" and "base". - Never give coordinates or positions as numbers. Say where in words. SeatLayer places everything. - categories: 1 to 7 ticket types, most expensive first. Each is {"name"}, with an optional "price" (a plain number, no currency symbol) and optional "color" ("#rrggbb"). Names must differ. - One category sells one kind of thing: row seats (rows, curvedRows), table chairs (tables), booths, or standing. If one price covers two kinds, make two categories. - With more than one category, every rows, curvedRows, tables, standing and booths item needs "category" set to one of the category names. - List items from the stage outward. Each item goes against everything placed before it: "placement" is front (toward the stage), rear (the default), left or right. Two rear items stand one behind the other; to put one next to another, give near (a name used earlier) and side. - stage: leave it out for a plain stage; false for no stage (an expo hall); or {"kind", "label", "widthM", "depthM"}. kind is rect, rounded, arc, thrust, runway, round, or a sports surface: pitch-football, rink-hockey, track-athletics, oval-cricket, pitch-rugby, diamond-baseball, court-basketball, court-tennis, ring-boxing. - For an arena bowl or several floors, use "base" instead of "stage", then add items. ITEM TYPES (* = required) - rows: rowCount* (1-100), seatsPerRow* (1-300, the whole row across all blocks), blocks (1-8; 2 = one centre aisle), category, placement, section. 24 seats with blocks 2 is two blocks of 12. - curvedRows: rowCount* (1-60), spreadDegrees (20-360; 100 theatre, 180 semicircle, 360 all round), seatsPerRow (2-300; leave out to fit), aisles (none, center, two), firstRowDistanceM (1-200), category, section. Always placed behind the seats already there; no placement. - tables: tableRows* (1-20), tableColumns* (1-20), seatsPerTable (1-20, default 8), shape (round, rect), category, placement, section. - standing: capacity* (1-100000), label, category, placement. - booths: boothRows* (1-30), boothColumns* (1-30), category, placement, section. When the venue has sections, tables and booths without one get a section named after their category, so phones show them. - landmark: exactly one of role or icon; label; placement; near; side. role: bar, entrance, exit, restroom, screen, sound, concession, coat, wall, rail, suite, obstruction. icon: restroom-men, restroom-women, restroom-accessible, restrooms, first-aid, coat-check, atm, info, lost-found, charging, smoking, no-smoking, food, bar, coffee, water, merch, screen, sound-booth, entrance, exit, emergency-exit, stairs, elevator, parking, wheelchair, hearing, box-office, escalator, ramp, taxi, transit, bike-parking, meeting-point, security, lockers, baby-change, prayer-room, quiet-room, wifi, vip-lounge, accessible-entrance, assistance-dog. - text: text* (up to 120 characters), size (small, medium, large), showAt (always, when-it-fits, close-up), placement, near, side. - area: label*, widthM* and depthM* (0.5-400 metres), shape (rect, ellipse), placement, near, side. For spaces nobody books: dance floor, registration desk, mixing desk, lounge. - landmark, text and area also take placement "center". near is the name of something made earlier (a section, landmark, area or standing area); side is then front, rear, left or right of it. - Names (label, section, category, near) are up to 80 characters. BASE (instead of stage) - layout* is one of rows, bowl, tables, ushape, booths, nightclub, runway, restaurant, hybrid. - Fields each layout reads. rows: rowCount, seatsPerRow, blocks, floors. bowl: rowCount, seatsPerRow, sectionCount, standingCapacity, floors. tables and ushape: tableRows, tableColumns. booths: boothRows, boothColumns. nightclub: standingCapacity. runway: rowCount, seatsPerRow. restaurant: none. hybrid: rowCount, seatsPerRow, blocks, standingCapacity. Every layout: stageKind, sectionNames. Never give a field the layout does not read. - Limits: rowCount 1-40, seatsPerRow 1-100, blocks 1-8, sectionCount 2-16 (default 4), tableRows and tableColumns 1-20, boothRows and boothColumns 1-30, standingCapacity 1-100000, floors 1-5. - stageKind: rect, rounded, arc, thrust, runway, round. - sectionNames name the generated sections clockwise from the stage end, for example North Stand, East Stand, South Stand, West Stand. - nightclub, runway, restaurant, and bowl with standingCapacity need at least two categories; the last one sells standing (bar stools for restaurant). FIXING If the user pastes back a list of problems from SeatLayer, fix every field it names and reply with the whole corrected JSON in one code block. LIMITS A Venue Spec cannot set exact positions, draw section outlines, add gates or step-free routes as their own objects, or trace a floor-plan image. Say so plainly and tell the user to do those steps in the SeatLayer Designer. ``` ChatGPT can also build the chart directly when SeatLayer is connected as an MCP app. See [connect ChatGPT](/agents/connect-chatgpt/). ## What it cannot do yet - Put anything at an exact position. Placement is always in words. - Draw section outlines or other free shapes. - Add gates or step-free routes. Add them after the build: in the Designer, or over MCP with `place_gate` and `add_step_free_route`. - Trace a floor-plan image. Use the [trace workflow on the Designer MCP](/agents/designer-mcp/#tracing-a-floor-plan-end-to-end) or the Designer's [reference plan import](/designer/reference-plan-import/). Do these steps in the Designer after the build. # Credits model > Learn exactly when Platform/SDK seat credits are granted, spent, replayed, refunded, and allowed to go negative. Source: https://docs.seatlayer.io/billing/credits/ · Markdown: https://docs.seatlayer.io/billing/credits/index.md This page describes the **Platform/SDK seat-credit wallet**. SeatLayer keeps one append-only Platform/SDK ledger per organization. The current balance is the fast read; ledger deltas are the reconstructable history. Hosted Ticketing uses a separate Hosted Ticket wallet: $0.50 per confirmed Hosted Ticket. Hosted Ticket credits and Platform/SDK seat credits cannot be exchanged. See [pricing](/billing/pricing/#managed-ticketing), including how free events use Hosted Tickets. ## Metering boundary A credit is spent only when inventory is newly booked, and a booking is never charged twice. ```text title="credit-lifecycle.txt" free -> held no credit held -> released no credit free -> blocked no credit blocked -> free no credit held/free -> booked debit newly sold guest quantity booked -> free credit is not returned free -> booked again no second debit for already-metered inventory ``` An idempotent retry with the same booking reference is not a new sale. ## Wallet lifetime Purchased credits remain in the organization wallet until used; they do not expire at a month or contract boundary. If no inventory is newly booked, no sold-seat credits are consumed. Any future promotional grant must state its own eligibility and expiry terms and is not part of the standard pricing contract. ## Ledger entry kinds | Kind | Meaning | |---|---| | `spend` | Newly sold guest capacity | | `free_grant` | The 100-credit monthly allowance, granted once per calendar month | | `purchase` | Verified prepaid-pack purchase | | `refund` | Explicit credit reversal | | `admin_adjust` | Controlled account correction | | `subscription_grant` | Legacy grant retained for historical ledgers | Spend entries include event id and guest quantity. Purchase entries include the Stripe reference. Each entry snapshots `balanceAfter`. ## Negative balances A sale that has already committed is not rolled back because of billing. The wallet may become negative after a burst of bookings. Creating a new live event requires a positive balance. Existing events continue selling. Test-mode event creation and booking do not consume credits. > **Monitor before on-sale** > > A low balance does not act as a strict per-booking circuit breaker. Alert and > top up before a large on-sale rather than relying on an automatic stop. ## Cancellation and a later ordinary sale For Platform/SDK events, `unbook` changes event inventory and can reduce current booked value in inventory reports, but it does not refund the credit. Refund customer money in your payment system. Hosted Ticketing events instead use the coordinated SeatLayer Order/refund workflow; generic `unbook` is not available for them. The same inventory can later be sold through a new ordinary booking without another credit because metering remembers that guest unit has already left free inventory through a successful sale. ## Resale [Resale](/server-api/inventory/#resale) uses no extra credits. Listing or delisting a seat is free, and when a listed seat sells to a new buyer it is not metered again, because the seat already used its credit on the first sale. SeatLayer moves the seat to the new booking; the resale price, payment, and payout to the original holder stay in your system. ## Billing API boundary Billing endpoints are dashboard-session authenticated rather than general secret-key Server API endpoints: | Route | Purpose | |---|---| | `GET /v1/billing` | Balance, sold count, and recent ledger | | `GET /v1/billing/packs` | Current server-authoritative pack catalog | | `POST /v1/billing/checkout` | Admin-only hosted purchase for a `packId` | | `GET /v1/billing/purchases/:stripeRef/invoice-url` | Org-scoped hosted invoice | Do not expose internal development adjustment tools as a production integration. ## Reconciliation checklist - [ ] Count guest quantity for groups, GA, booths, and variable-occupancy tables. - [ ] Keep booking references stable across retries. - [ ] Separate SeatLayer credit spend from ticket money and payment settlement. - [ ] Expect cancellation to leave the original credit spent. - [ ] Group financial reporting by currency and source. - [ ] Store event and workspace ids with host tenant records. - [ ] Monitor low and negative balance before provisioning the next live event. See [pricing](/billing/pricing), [booking idempotency](/server-api/idempotency-and-conflicts), and [reports](/server-api/reports). # Pricing > Understand SeatLayer sold-seat credits, Hosted Ticketing pricing, non-expiring prepaid packs, and account-level add-ons. Source: https://docs.seatlayer.io/billing/pricing/ · Markdown: https://docs.seatlayer.io/billing/pricing/index.md For Platform and SDK integrations, SeatLayer uses a prepaid credit wallet. One credit is consumed when one guest place is first booked; rendering, holds, releases, and organizer blocking do not consume credits. Purchased credits remain in the organization wallet until used. They do not expire at the end of a month or contract period. If no guest places are sold, no sold-seat credits are consumed. No card is required to create charts and test the integration. Promotional credits are not part of the standard published pricing contract unless a specific active offer states its eligibility and expiry terms. ## Prepaid SDK packs | Pack | Price | Effective price | |---:|---:|---:| | 500 credits | $50 | 10.0¢ per credit | | 1,000 credits | $90 | 9.0¢ per credit | | 5,000 credits | $400 | 8.0¢ per credit | | 25,000 credits | $1,500 | 6.0¢ per credit | | 50,000 credits | $2,500 | 5.0¢ per credit | | 100,000 credits | $5,000 | 5.0¢ per credit | SDK pack prices are in USD. The server catalog, not a browser-submitted amount, is authoritative for checkout. This v2 catalog applies to new checkouts; completed purchases, existing balances, and already-created checkout sessions keep the price and credits recorded when they were created. Every Platform/SDK organization also receives **100 free SDK credits each month**; Hosted Ticket wallets have no free allowance. The allowance renews on the first of the month and is not a trial. It does not run out after a starter period. Beyond it you can buy a smaller pack for a quiet season, keep unused purchased credits indefinitely, and top up again only when your sold-seat volume requires it. > **Organization wallet** > > Workspaces isolate charts and events, but credits and purchases belong to the > organization. There is no separate wallet per tenant workspace. ## Hosted Ticketing Hosted Ticketing is self-serve for every organizer and venue account: connect your own payment gateway on the **Payments** page. Fourteen gateways are supported. Stripe and Razorpay connect straight away; PayPal, Square, Braintree, Authorize.Net, Checkout.com, Mollie, Tap, Mercado Pago, Xendit, Flutterwave, Paystack and iyzico are switched on for your account on request. See [Connect payments](/sell/connect-payments/). It uses a separate Hosted Ticket wallet. Each confirmed Hosted Ticket costs **$0.50**. Every new organization gets **25 Hosted Tickets** once, free, to start with. ### Hosted Ticket packs | Pack | Price | |---:|---:| | 100 Hosted Ticket credits | $50 | | 500 Hosted Ticket credits | $250 | | 1,000 Hosted Ticket credits | $500 | | 5,000 Hosted Ticket credits | $2,500 | | 10,000 Hosted Ticket credits | $5,000 | Every pack works out to $0.50 per ticket; there is no volume discount. Packs are priced and invoiced in USD. An admin buys them in **Settings → Billing** under **Buy credits**, after choosing the Hosted Ticket balance. Test events never use Hosted Ticket credits. Hosted Ticket credits and Platform/SDK seat credits are separate and cannot be exchanged. The organizer's connected payment provider controls processing fees, applicable taxes, and payout timing; those amounts are separate from the Hosted Ticket price. ### Free events A free Managed event has no payment step and needs no connected payment account. Buyers receive the same tickets, QR codes, PDF, and confirmation email as a paid event. Each confirmed ticket uses one Hosted Ticket ($0.50), the same meter as a paid Hosted Ticket. Checkout is refused when the Managed wallet does not have enough Hosted Tickets for the booking. ## What consumes a credit | Operation | Credits | |---|---:| | Render a chart or picker | 0 | | Create, replace, extend, release, or expire a hold | 0 | | Best-available hold | 0 | | Block or unblock inventory | 0 | | First successful booking of guest capacity | 1 per newly sold place | | Idempotent booking replay | 0 additional | | Cancellation | No refund of the original credit | | New ordinary booking after cancellation/unbook of already-metered inventory | 0 additional | | Test-mode booking | 0 | Grouped objects and variable occupancy are guest-weighted. A five-person table booked for five guests consumes five credits, not one object credit. ## Features and add-ons Core Designer, buyer SDK, Server API, webhooks, workspaces, and embed surfaces use the same usage wallet. White label is not an add-on: removing “Powered by SeatLayer” is included with your first credit pack, applied automatically to your whole organization and enforced at buyer-serve time. Do not treat a theme flag as proof of it. For committed volume, SSO/SAML, an SLA, integration support, or custom venue design, contact [hello@seatlayer.io](mailto:hello@seatlayer.io). Every account includes our [data processing agreement](https://seatlayer.io/dpa/). ## Buy and retrieve invoices Admins purchase packs in the dashboard. SeatLayer creates a provider-hosted checkout from a server-authoritative `packId`, credits the wallet exactly once after a verified completion, and sends a purchase confirmation. Where the payment provider supports invoices, a completed purchase can expose a hosted invoice or PDF. A just-completed purchase can briefly return `invoice_not_ready`; retry the invoice action rather than starting a second checkout. ## Plan for production - Estimate peak sold guest capacity, not chart renders or holds. - Keep a buffer before a high-volume on-sale. - Monitor the organization balance, not individual workspace balances. - Use test-mode events for integration and load validation. - Treat public pack values as product pricing that can change; read the dashboard/server catalog before building an internal purchase UI. - White label needs no separate step; it follows your first credit pack. See the [credit model](/billing/credits) and [test-mode authentication](/start/authentication). # Stripe permissions > The four Stripe permissions SeatLayer needs, why each one is needed, and how to create a restricted key with exactly those scopes for test and live events. Source: https://docs.seatlayer.io/billing/stripe-permissions/ · Markdown: https://docs.seatlayer.io/billing/stripe-permissions/index.md SeatLayer takes payments through **your own Stripe account** using a [restricted API key](https://dashboard.stripe.com/apikeys/create) that you paste on the dashboard's **Settings → Payments** page. A restricted key grants only the permissions you choose, so SeatLayer never holds more access to your account than it needs. This page lists the four permissions, says why each one is needed, and shows how to create the key. ## The four permissions | Permission | Level | Why SeatLayer needs it | | --- | --- | --- | | **Checkout Sessions** | Write · required | To open Stripe's payment page for each order. | | **Webhook Endpoints** | Write · required | So Stripe can tell us a payment succeeded and we can confirm the seats. | | **Refunds** | Write · recommended | To refund from your dashboard and put seats back on sale. | | **Basic Business Contact Information** (Account) | Read · recommended | To show which Stripe account is connected and its currency. | A key missing a **required** permission is refused when you paste it, and a connected key that loses one shows **Connected · cannot take payments yet** on the Payments page until it is replaced. A key missing **Refunds** still takes payments; refunds from the dashboard fail until the scope is granted. ## Create a restricted key 1. In Stripe, open **Developers → API keys → Create restricted key**. 2. Set **Checkout Sessions**, **Refunds** and **Webhook Endpoints** to **Write**, and **Basic Business Contact Information** to **Read**. Leave every other resource on **None**. 3. Choose the mode: - **Test events**: switch Stripe's **Test mode** toggle on and create an `rk_test_…` key. - **Live events**: keep Test mode off and create an `rk_live_…` key. Test and live are separate keys; a test key serves sandbox events only. 4. Paste the key on **Settings → Payments**. SeatLayer verifies the permissions, creates its payment webhook on your account, and connects. Pasting a new key replaces the old one without a gap in payments. ## The permission checklist Under a connected Stripe card the Payments page lists the four permissions with **Granted** or **Missing** for each, from a real check against Stripe, not from the key's prefix. A missing row shows Stripe's own message and what it stops. **Re-check permissions** runs the check again (at most once a minute); the page never re-checks on its own. Permissions are checked without creating anything in your Stripe account: no session, no booking. If Stripe rejects the key itself (it was rolled or is no longer valid) the card reads **Stripe rejected this key**; use **Replace key** to paste a new one. ## When the key is fine but Stripe still refuses Sometimes the key has every permission and Stripe refuses the payment call for a setting in your account, for example *"the product tax code is missing"* when Stripe Tax is on without a default tax code. The card then reads **Stripe account needs a change**, the row is marked **Refused** with "Granted, but Stripe refused: …" and Stripe's own message, and the chip stays *cannot take payments yet*. Fix the setting in your Stripe dashboard (for a tax message: Settings → Tax → default product tax code), then **Re-check permissions**. Do not create a new key for this; the key is not the problem. SeatLayer never displays or returns your key after you paste it: it is sealed server-side and can only be replaced, never read back. # Client SDKs > Choose a SeatLayer web or mobile client SDK and the buyer component for your inventory model, then hand temporary holds to a trusted backend for booking. Source: https://docs.seatlayer.io/buyer-sdk/ · Markdown: https://docs.seatlayer.io/buyer-sdk/index.md Client SDKs render live seating inventory, manage selection, and create temporary holds. They do not replace your trusted checkout, payment, order, ticketing, refund, or fulfilment systems. This page narrows the choice to the client side. To compare client SDKs with server SDKs and REST, start at [Choose an SDK or API](/sdks/). ## Choose a platform - **Web application** — Compare [JavaScript, React, Vue, and Angular](/buyer-sdk/web/), and choose between the ready-made `SeatPicker` and the map-only `SeatingChart`. - **Native app SDKs** — Compare [Flutter, React Native, iOS, and Android](/buyer-sdk/mobile/) and the four levels of buyer UI your app can own. ## Choose the buyer component for your inventory model | Inventory model | Buyer component | Platforms | |---|---|---| | One dated [Event](/start/how-it-works/) | [`SeatPicker`](/buyer-sdk/seat-picker/) or [`SeatingChart`](/buyer-sdk/seating-chart/) on the web; the native picker in an app | Web and native | | A [Performance Group](/integrations/performance-groups/): one atomic selection across several performances | [Performance Group picker](/buyer-sdk/performance-groups/) | Web only | | A [Fixed Renewable Season](/integrations/seasons/): the same seats across a renewable Plan | [Season picker](/buyer-sdk/seasons/) | Web only | If you have not chosen the inventory model yet, compare [Events, Performance Groups, and Seasons](/start/inventory-models/). ## Complete the trusted half Every client path ends at the same boundary: pass the opaque hold identity to a trusted backend, inspect it there, and commit booking only after your own order or payment decision. Choose a [server SDK](/server-sdk/) or use the [REST API](/server-api/) directly. # 3D buyer view > Offer an interactive venue view, cinematic seat navigation, and 360° sightlines with a reliable 2D fallback. Source: https://docs.seatlayer.io/buyer-sdk/3d-view/ · Markdown: https://docs.seatlayer.io/buyer-sdk/3d-view/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. The SeatPicker can move between a fast 2D map and an interactive 3D venue. Buyers can orbit the venue, inspect live seat state, fly to a seat, and open its 360° view. For a walkthrough of what buyers see, see [the 3D seat map experience](https://seatlayer.io/3d-seat-map/). The feature is progressive enhancement: - enabled by default; - shown only when the browser supports WebGL2; - lazy-loaded only when the buyer enters 3D; and - always paired with the standard 2D map. ## Basic setup New to this feature? The step-by-step [3D seat view tutorial](/buyer-sdk/add-3d-seat-view) walks from install through fallback behaviour; this page is the reference for everything it touches. ```js title="buyer-picker.js" {5,6} const picker = new SeatPicker({ container: "#picker", event: "", publicKey: "", enable3D: true, seatView: true, }); await picker.render(); ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. Set `enable3D: false` when an embed must remain strictly 2D. Set `seatView: false` to hide 360° view-from-seat actions in both 2D and 3D. ## Control the view The buyer can use the built-in **Map | 3D** switch. Your application can drive the same state: ```js title="venue-tour.js" picker.setBuyerView("venue3d"); console.log(picker.getBuyerView()); // "venue3d" // Enter 3D and move directly to a known seat. picker.setBuyerView("venue3d", { flyToSeatId: "A-12", }); picker.setBuyerView("map"); ``` Calling `setBuyerView("venue3d", { flyToSeatId })` while already in 3D moves the existing camera without rebuilding the scene. > **Use a seat id** > > `flyToSeatId` must identify a seat that exists in the loaded chart. Store the seat id from the SDK selection; do not derive it from display text in your UI. ## View-from-seat sources SeatLayer resolves the closest available source: 1. a seat or row-level authored image; 2. a floor-level fallback; 3. a venue-wide fallback; or 4. the live 3D view from the seat, built from the chart. Every seat therefore has a view on day one: the buyer lands in the seat, labelled with its section, row and seat number, can drag to look around, and can step to the neighbouring seats with arrows. A venue can add one 360° image later and progressively add more precise sightlines where they matter. Buyer UI labels each view by its source, so the live 3D view is never presented as photography. The public template demos attach no panoramas: every seat view there is the live 3D view. Organizer-authored media remains authoritative. Use buyer-safe metadata such as coverage, capture date and source label so the viewer can disclose whether a panorama represents an exact seat, row, section or the wider venue. See the [virtual seat-view source model](https://seatlayer.io/view-from-seat/) for examples and limits. For fast mobile delivery, keep `viewFromSeatUrl` as the sharp equirectangular source and add an optional lightweight source to its metadata: ```json title="progressive-panorama.json" { "viewFromSeatUrl": "https://cdn.example.com/venue/main-8192.webp", "viewFromSeatMeta": { "projection": "equirectangular", "previewUrl": "https://cdn.example.com/venue/main-512.webp", "previewWidth": 512, "previewHeight": 256, "sourceWidth": 8192, "sourceHeight": 4096 } } ``` The preview paints first. On capable devices the viewer decodes the sharp source after that first frame and replaces the existing texture. Save-Data connections and devices that cannot safely allocate the full texture keep the preview. Charts without preview metadata continue to load `viewFromSeatUrl` directly. SeatLayer-hosted view media is delivered through the Event, not through a raw Chart URL. `SeatPicker` handles this automatically: it fetches the image bytes with the same in-memory buyer-access bearer used for inventory, displays a blob URL, and revokes that URL when the picker is destroyed. The bearer is never placed in `src`, CSS, query parameters, browser history, or logs. A custom `PickerTransport` that serves SeatLayer-authored private media must implement its optional `asset(eventKey, assetName)` byte loader; without it, the picker fails closed for those Event-scoped images. External organizer/CDN image URLs continue to load as authored. ## Finding an exact seat in a large venue The buyer does not need to hunt for a tiny seat in the full bowl. The built-in 3D locator drills through **Section → Row → Seat**, frames each chosen level, highlights the exact seat and then offers the cinematic seat view. For venues above 6,000 seats, seat-number labels are created only for the focused section instead of allocating thousands of DOM labels for the entire venue. Returning to Overview restores the locator for another search. ## Picking seats on a phone Since `@seatlayer/js` `0.100.0`, touch and pen input in 3D works like this when the seats are small on screen: 1. Tap a section to enter it, as before. 2. If the chairs under the finger are less than about 20 px tall, the tap zooms in until they are about 28 px and picks nothing. 3. The next tap picks the seat under the finger. A mouse picks in one click, as before. Picking a seat no longer moves the camera, so the view stays where the buyer put it. Wheelchair, companion, access, limited-view, and premium seats show their icon in 3D, as they do in 2D. ## Live state and theming The 3D scene receives the same availability and selection changes as the 2D picker. Available, held, sold, not-for-sale, accessible, table, booth, section, zone, label, and floor information remains consistent while the buyer changes view. Chart branding and host theme overrides are applied to the 3D experience as well as the picker chrome. ## Venue architecture Venue landmarks authored in the Designer are shared with the 3D scene rather than maintained as a separate model. Entrance and exit landmarks become open portal frames; screens become vertical display surfaces; walls, sound booths and service counters use physical extrusion; and the Safety rail, Suite and Obstruction presets add guardrails, open-front hospitality boxes and pillars. Select one of these landmarks in the Designer to adjust its **3D physical height**. The stored `heightM` value is optional: when absent, the semantic role supplies a safe default, so existing charts remain compatible. Reusable venues can scope architecture to an event layout. Set `ChartDoc.eventConfigurationId` to the active layout and add `eventConfigurationIds` to shapes that only exist for configurations such as `concert` or `basketball`. Unscoped objects remain visible in every layout; scoped objects remain hidden until a matching configuration is active. A 3D scene input can override the active id for previews without mutating the saved chart. Use `onBuyerViewChange` to mirror `{ view, seatId }` into a host route. The public demo does this with `?tour=3d&seat=`, so a copied URL opens the same venue and flies to the same seat; browser Back and Forward restore the map, 3D overview, and targeted seat without rebuilding application state. The buyer picker prepares the 3D scene in a module worker. If workers are unavailable or blocked by your page's Content Security Policy, the picker prepares the scene on the main thread instead, so the 3D view still works. All architectural pieces are merged into the existing venue solid mesh. Adding them does not create one draw call per wall, portal or screen. ## Analytics Use `onAnalytics` to forward the 3D journey into your product analytics: ```js title="3d-analytics.js" const picker = new SeatPicker({ container: "#picker", event: "", publicKey: "", onAnalytics: (event, properties) => { analytics.capture(event, properties); }, }); ``` Current events include: - `3d_opened`; - `3d_orbit_engaged`; - `3d_seat_picked`; - `3d_cinematic_played`, `3d_cinematic_skipped`, `3d_cinematic_cancelled`; - `3d_panorama_opened`; and - `3d_panorama_closed`. The callback receives `{ surface: "buyer" }` with event-specific properties. A failing analytics sink does not break the picker. ## UX guidance - Keep the built-in toggle discoverable; do not force every buyer into 3D. - Use a direct flight for a “show my seat” or order-history experience. - Keep controls outside the picker synchronized with `getBuyerView()`. - Test low-power devices and browser WebGL restrictions. - Do not make 3D a requirement for accessibility or checkout. - Provide meaningful authored 360° images where obstructed views affect purchase decisions. ## Test matrix - WebGL2 available and unavailable. - Single and multi-floor venues. - A seat with an authored image and one using the generated fallback. - Live availability changing while 3D is open. - Keyboard exit and return to the 2D selection. - Mobile portrait and reduced container heights. - On a touch device, a tap on small seats zooms in first and the next tap picks. - Host theming with sufficient text and control contrast. For the complete purchase flow, continue to the [checkout example](/examples/complete-checkout). # Add a 3D seat view to your website > Add an interactive 3D venue and 360° view-from-seat to your ticketing website with the SeatLayer Buyer SDK, from install through fallback behaviour. Source: https://docs.seatlayer.io/buyer-sdk/add-3d-seat-view/ · Markdown: https://docs.seatlayer.io/buyer-sdk/add-3d-seat-view/index.md This tutorial adds a 3D seat view to a ticketing website: buyers orbit the venue, fly to a seat, and open its 360° view, then select that seat through the same picker and hold that already drives your 2D checkout. There is no separate 3D model to commission and no second chart to keep in sync; the 3D scene is built from the chart you already publish. Test mode is free with no time limit and no card, so you can complete every step below before a live account exists. For a walkthrough of what buyers see, see [what buyers see in a 3D seat map](https://seatlayer.io/3d-seat-map/). ## 1. Install the package ```sh npm install @seatlayer/js@0 pnpm add @seatlayer/js@0 yarn add @seatlayer/js@0 bun add @seatlayer/js@0 ``` For script-tag, browser ESM, React, Vue, and Angular routes, see the [install options for all frameworks](/buyer-sdk/install). ## 2. Enable 3D on the SeatPicker 3D is a progressive enhancement of the picker you already mount. Two options turn it on: `enable3D` for the interactive venue, and `seatView` for the 360° view-from-seat actions. ```js title="buyer-picker.js" {4,5,6} const picker = new SeatPicker({ container: "#picker", event: "", publicKey: "", enable3D: true, seatView: true, }); await picker.render(); ``` `enable3D` is `true` when WebGL2 is available, and `seatView` is `true` by default. Set `enable3D: false` when an embed must remain strictly 2D, and `seatView: false` to hide 360° view-from-seat actions in both 2D and 3D. > **Public and private audiences** > > `publicKey` gives a Platform event one direct, exact-origin, Public-sale-only > bootstrap; the resulting bearer remains in memory and the first response > carries both the chart and compact inventory status. Login, presale, partner, > and channel inventory instead require `buyerAccessTokenProvider` or > `buyerAccessToken` from your authenticated backend. An explicit scoped > credential always takes precedence over `publicKey`. ## 3. Drive the view from your own UI Buyers get a built-in **Map | 3D** switch. Your application can drive the same state, useful for a “show my seat” button or an order-history screen. ```js title="venue-tour.js" picker.setBuyerView("venue3d"); console.log(picker.getBuyerView()); // "venue3d" // Enter 3D and move directly to a known seat. picker.setBuyerView("venue3d", { flyToSeatId: "A-12", }); picker.setBuyerView("map"); ``` Calling `setBuyerView("venue3d", { flyToSeatId })` while already in 3D moves the existing camera without rebuilding the scene. > **Use a seat id** > > `flyToSeatId` must identify a seat that exists in the loaded chart. Store the > seat id from the SDK selection; do not derive it from display text in your UI. ## 4. Mirror the view into your own route `onBuyerViewChange` reports map/3D entry, exit, and targeted-seat changes as `{ view, seatId? }`. Mirror it into a host route so a copied URL reopens the same venue at the same seat. ```js title="deep-linked-tour.js" const picker = new SeatPicker({ container: "#picker", event: "", publicKey: "", enable3D: true, seatView: true, onBuyerViewChange: ({ view, seatId }) => { syncHostRoute({ view, seatId }); }, }); ``` The public demo does this with `?tour=3d&seat=`, so browser Back and Forward restore the map, the 3D overview, and the targeted seat without rebuilding application state. ## 5. Forward the 3D journey to analytics `onAnalytics` receives the same 3D events the picker emits, with `{ surface: "buyer" }` plus event-specific properties. ```js title="3d-analytics.js" const picker = new SeatPicker({ container: "#picker", event: "", publicKey: "", onAnalytics: (event, properties) => { analytics.capture(event, properties); }, }); ``` Current events include `3d_opened`, `3d_orbit_engaged`, `3d_seat_picked`, `3d_cinematic_played`, `3d_cinematic_skipped`, `3d_cinematic_cancelled`, `3d_panorama_opened`, and `3d_panorama_closed`. A failing analytics sink does not break the picker. ## 6. Know the fallback behaviour 3D never becomes a requirement for selection or checkout: - it is shown only when the browser supports WebGL2; - it is lazy-loaded only when the buyer enters 3D; and - it is always paired with the standard 2D map. `max3DSeats` defaults to `60,000` on desktop and `30,000` on small or low-core devices, and host overrides are accepted. Keep the built-in toggle discoverable rather than forcing every buyer into 3D, keep any controls outside the picker synchronized with `getBuyerView()`, and test low-power devices and browser WebGL restrictions. ## Before you ship - [ ] WebGL2 available and unavailable both tested. - [ ] A seat with an authored image and one using the generated fallback. - [ ] Live availability changing while 3D is open. - [ ] Keyboard exit and return to the 2D selection. - [ ] Mobile portrait and reduced container heights. - [ ] Host theming with sufficient text and control contrast. ## Questions ### How do I add a 3D seat preview to my ticketing website? Install `@seatlayer/js`, mount `SeatPicker` on an event, and set `enable3D: true` with `seatView: true`. The picker renders the 2D map, the interactive 3D venue, and the 360° view-from-seat from the same published chart, so the seat a buyer picks while orbiting the venue is the seat that enters the hold and your checkout handoff. ### Does the 3D view need a separate chart? No. The 3D scene is built from the chart you already publish, and venue landmarks authored in the Designer are shared with the 3D scene rather than maintained as a separate model. The 3D scene receives the same availability and selection changes as the 2D picker, so seat state stays consistent while the buyer changes view. ### Where do the 360° images come from? SeatLayer resolves the closest available source: a seat or row-level authored image, then a floor-level fallback, then a venue-wide fallback, and otherwise the live 3D view from the seat, built from the chart. Buyer UI labels each view by its source, so the live 3D view is never presented as photography. See the [3D buyer view reference](/buyer-sdk/3d-view) for the progressive-panorama metadata and the private-media transport rules. ### Can I do this in React? Yes. The same options apply to the React wrapper. Start from the [React seating chart tutorial](/buyer-sdk/react-seating-chart) and add `enable3D` and `seatView` to the `SeatPicker` component it builds. For the complete purchase flow, continue to the [checkout example](/examples/complete-checkout). # Buyer analytics > Forward SeatPicker journey events to PostHog, Google Analytics, or your own telemetry system. Source: https://docs.seatlayer.io/buyer-sdk/analytics/ · Markdown: https://docs.seatlayer.io/buyer-sdk/analytics/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. `SeatPicker.onAnalytics` is an optional callback that lets your application route buyer journey events to any analytics system. SeatLayer does not require or select the destination. > **Event catalog compatibility** > > The callback and current event catalog are released. New event names may be > added over time, so treat unknown events as valid and avoid using this stream > as a billing or transaction authority. ## Add a provider adapter ### PostHog ```js const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", onAnalytics: (event, properties) => { posthog.capture(`seatlayer_${event}`, properties); }, }); ``` ### Google Analytics ```js const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", onAnalytics: (event, properties) => { gtag("event", `seatlayer_${event}`, properties); }, }); ``` ### Custom endpoint ```js const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", onAnalytics: (event, properties) => { navigator.sendBeacon( "/analytics", JSON.stringify({ event, properties }), ); }, }); ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. Every property object includes `surface: "buyer"`. A throwing callback is caught inside the picker and does not interrupt rendering, but your adapter should still fail quietly and avoid synchronous network work. ## Current event catalog | Event | Additional properties | Emitted when | |---|---|---| | `3d_opened` | `seats`, `hasHeights` | The interactive venue view opens | | `3d_orbit_engaged` | None | The first real orbit/dolly gesture in this 3D mount occurs | | `3d_seat_picked` | `seatId`, `sectionId?` | The buyer picks a seat from 3D | | `3d_cinematic_played` | `durationMs`, `reducedMotion: false` | The seat approach animation completes | | `3d_cinematic_skipped` | `reducedMotion: true` | Reduced-motion preference skips the animation | | `3d_cinematic_cancelled` | None | The active cinematic is cancelled | | `3d_panorama_opened` | None | A view-from-seat panorama opens | | `3d_panorama_closed` | `viewMs` | The panorama closes | All rows also include `surface: "buyer"`. ## Use a stable internal envelope Decouple your product analytics from SDK event names by wrapping them: ```ts title="browser/seatlayer-analytics.ts" type SeatLayerJourneyEvent = { schemaVersion: 1; source: "seatlayer"; name: string; occurredAt: string; properties: Record; }; function forwardSeatLayerEvent( name: string, properties: Record, ) { const event: SeatLayerJourneyEvent = { schemaVersion: 1, source: "seatlayer", name, occurredAt: new Date().toISOString(), properties, }; productAnalytics.capture("buyer_seat_journey", event); } ``` Your warehouse or dashboard can then map each incoming name without requiring SDK callbacks throughout the codebase. ## Product questions these events can answer - How many buyers discover and open the 3D venue view? - Does interaction with 3D correlate with seat selection? - How often is the cinematic skipped because of reduced-motion preference? - Which sections receive the most 3D seat picks? - Do buyers open a view-from-seat panorama, and how long do they inspect it? These are journey signals, not proof that a hold, payment, or booking succeeded. Use checkout responses, orders, and signed webhooks for transactional analytics. ## Privacy and resilience - Do not attach buyer email, name, payment data, or unneeded identifiers. - Confirm that seat and section identifiers fit your privacy policy. - Respect the consent model already used by your application. - Sample or batch in the analytics provider, not inside critical picker logic. - Accept unknown names so a newly added event cannot break the adapter. - Keep provider exceptions and rejected network calls away from the buyer UI. ## Verification checklist - [ ] Events arrive in the intended test analytics project. - [ ] The adapter prefixes or envelopes SDK event names. - [ ] Unknown event names are accepted. - [ ] No personal or payment data is added. - [ ] Analytics consent is respected. - [ ] A deliberately throwing adapter does not break the picker. - [ ] Booking conversion comes from server/order data, not this callback. Continue with the [3D buyer view](/buyer-sdk/3d-view) or [custom application architecture](/integrations/custom-applications). # Android SDK > Install the aligned SeatLayer Android 0.3.5 core and Compose artifacts for a ready-made picker, branded flow, custom Compose/View layout, or raw map. Source: https://docs.seatlayer.io/buyer-sdk/android/ · Markdown: https://docs.seatlayer.io/buyer-sdk/android/index.md SeatLayer Android `0.3.5` ships the complete adaptive native picker on Maven Central. Jetpack Compose supplies the ready-made tree and public components; View/XML applications can host the same tree or use the headless state/controller. The preserved `SeatLayerView` remains available for raw-map integrations. > **Two aligned Maven artifacts** > > Use `0.3.5` for both `seatlayer-android` and > `seatlayer-android-compose`. Core contains the raw map plus headless state, > controller, and map View. Compose contains the complete > picker, themes, strings, styles, builders, native components, and View/XML > host. Never mix their versions. ## Requirements - Android API 24+ and compile SDK 36 - JDK 17 for local builds - AndroidX - `android:enableOnBackInvokedCallback="true"` when predictive Back is required on an activity targeting Android 15 or lower ## Install ```kotlin title="app/build.gradle.kts" dependencies { implementation("io.seatlayer:seatlayer-android:0.3.5") implementation("io.seatlayer:seatlayer-android-compose:0.3.5") } ``` ```kotlin title="settings.gradle.kts" dependencyResolutionManagement { repositories { google() mavenCentral() } } ``` Raw-only applications can omit `seatlayer-android-compose`. Maven Central is the permanent release channel; do not use old JitPack coordinates or a moving snapshot. ## Ready-made Compose picker ```kotlin title="CheckoutActivity.kt" class CheckoutActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { SeatLayerPicker( configuration = SeatLayerConfiguration( event = "", publicKey = "", currency = "USD", maxSelection = 6, ), modifier = Modifier.fillMaxSize(), onCheckout = { handoff -> checkoutBackend.start(holdId = handoff.holdId) }, onError = ::reportPickerError, onClose = ::finish, ) } } } ``` The default tree covers adaptive compact/wide layouts, native header and legend, floors and sections, accessibility filters, confirmation and tiers, GA/table quantities, dense cart, temporary holds and lapse recovery, checkout, 3D and panorama chrome, 37 locale dictionaries, lifecycle reconciliation, and Android predictive Back. Give the picker a definite size and keep the venue map outside a scrolling or gesture-competing parent. ## View and XML applications Use `SeatLayerPickerView` to host the same ready-made Compose tree in an existing View screen: ```xml ``` ```kotlin binding.picker.bind( lifecycleOwner = this, configuration = configuration, onCheckout = ::openCheckout, onReady = ::observeReady, onSnapshot = ::observeSnapshot, onClose = ::finish, ) ``` For a completely custom View hierarchy, bind a `SeatLayerPickerStateHolder` to one `SeatLayerPickerMapView`, collect its immutable `state`, and invoke the typed `controller`. ## Public and private inventory The examples use a publishable key for Public inventory. For login-gated, presale, partner, channel, or other private inventory, omit `publicKey` and use the renewable in-memory provider: ```kotlin SeatLayerConfiguration( event = "ev_private", buyerAccessTokenProvider = BuyerAccessTokenProvider { context -> buyerBackend.mintSeatLayerAccess(context.reason) }, ) ``` Mint the native buyer-access session on your backend through [buyer access sessions](/server-api/buyer-access-sessions), return only its short-lived token to the provider, and treat SDK access details as opaque. Never ship a SeatLayer secret in the APK, configuration, logs, or analytics. ## Choose an integration path | Path | API | Your application owns | | --- | --- | --- | | Ready Compose | `SeatLayerPicker` | Configuration, callbacks, destination | | Branded Compose | Theme, strings, options, styles, builders | Brand and selected complete parts | | Custom Compose | `SeatLayerPickerScope` + public composables | Entire Compose hierarchy | | Ready View/XML | `SeatLayerPickerView` | Existing View screen and dismissal | | Custom Views | `SeatLayerPickerStateHolder` + `SeatLayerPickerMapView` | Entire View hierarchy | | Raw map | `SeatLayerView` | Every buyer surface and hold transition | Every native-picker path keeps one picker state, ordered actions, supported-feature checks, hold ownership, foreground recovery, and the same typed checkout handoff. [Explore customization, headless state, and public components](/buyer-sdk/android/native-picker). ## Checkout and hold ownership The app selects and temporarily holds inventory. The trusted backend inspects and books after payment or order validation. 1. `onCheckout` receives one `SeatLayerPickerCheckoutHandoff`. 2. Send only its opaque `holdId` plus normal order context to your backend. 3. Inspect authoritative hold items and calculate the payable total there. 4. Charge through your payment provider. 5. Reuse the same `bookingRef` for idempotent retries. Ordinary picker state includes hold status, expiry, and owner, not `holdId`. Successful handoff transfers ownership to the host. Closing before handoff may release picker-owned inventory; closing afterwards must preserve the host-owned hold. A rejected callback rejects only that exact handoff and restores a recoverable picker state. Read [holds and secure checkout](/buyer-sdk/holds-and-checkout) before wiring payment. ## Application and SDK ownership | Native Android owns | SeatLayer SDK owns | | --- | --- | | Header, legend, floors/sections, filters, confirmation, tiers/quantities, cart, holds, checkout, loading/errors, Test Mode, attribution, adaptive layout, TalkBack, haptics and Back | Seats, labels, geometry, hit testing, pan/pinch, camera, authored 3D, and panorama pixels | | Compose/View hierarchy, 48dp targets, safe areas, lifecycle and window behavior | Authoritative inventory state and supported map capabilities | The SDK presents one confirmation, cart, Test Mode indicator, and checkout action around the venue surface. ## Lifecycle, Back, and recovery The ready picker installs lifecycle, haptic, and Back behavior. Foreground or return from checkout refreshes availability when supported, reconciles server hold truth, reports lost inventory, and presents one hold-lapse recovery state. Hardware and predictive Back consume one semantic layer at a time: quantity prompt → expanded cart → panorama → venue 3D → seat confirmation → focused section → host close. Custom Compose hosts install `SeatLayerPickerLifecycle`, `SeatLayerPickerBackHandler`, and `SeatLayerPickerHapticEffects`, or provide equivalent behavior around the same controller. Recomposition and configuration-handled resize retain picker state. Activity or process recreation creates new state; restore a host-owned hold with `initialHoldId` and the same renewable access provider, then let SeatLayer authoritatively resume it. ## Optional picker prewarm `SeatLayerPickerPrewarmer.prewarm()` is an optional API that prepares the picker before navigation: ```kotlin lifecycleScope.launch { val result = SeatLayerPickerPrewarmer.prewarm(applicationContext) check(result.engineStarted) } ``` Prewarm receives no Activity, event key, public key, buyer token, or hold. It never starts a buyer session. ## Test Mode and attribution Test Mode follows authoritative event state. Required `Powered by SeatLayer` attribution appears at the safe bottom-right edge according to the SeatLayer account configuration. Client-side options, builders, and styles cannot override either truth signal. ## Run the tagged sample ```bash git clone --branch v0.3.5 --depth 1 https://github.com/seatlayer/seatlayer-android.git cd seatlayer-android ./gradlew validate ./gradlew :sample:installDebug ``` The sample covers ready/branded/custom Compose, ready/custom View, and raw-map paths. Validate API 24+, TalkBack, large text, RTL, rotation, split-screen, process restoration, foreground recovery, tiers, GA/tables, 3D/panorama, checkout rejection, Test Mode, and attribution on physical devices before rollout. ## Product scope Android `0.3.5` accepts one published Event per picker. Native Performance Group and Season configuration is not part of this release. ## Next steps - [Compose, View/XML, and headless customization](/buyer-sdk/android/native-picker) - [Raw-map API](/buyer-sdk/android/raw-map) - [Compare all four native SDKs](/buyer-sdk/mobile) - [Secure checkout](/buyer-sdk/holds-and-checkout) - [Troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) - [`v0.3.5` source](https://github.com/seatlayer/seatlayer-android/tree/v0.3.5) - [Tagged native-picker guide](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/docs/native-picker.md) - [Tagged sample](https://github.com/seatlayer/seatlayer-android/tree/v0.3.5/sample) - [Tagged migration guide](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/docs/migration-0.3.md) - [Tagged changelog](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/CHANGELOG.md) # Compose, Views, and headless picker > Customize the Android 0.3.5 Compose picker or build a complete Compose/View hierarchy from public components and headless state. Source: https://docs.seatlayer.io/buyer-sdk/android/native-picker/ · Markdown: https://docs.seatlayer.io/buyer-sdk/android/native-picker/index.md Android `0.3.5` publishes the complete native picker as two aligned Maven artifacts. `seatlayer-android` contains the raw API and headless contract; `seatlayer-android-compose` contains the ready-made adaptive picker, native components, customization layers, and View/XML host. ```kotlin implementation("io.seatlayer:seatlayer-android:0.3.5") implementation("io.seatlayer:seatlayer-android-compose:0.3.5") ``` ## Application and SDK ownership | Native Android UI owns | SeatLayer SDK owns | | --- | --- | | Header, price/floor/section navigation, accessibility filters, confirmation, tiers, quantities, cart, hold state, checkout, loading/errors, Test Mode, attribution and Back | Seats, labels, geometry, hit testing, pan/pinch, venue camera, authored 3D, and panorama pixels | | Compose/View hierarchy, TalkBack, 48dp targets, predictive Back, safe areas, lifecycle and haptics | Authoritative inventory state and supported map capabilities | The SDK presents one native confirmation, cart, Test Mode indicator, and checkout action around the venue surface. ## Integration ladder | Path | API | Host ownership | | --- | --- | --- | | Ready Compose | `SeatLayerPicker` | Configuration, callbacks, destination | | Branded Compose | Theme, strings, options, styles, builders | Brand or selected complete parts | | Custom Compose | `SeatLayerPickerScope` + public composables | Entire Compose hierarchy | | Ready View/XML | `SeatLayerPickerView` | Existing View screen and dismissal | | Custom Views | `SeatLayerPickerStateHolder` + `SeatLayerPickerMapView` | Entire Android View hierarchy | | Raw map | `SeatLayerView` | Every buyer surface and hold transition | The first five paths retain the same read-only picker state, typed controller, ordered actions, supported-feature checks, lifecycle recovery, and checkout handoff. ## Customize the ready-made picker Three layers can be mixed without forking the SDK: 1. `SeatLayerPickerOptions`, `SeatLayerPickerTheme`, localized `SeatLayerPickerStrings`, and `SeatLayerPickerStyles` change behavior and appearance. 2. `SeatLayerPickerBuilders` decorates or replaces any of 25 independent complete parts. 3. `SeatLayerPickerScope` and the public components create a host-owned Compose hierarchy. ```kotlin SeatLayerPicker( configuration = configuration, themeMode = SeatLayerPickerThemeMode.Auto, options = SeatLayerPickerOptions( layout = SeatLayerPickerLayoutMode.Adaptive, haptics = true, ), strings = SeatLayerPickerStrings.localized("fr-FR"), styles = SeatLayerPickerStyles( parts = mapOf( SeatLayerPickerPart.CheckoutBar to SeatLayerPickerPartStyle( cornerRadius = 18.dp, ), ), ), builders = SeatLayerPickerBuilders( header = { _, defaultContent -> defaultContent() }, ), onCheckout = ::openCheckout, ) ``` Each builder receives `SeatLayerPickerPartContext`: read-only picker state, native presentation state, typed controller, resolved theme, strings, options, global styles, and the style for that part. Its second closure argument renders the default content; calling it decorates the stock component, while omitting it replaces only that part. Test Mode and attribution intentionally remain outside the replacement matrix. ## Compose a custom screen ```kotlin SeatLayerPickerScope( configuration = configuration, callbacks = SeatLayerPickerCallbacks( onCheckout = ::openCheckout, onClose = onClose, ), ) { SeatLayerPickerLifecycle() SeatLayerPickerBackHandler() SeatLayerPickerHapticEffects() Column(Modifier.fillMaxSize()) { SeatLayerPickerHeader(onClose) SeatLayerPriceLegend() Box(Modifier.weight(1f)) { SeatLayerPickerMap(Modifier.fillMaxSize()) SeatLayerPickerMapControls() SeatLayerConfirmCard() } SeatLayerPickerCartSheet() } } ``` The scope owns one picker state. Do not place a second map in another adaptive branch; the layout moves the existing map between compact and wide containers so camera, cart, and hold state survive rotation or window resize. ## View/XML and custom Views `SeatLayerPickerView` hosts the ready-made tree in a traditional View screen: ```kotlin binding.picker.bind( lifecycleOwner = this, configuration = configuration, onCheckout = ::openCheckout, onReady = ::observeReady, onSnapshot = ::observeSnapshot, onClose = ::finish, ) ``` For fully custom Views: 1. Create one `SeatLayerPickerStateHolder` for the screen lifecycle. 2. Bind one `SeatLayerPickerMapView` to it. 3. Collect `state`, an immutable `StateFlow` projection. 4. Invoke semantic actions through `stateHolder.controller`. 5. Reproduce lifecycle, Back, Test Mode, attribution, and safe-area duties the ready host normally supplies. ## Public component catalogue | Builder part | Stock public components | | --- | --- | | Header and navigation | `SeatLayerPickerHeader`, price legend, floor selector/strip, section navigator, dock bar | | Map and controls | `SeatLayerPickerMap`, map controls, zoom controls, accessibility filters | | Decisions | Best-seats form, seat confirmation, tier selector/choices, confirm card, GA/table prompts | | Cart and holds | Cart list/sheet, countdown, lapse notice, undo notice, checkout bar/button | | Immersive views | `SeatLayerVenue3D`, `SeatLayerSeatViewChrome` | | System states | Loading, retryable/fatal error, empty/sold-out/sales-closed, action error | | Truth components | Test Mode indicator and required attribution | All stock controls expose TalkBack labels, roles, enabled/disabled state, and at least 48dp interaction targets. Adaptive compact/wide layout, large text, RTL, safe drawing, and gesture insets remain SDK responsibilities unless a custom host replaces the whole hierarchy. ## Headless state guarantees `SeatLayerPickerStateHolder.state` combines read-only SeatLayer state with native presentation state. The controller provides ordered suspending actions for selection, filters, floors/sections, view modes, camera, GA/tables, holds, best available, lifecycle refresh, checkout, undo, Back, and close. - Published state always belongs to the configured Event and supersedes older state. - Malformed optional fields are ignored; invalid schema/session/event identity is rejected. - Empty UI appears only from affirmative sold-out/all-unavailable truth. - Optional commands are sent only when the exact capability is advertised. - Repeated checkout taps join one flight. Custom UI never mutates SDK-owned picker state locally. ## Selection, cart, and checkout The confirmation surface keeps a seat pending until the buyer accepts it. When event-authored tiers exist, `confirmPending(tierId)` applies the exact tier before confirmation, so cart and checkout never briefly carry the wrong price. GA and variable-table prompts validate quantity against authoritative capacity and occupancy. Seats, GA, and tables enter one dense cart. Removing a line opens a session-scoped undo window only while that exact line remains absent. Checkout returns one `SeatLayerPickerCheckoutHandoff` with opaque `holdId`, server expiry, currency, priced lines, and display total. Ordinary state omits `holdId`. Your backend must inspect the hold and calculate payment from server data. ## Lifecycle, Back, and recovery The ready picker refreshes availability when supported, reconciles server hold state, reports lost seats, and presents one recoverable hold-lapse message on foreground or return from checkout. Back closes the most local state first: quantity prompt → expanded cart → panorama → venue 3D → confirmation → focused section → host route. Custom Compose hosts install the exported lifecycle/Back/haptic effects; custom View hosts provide equivalent behavior around the same controller. Activity/process recreation creates a new session. Restore only a host-owned hold through `initialHoldId` and the same access provider, then let SeatLayer prove whether it is still active. ## Optional picker prewarm ```kotlin lifecycleScope.launch { val result = SeatLayerPickerPrewarmer.prewarm(applicationContext) check(result.engineStarted) } ``` Prewarm prepares the picker without receiving an Activity, event, credential, or hold and never starts a buyer session. ## Test Mode and required attribution Custom hosts must present Test Mode from authoritative Event state and show bottom-right attribution exactly when the SDK requires it. Server white-label entitlement is the only authority that disables attribution. ## Production checklist - Pin both Maven artifacts to `0.3.5`. - Test every supported Android API level. - Exercise ready/custom Compose and View paths your product uses. - Validate TalkBack, 48dp targets, large text, RTL, safe areas, predictive Back, rotation, split-screen, process restoration, and picker load failure. - Test real tiers, GA/tables, lapse recovery, 3D/panorama, rejected checkout, Test Mode, and attribution on physical devices. - Confirm the trusted backend inspects and books the handoff hold. ## Product scope The picker selects one Event. It does not accept native Performance Group or Season configuration. ## Related pages - [Android quick start](/buyer-sdk/android) - [Raw-map API](/buyer-sdk/android/raw-map) - [Compare mobile SDKs](/buyer-sdk/mobile) - [`v0.3.5` source](https://github.com/seatlayer/seatlayer-android/tree/v0.3.5) - [Tagged Compose entry point](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/seatlayer-compose/src/main/kotlin/io/seatlayer/android/compose/SeatLayerPicker.kt) - [Tagged headless controller](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerPickerController.kt) - [Tagged native-picker guide](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/docs/native-picker.md) - [Tagged sample](https://github.com/seatlayer/seatlayer-android/tree/v0.3.5/sample) - [Tagged changelog](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/CHANGELOG.md) - [Troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) # Raw map API > Use SeatLayerView, SeatLayerConfiguration, coroutine commands, and typed flows from the preserved raw-map surface in Android 0.3.5. Source: https://docs.seatlayer.io/buyer-sdk/android/raw-map/ · Markdown: https://docs.seatlayer.io/buyer-sdk/android/raw-map/index.md This page covers the preserved raw-map API in `io.seatlayer:seatlayer-android:0.3.5`. It does not require the Compose artifact. ## Core types | Type | Responsibility | | --- | --- | | `SeatLayerView` | `FrameLayout` map host with `load`, `controller`, and `destroy` | | `SeatLayerConfiguration` | Event, locale, currency, selection rules, buyer access, and timeouts | | `SeatLayerController` | Suspending commands plus ready, bundle, and event flows | | `BuyerAccessTokenProvider` | Kotlin `fun interface` with a suspending `provide(context)` method | | `HoldResult` | Opaque hold ID, server expiry, selected seats, and display items | | `SeatLayerException` | Typed load, access, compatibility, timeout, and command failures | ## Configure buyer access ```kotlin val configuration = SeatLayerConfiguration( event = "", maxSelection = 6, locale = "en", currency = "USD", buyerAccessTokenProvider = BuyerAccessTokenProvider { context -> buyerBackend.mintSeatLayerAccess(context.reason) }, ) ``` The provider returns `BuyerAccessToken(token, expiresAt)` in memory. Prefer it to a one-shot token when the screen must renew access without rebuilding the view. Mint the native buyer-access session on your backend through [buyer access sessions](/server-api/buyer-access-sessions), return only its short-lived token, and treat SDK access details as opaque. The [tagged configuration declaration](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerConfiguration.kt) lists every field in `0.3.5` for access, selection rules, presentation, deadlines, and diagnostics. ## Observe lifecycle-aware state ```kotlin lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { seatMap.controller.events.collect(::handleSeatLayerEvent) } launch { seatMap.controller.ready.collect(::renderReadyState) } } } ``` `ready` contains the current `ReadyInfo?`; `events` emits typed selection, validity, access, hold, general-admission, seat-hover, error, and unknown-future events. A raw-map host must explicitly call `controller.hold()` and start checkout with only `holdId`; no map event is booking authority. Handle payloads exhaustively where your product has behavior: ```kotlin when (event) { is SeatLayerEvent.SelectionChanged -> renderSelection(event.seats) is SeatLayerEvent.SelectionValidityChanged -> setContinueEnabled(event.validity.isValid) SeatLayerEvent.HoldExpired -> returnBuyerToSelection() is SeatLayerEvent.BuyerAccessUnavailable -> showAccessError(event.event) is SeatLayerEvent.SelectedObjectsUnavailable -> explainUnavailable(event.event.labels) is SeatLayerEvent.Error -> showInventoryError(event.error) else -> Unit } ``` See the [tagged event models](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/seatlayer/src/main/kotlin/io/seatlayer/android/Models.kt) for every subtype and payload. ## Common command groups This is a task map, not an exhaustive replacement for the tagged API. | Group | `SeatLayerController` methods | | --- | --- | | Read | `getSelection`, `getSelectionValidity`, `getCurrentHold`, `getGeneralAdmissionAreas`, `getFloors`, `getViewMode` | | Select | `selectObjects`, `deselectObjects`, `clearSelection`, `selectCategories`, `deselectCategories`, `setSelectableObjects`, `setMaxSelection`, `setSeatTier` | | Hold | `hold`, `resumeHold`, `extendHold`, `release`, `releaseLabels`, `bestAvailable`, `holdGeneralAdmission` | | Access | `refreshAccess` | | Map | `setFloor`, `setColorblindSafe`, `setViewMode`, `zoomIn`, `zoomOut`, `zoomToFit` | Use the [tagged controller source](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/seatlayer/src/main/kotlin/io/seatlayer/android/SeatLayerController.kt) for exact parameters, return types, and every method in `0.3.5`. Gate optional controls with the reported capability information rather than assuming method presence means the current Event supports it. Commands are suspending. Catch `SeatLayerException` around the awaited action so inventory conflict, timeout, incompatibility, or destroyed-session feedback is attached to the operation that failed. ```kotlin lifecycleScope.launch { try { val hold = seatMap.controller.bestAvailable(quantity = 4) if (hold != null) checkoutBackend.begin(holdId = hold.holdId) } catch (error: SeatLayerException) { showInventoryMessage(error.code, error.message) } } ``` ## Restore a hold ```kotlin val restored = seatMap.controller.resumeHold(persistedHoldId) if (restored == null) returnBuyerToSelection() ``` The server expiry is authoritative. A paused process timer cannot prove that a hold remains active. ## View ownership and cleanup - `load(configuration)` prepares the map and returns `ReadyInfo`. - Commands are sent through `seatMap.controller`, never `seatMap.hold()`. - `destroy()` permanently releases the view; create a new view for another session. - Give the view a definite size and keep it outside competing gesture parents. ## Related pages - [Android SDK overview](/buyer-sdk/android) - [Compose, Views, and headless picker](/buyer-sdk/android/native-picker) - [Holds and checkout](/buyer-sdk/holds-and-checkout) - [Lifecycle, Back, and recovery](/buyer-sdk/android#lifecycle-back-and-recovery) - [Shared troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) - [Tagged sample](https://github.com/seatlayer/seatlayer-android/tree/v0.3.5/sample) - [Tagged changelog](https://github.com/seatlayer/seatlayer-android/blob/v0.3.5/CHANGELOG.md) # Angular seating chart > Install @seatlayer/angular, render an interactive Angular seating chart with live availability and seat selection, create holds, and book on your server. Source: https://docs.seatlayer.io/buyer-sdk/angular/ · Markdown: https://docs.seatlayer.io/buyer-sdk/angular/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. The official SeatLayer Angular seating chart component adds an interactive seat map and seat picker to reserved-seating apps built with Angular. The [`@seatlayer/angular` package](https://www.npmjs.com/package/@seatlayer/angular) ships a standalone component with typed inputs, outputs, and an imperative API for live availability, seat selection, temporary holds, and best-available seating. [Inspect the Angular source](https://github.com/seatlayer/seatlayer-sdk), learn about the [SeatLayer reserved-seating platform](https://seatlayer.io/), or try the [browser seat-map demo](https://app.seatlayer.io/demo/play/grand-theatre). > **The app selects and holds; your server books** > > Never put a SeatLayer secret key in browser code. Send the opaque hold id to > your trusted backend, inspect the hold there, calculate the charge from > server data, and book with a stable `bookingRef`. ## Install the package ```bash npm install @seatlayer/angular ``` The package ships in Angular Package Format with bundled TypeScript types. ## Render a seating chart Import the standalone component and bind your event: ```ts import { Component } from '@angular/core'; import { SeatingChartComponent } from '@seatlayer/angular'; @Component({ standalone: true, imports: [SeatingChartComponent], template: ` `, }) export class EventSeatMapComponent {} ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace the `publicKey` input with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. The component renders a plain `div` in your DOM, with no iframe, and runs chart events outside Angular's zone, so live seat updates never thrash change detection. ## Select, hold, and hand off checkout Keep the security boundary explicit: - The browser selects seats and creates a temporary hold. - Your backend inspects the hold and derives the amount to charge. - Your payment and order workflow stays on your server. - Your backend books with a stable `bookingRef` so retries are safe. Continue with [holds and checkout](/buyer-sdk/holds-and-checkout) before connecting a production order flow. ## Frequently asked questions ### Is this a real Angular component or an iframe? The seating chart is a standalone Angular component rendering a plain `div` and driving the buyer renderer directly, with no iframe. Inputs, outputs, and the imperative API are all typed. ### How do temporary seat holds work? Selecting seats creates a temporary hold that reserves inventory against concurrent buyers for a limited window. The hold expires automatically if checkout does not complete. The hold-expired output tells the app to return the buyer to the map, and holds can be extended or resumed for longer checkouts. ### Can I use my own payment provider? Yes. SeatLayer never processes payment inside the seat map. The browser hands the hold id to your backend, and your backend charges through any payment provider you already use before booking the hold. ### Is there a React or Vue version? Yes. [`@seatlayer/react`](https://www.npmjs.com/package/@seatlayer/react) and [`@seatlayer/vue`](https://www.npmjs.com/package/@seatlayer/vue) share the same typed contract, and [`@seatlayer/js`](https://www.npmjs.com/package/@seatlayer/js) covers any framework or none. ## Next steps - [Install @seatlayer/angular on npm](https://www.npmjs.com/package/@seatlayer/angular) - [Run or inspect the SDK source](https://github.com/seatlayer/seatlayer-sdk) - [Understand the SeatLayer platform](https://seatlayer.io/) - [Connect holds to secure checkout](/buyer-sdk/holds-and-checkout) # Best available seats > Find and hold the best adjacent seats by quantity, category, zone, and premium preference. Source: https://docs.seatlayer.io/buyer-sdk/best-available/ · Markdown: https://docs.seatlayer.io/buyer-sdk/best-available/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. `bestAvailable()` asks SeatLayer to find and hold a suitable adjacent group without requiring the buyer to click individual seats. ## Public methods ```ts title="best-available-types.ts" // SeatingChart bestAvailable( quantity: number, categoryKey?: string, options?: { zoneId?: string; preferPremium?: boolean; ttlMs?: number; }, ): Promise // SeatPicker bestAvailable( quantity: number, categoryKey?: string, options?: { zoneId?: string; preferPremium?: boolean; }, ): Promise ``` > **Same operation, different presentation** > > `SeatingChart` returns the selected labels for a custom UI. `SeatPicker` > reflects the hold in its built-in tray. Both create a real server hold and > continue through the same checkout flow. ## Find a group ```js title="browser/find-group.js" const chart = new seatlayer.SeatingChart({ container: "#chart", event: "", publicKey: "", onHold: ({ holdId, expiresAt }) => { saveCheckoutHandoff({ holdId, expiresAt }); }, onError: (error) => { console.error("Best-available request failed", error); }, }); await chart.render(); const result = await chart.bestAvailable(4); if (!result) { showAlternatives("No block of four seats is currently available."); } else { console.log(result.labels, result.holdId, result.expiresAt); } ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. A successful call updates selection, fires `onSelectionChange`, creates the hold, and fires `onHold`. ## Narrow the search ```js title="browser/best-available-filters.js" // Two adjacent VIP seats. await chart.bestAvailable(2, "vip"); // Four seats in an authored venue zone. await chart.bestAvailable(4, undefined, { zoneId: "zone-east-stand", }); // Prefer a premium block and request ten minutes when the event has no override. await chart.bestAvailable(2, undefined, { preferPremium: true, ttlMs: 10 * 60 * 1000, }); ``` Use stable category keys and zone ids from the published chart, not translated labels or screen coordinates. Hidden or unrevealed inventory is not eligible. ## Result contract ```ts interface BestAvailableResult { holdId: string; expiresAt: number; labels: string[]; seats?: SelectedSeat[]; items?: HoldLineItem[]; zoneId?: string; } ``` `items` contains authoritative hold line information when present. Your browser still sends only `holdId` to your server; the server re-inspects the hold before payment. ## Failure behavior The public method resolves to `null` for conflicts and routes the underlying error through `onError`. | Reason/code | Meaning | Buyer experience | |---|---|---| | `not_enough_together` | Enough seats may exist, but no adjacent block fits | Offer fewer seats, another zone/category, or manual selection | | `sold_out` | Eligible inventory is exhausted | Offer another ticket type or waitlist | | `invalid_zone` | Zone id is malformed | Treat as an integration error | | `unknown_zone` | Zone is not in the published chart | Refresh configuration; do not retry blindly | | `event_closed` | Sales are closed | Disable purchase controls | Do not translate every `null` into “sold out.” Preserve enough error context in `onError` to choose the correct message. ## Premium fallback `preferPremium: true` expresses preference, not an absolute filter. If a full premium block does not exist, the buyer picker may hold the best overall group and show a fallback note. Use `categoryKey` when the result must be restricted to a specific inventory category. ## Verification checklist - [ ] Quantity is a positive integer within the buyer limit. - [ ] Category and zone values are stable ids. - [ ] Failure messages distinguish adjacency from sold out. - [ ] The buyer controls any fallback to fewer or separated seats. - [ ] A successful group appears in selection and `onHold`. - [ ] The server inspects and atomically books the returned hold. - [ ] Expiry clears the whole group from the cart. Continue with [groups and GA](/integrations/groups-and-ga) or [holds and checkout](/buyer-sdk/holds-and-checkout). # Flutter seat map > Add the SeatLayer native Flutter seat picker to an iOS or Android app with one drop-in widget, or compose the same widgets into your own layout. Source: https://docs.seatlayer.io/buyer-sdk/flutter/ · Markdown: https://docs.seatlayer.io/buyer-sdk/flutter/index.md The official SeatLayer Flutter seat map SDK is a **native picker**. The venue map is drawn by the SeatLayer venue map; the header, price legend, dock bar, seat confirmation, cart sheet, checkout button, and 3D controls around it are ordinary Flutter widgets you can theme, restyle, translate, replace, or place yourself. The [`seatlayer` package on pub.dev](https://pub.dev/packages/seatlayer) ships the drop-in picker, every widget behind it, a typed controller, and structured errors for live availability, seat selection, temporary holds, best-available seating, and a typed checkout handoff. [Inspect the immutable `v0.12.0` source and runnable example](https://github.com/seatlayer/seatlayer-flutter/tree/v0.12.0), learn about the [SeatLayer reserved-seating platform](https://seatlayer.io/), or explore the wider buyer experience in the [browser seat-map demo](https://app.seatlayer.io/demo/play/grand-theatre). The browser demo is product proof, not a Flutter application. > **The app selects and holds; your server books** > > Never put a SeatLayer secret key in a Flutter binary. Send the > opaque hold id to your trusted backend, inspect the hold there, calculate the > charge from server data, and reuse the same `bookingRef` for retries. ## Install the package ```bash flutter pub add seatlayer:0.12.0 ``` Pin the version explicitly in `pubspec.yaml` so a buyer-facing surface never moves on its own: ```yaml dependencies: seatlayer: 0.12.0 ``` `0.12.0` is the current version (checked 25 September 2026), and the native picker described here ships in it. It supports Flutter applications on iOS and Android; it does not currently claim Flutter web, macOS, Windows, or Linux support. The package requires Flutter `3.19.0` or newer and Dart `3.4.0` or newer. The [SDK catalog](/sdk-catalog.json) is the machine-readable version and surface authority for automated setup. > **Flutter 0.12.0** > > `0.12.0` loads map runtime `0.101.0`: in 3D a drag turns the venue at the > overview and moves you when zoomed in, the seat card opens on the view from > that seat, and a seat card shows the app's own prices when it sends them. > `0.11.0` loads map runtime `0.100.0`. In 3D on a phone the first tap zooms > to the seats and a second tap picks one, and wheelchair, companion, access, > limited-view, and premium seats show their icon in 3D. `0.10.0` sends > `SeatLayerPickerOptions.pricing.categories` to the map and added > `SeatLayerPickerController.setPricing`. Upgrading from `0.8.x`: `0.9.0` > replaced the dense ticket list with one card per ticket and removed > `SeatLayerPickerStrings.accessNoneLeft` (use `notAvailable`) and the > checkout button's `peekLine`, `fromPriceText`, `totalText`, and > `showPrices` inputs. Required `Powered by SeatLayer` attribution follows > the SeatLayer account configuration and cannot be overridden by client-side > options or builders. ## Which way in do I need? There are three, and they are a ladder: take the next rung only when the one below it cannot express what you want. ![Three ways into the Flutter picker: drop in SeatLayerPicker; keep the same widget and change its theme, styles, sizes, chrome switches, strings, or replace one part through a builder; or place SeatLayerPickerScope and arrange the exported widgets yourself. Every rung ends in the same typed checkout handoff.](/diagrams/flutter-three-ways.svg) | | You want | You reach for | Guide | | --- | --- | --- | --- | | **1** | The buyer flow, working, today | `SeatLayerPicker` | This page | | **2** | The same flow, in your brand and wording | `theme` · `options` · `builders` | [Customise the picker](/buyer-sdk/flutter/customise) | | **3** | Your own arrangement of the parts | `SeatLayerPickerScope` | [Build your own layout](/buyer-sdk/flutter/custom-layout) | Rung 2 is where most branding work ends: a `ColorScheme`, a handful of style slots, a chrome switch or two, and one builder to swap a single part. You only need rung 3 when the *arrangement* has to differ: a persistent sidebar, a tab-embedded map, a bespoke sheet already in your design system. Every rung produces the same holds, the same validation, and the same typed `SeatLayerCheckoutHandoff`, so moving up a rung never means reimplementing inventory logic. ## What is native, and what is drawn ![The Flutter picker stack: the ready-made SeatLayerPicker and your own composition both sit on SeatLayerPickerScope, which owns the controller and picker state; those drive the SeatLayer venue map and its API operations.](/diagrams/flutter-layers.svg) The SeatLayer map owns the drawn venue: seats, sections, pan, pinch, and the 3D scene. Everything the buyer reads or presses outside the map is a Flutter widget in your tree, so the two never draw the same control twice. That seam is one **picker state**: a complete read-only view carrying the event, sections, categories, selection, cart, hold, floors, and the current rung. [The architecture page](/buyer-sdk/flutter/architecture) documents it, along with the command flow and the back-navigation ladder. ## Drop the picker in One widget, the complete buyer flow, on the approved phone layout: ```dart title="ticket_screen.dart" import 'package:flutter/material.dart'; import 'package:seatlayer/seatlayer.dart'; SeatLayerPicker( configuration: SeatLayerConfiguration( event: '', publicKey: '', ), themeMode: SeatLayerThemeMode.auto, onCheckout: (handoff) => bookOnYourBackend(handoff.holdId), onClose: () => Navigator.of(context).pop(), ) ``` `SeatLayerPickerPage` is the same picker as a full route, and `showSeatLayerPicker` presents it adaptively: edge-to-edge on a compact phone, a constrained dialog at 700 logical pixels or wider. It returns the handoff, or `null` when the buyer closes it: ```dart final handoff = await showSeatLayerPicker( context, configuration: configuration, presentation: SeatLayerPickerPresentation.adaptive, ); if (handoff != null) { openCheckout(holdId: handoff.holdId); } ``` Give the picker bounded space: an `Expanded` child or a full-screen route. Do not nest the map inside a competing gesture-driven `ListView` or `SingleChildScrollView`: the buyer canvas owns pan and pinch, and the SDK already disables document zoom, overscroll, and edge glow inside the venue map, so no gesture workaround is needed on your side. ### Optional prewarm `SeatLayerPicker.prewarm()` is an optional API that prepares the picker before navigation. It is safe to call on every build; an unused prewarm is released after a few minutes or on memory pressure, and `SeatLayerPicker.cancelPrewarm()` releases it early. ```dart @override void initState() { super.initState(); SeatLayerPicker.prewarm(); } ``` ## The buyer journey A single `PopScope` walks that ladder backwards, so Android predictive back and the iOS edge swipe both behave. Haptic cues fire on selection, section focus, hold creation, and hold expiry, and every animation collapses under `MediaQuery.disableAnimations`. ## The widgets Every widget marked standalone can be placed anywhere inside a `SeatLayerPickerScope`. | Widget | What it is | Standalone | | --- | --- | --- | | `SeatLayerPicker` | The drop-in buyer flow | n/a | | `SeatLayerPickerPage` | The same picker as a full route | n/a | | `SeatLayerPickerScope` | The state every widget below reads | n/a | | `SeatLayerChart` | The drawn venue map | yes | | `SeatLayerPickerHeader` | Event identity, hold pill, dismiss | yes | | `SeatLayerPriceLegend` | Price chips that filter the map | yes | | `SeatLayerDockBar` | Focused section, prev/next, overview (off by default) | yes | | `SeatLayerFloorStrip` | Floor chips on a multi-floor venue | yes | | `SeatLayerPickerMapControls` | Accessibility, fit, and Map/3D in the corners | yes | | `SeatLayerConfirmCard` | The one-seat decision card | yes | | `SeatLayerCartSheet` | Collapsed total and button, opens to the cart | yes | | `SeatLayerCartList` | The buyer's tickets, one card each | yes | | `SeatLayerBestSeatsForm` | Two selects, a stepper, one action | yes | | `SeatLayerBookButton` | The full-width checkout call to action | yes | | `SeatLayerVenue3D` | Caption, seat stepper, and exits over the 3D scene | yes | | `SeatLayerSeatViewChrome` | The seat-view caption and disclosure badge | yes | | `SeatLayerPickerAccessibilityFilters` | Access needs and the colorblind palette | yes | | `SeatLayerPickerTablePrompt` | Table quantity prompt | yes | | `SeatLayerPickerGeneralAdmissionPrompt` | General-admission quantity prompt | yes | The individual map buttons are exported too, for a layout that places them itself: `SeatLayerPickerZoomInButton`, `SeatLayerPickerZoomOutButton`, `SeatLayerPickerZoomToFitButton`, `SeatLayerPickerOverviewButton`, `SeatLayerPickerViewModeButton`, `SeatLayerPickerColorblindButton`, and `SeatLayerPicker3DNavigationModeButton`. ## Hand the hold to checkout `onCheckout` receives a `SeatLayerCheckoutHandoff`: an opaque `holdId`, the server-side `expiresAt`, the `currency`, `lineItems` carrying object, category, tier, and seat identity, and a display `total`. Ordinary picker state deliberately does **not** contain the `holdId`. It crosses into Dart only at this boundary. ```dart SeatLayerPicker( configuration: configuration, onCheckout: (handoff) async { await yourBackend.beginOrder(holdId: handoff.holdId); }, ) ``` `expiresAt`, `lineItems`, and `total` are useful for native display, but they are not payment authority. Your backend derives the price from the inspected hold. Keep the security boundary explicit: - The Flutter app selects seats and creates a temporary hold. - Your backend inspects the hold and derives the amount to charge. - Your payment and order workflow stays outside the venue map. - Your backend reuses one `bookingRef` so retries are safe. - Expiry and inventory conflicts return buyers to a recoverable selection state. Calling checkout transfers hold ownership to your app, so closing the picker afterwards does not release it. Before the handoff, closing the picker does release a picker-owned hold. If your `onCheckout` throws because host validation or navigation failed, the picker rejects the handoff before surfacing your error, returning the seats rather than stranding them until the server TTL. Continue with [holds and checkout](/buyer-sdk/holds-and-checkout) before connecting a production order flow. ## Public, private, and gated inventory For an ordinary Public sale, match `pk_test_…` to a test Event or `pk_live_…` to a live Event. The SDK obtains Public-only access and keeps the grant in memory. For login-gated, presale, partner, or channel inventory, replace `publicKey` with a provider that calls your backend and mints a short-lived native buyer session through [`POST /v1/events/:key/buyer-access-sessions`](/server-api/buyer-access-sessions). Return only its token to the provider and treat the SDK access context as opaque. An explicit provider or token always wins over `publicKey`: ```dart final configuration = SeatLayerConfiguration( event: 'ev_private', buyerAccessTokenProvider: (request) => buyerBackend.mintSeatLayerAccess(request.reason), ); ``` Never mint a buyer session with a SeatLayer secret inside the app. Access tokens stay in memory and are never placed in page URLs or emitted as application events. ## Read-only inspection `SeatLayerPickerOptions(readOnly: true)` inspects a map, the current selection, or a restored hold without allowing inventory changes. Selection, holds, Best Available, and checkout are disabled, and the controller enforces the same boundary before sending a command. A custom widget cannot bypass the interface guard, and gets a typed `SeatLayerError` with code `read_only`. Category and accessibility filters, section and floor navigation, view modes, and zoom stay available. ## See the shipped example
SeatLayer Flutter reserved-seating chart with curved rows and two selected seats in the iOS Simulator
The repository example running the Flutter picker and the venue map in the iOS Simulator.
The [runnable Flutter `v0.12.0` example](https://github.com/seatlayer/seatlayer-flutter/tree/v0.12.0/example) opens on the live picker, and a packaged offline fixture is included as well, so you can verify rendering, selection, holds, and best-available behavior without a live event key. ```bash git clone --branch v0.12.0 --depth 1 https://github.com/seatlayer/seatlayer-flutter.git cd seatlayer-flutter/example flutter pub get flutter run --dart-define=SEATLAYER_EVENT="" \ --dart-define=SEATLAYER_PUBLIC_KEY="" ``` The fixture does not simulate live inventory or production checkout. Connect a SeatLayer test event and your backend when validating expiry, contention, access, payment, and booking. ## Advanced: the raw seat map Use `SeatLayerView` with `SeatLayerController` only when your application wants to own every part of the buyer experience, including chrome, selection presentation and hold orchestration, without the picker's state model. It preserves the `0.2.x` API and remains source-compatible in `0.3`: ```dart SeatLayerView( controller: controller, configuration: SeatLayerConfiguration( event: '', publicKey: '', currency: 'USD', ), onReady: (info) => debugPrint('SeatLayer ready: ${info.mode.raw}'), ) ``` Raw commands include `hold`, `resumeHold`, `extendHold`, `release`, `releaseLabels`, `bestAvailable`, `holdGA`, `getSelection`, `clearSelection`, `getCurrentHold`, `setFloor`, `setViewMode`, `getViewMode`, `zoomIn`, `zoomOut`, `zoomToFit`, and `destroy`. Typed streams include `onReady`, `onSelectionChanged`, `onHold`, `onHoldRestored`, `onHoldExpired`, `onBuyerAccessExpired`, `onSelectedObjectsUnavailable`, `onError`, and `onUnknownEvent`. Unknown future enum values and events stay forward-compatible instead of crashing an older application. Most integrations should not start here. Rung 3, [building your own layout](/buyer-sdk/flutter/custom-layout) from the picker widgets, gives the same freedom of arrangement while keeping holds, validation, and the checkout handoff. Use the dedicated [raw-map guide](/buyer-sdk/flutter/raw-map) when that lower-level surface is intentional. ## Product scope Flutter `0.12.0` accepts one published Event key per picker. The browser SDK has separate [Performance Group](/buyer-sdk/performance-groups) and [Season](/buyer-sdk/seasons) products; this Flutter release does not document either as a native configuration. See the shared [mobile product boundary](/buyer-sdk/mobile#product-scope). ## Integration checklist - Give the picker a full-screen route or a parent with bounded height. - Keep one controller for one picker lifecycle and dispose it with the screen. - Pin an exact package version and read the changelog before moving it. - Preserve the hold id across checkout navigation or app suspension if your product promises restoration. - Test rotation, safe areas, keyboards, back navigation, suspension, and resume in both light and dark. - Verify selection, hold, expiry, release, conflict, and booking with a test event. - Smoke-test supported physical iOS and Android devices before rollout. ## Frequently asked questions ### Is SeatLayer a Flutter widget or a JavaScript snippet? It is a native Flutter picker, and every control is a Flutter widget. `SeatLayerPicker` and every widget around it are ordinary Flutter widgets with a typed Dart controller: the header, legend, dock, confirm card, cart sheet, and checkout button are all native Flutter, and your app talks to the SeatLayer venue map through Dart commands and callbacks rather than an untyped JavaScript snippet. ### Do I have to use the drop-in layout? No. `SeatLayerPicker` is one call, but every widget it composes is exported and works standalone inside a `SeatLayerPickerScope`. You can hide parts, replace one through a builder, restyle a single control through a style slot, or build the whole layout yourself and still get the same holds, validation, and checkout handoff. ### Which Flutter platforms are supported? iOS and Android are supported. The package does not currently advertise Flutter web or desktop support. ### Does the picker follow my app's dark mode? Yes. From `0.3.1`, `SeatLayerThemeMode.auto` reads your app's theme first and the device only as a fallback, and both readings are live: flipping either repaints the chrome and the drawn map together, without a reload and without losing the selection. `.light` and `.dark` pin one side. ### Can I brand it from my existing color scheme? Yes. `SeatLayerPickerThemeData.of(context)` or `.fromColorScheme(scheme)` maps a Material palette onto the whole picker in one call. Ticket-category colors are deliberately untouched, because they stand for prices set by the organizer. ### What languages does the picker speak? The drawn map ships 37 languages, and `SeatLayerPickerStrings.forLocale` gives the native chrome the same reviewed wording. Any entry with no translation keeps its English default, and every string remains individually overridable. ### Can I evaluate the seat picker without a live event? Yes. Run the repository example to exercise the picker, the venue map, selection, holds, and best-available behavior against the offline fixture. Use a test event for live availability, expiry, access, conflict, and checkout validation. ### Does the Flutter application book seats or process payment? No. It selects inventory and creates a temporary hold. Your trusted backend inspects the hold, processes the order, and books the seats. ### Does the Flutter SDK include a 3D seating chart? Yes. `SeatLayerVenue3D` is a real, lazily loaded 3D venue scene with its own native caption, seat stepper, and exits, alongside the authored or chart-derived view from a seat. The map stays interactive while the scene loads, and unsupported devices keep the complete 2D flow instead of showing a dead control. `enable3D`, `enableSeatView`, and `max3DSeats` constrain or disable it. ### Is the browser buyer demo a live Flutter demo? No. It demonstrates the wider SeatLayer buyer journey in a browser. The repository example and the iOS Simulator capture above are the Flutter-specific proof. ## Next steps - [Understand the architecture](/buyer-sdk/flutter/architecture) - [Customise the picker](/buyer-sdk/flutter/customise) - [Build your own layout](/buyer-sdk/flutter/custom-layout) - [Handle lifecycle and recovery](/buyer-sdk/flutter/lifecycle-and-recovery) - [Use the raw map](/buyer-sdk/flutter/raw-map) - [Troubleshoot mobile integrations](/buyer-sdk/mobile#troubleshooting) - [Install exact Flutter `0.12.0`](https://pub.dev/packages/seatlayer/versions/0.12.0) - [Browse the tagged configuration API](https://github.com/seatlayer/seatlayer-flutter/blob/v0.12.0/lib/src/seat_layer_configuration.dart) - [Browse the tagged controller API](https://github.com/seatlayer/seatlayer-flutter/blob/v0.12.0/lib/src/seat_layer_controller.dart) - [Read the `v0.12.0` changelog](https://github.com/seatlayer/seatlayer-flutter/blob/v0.12.0/CHANGELOG.md) - [Connect holds to secure checkout](/buyer-sdk/holds-and-checkout) - [Compare every mobile SDK](/buyer-sdk/mobile) # Architecture > How the Flutter picker is layered: native widgets, the scope and controller, one read-only picker state, and the drawn SeatLayer venue map. Source: https://docs.seatlayer.io/buyer-sdk/flutter/architecture/ · Markdown: https://docs.seatlayer.io/buyer-sdk/flutter/architecture/index.md The Flutter picker is one native widget tree over one drawn map, joined by a single seam. Understanding that seam is what makes the customisation and composition guides obvious rather than magical. ## The layers ![The Flutter picker stack: the ready-made SeatLayerPicker and your own composition both sit on SeatLayerPickerScope, which owns the controller and picker state; those drive the SeatLayer venue map and its API operations.](/diagrams/flutter-layers.svg) | Layer | What lives there | Who owns it | | --- | --- | --- | | Your app | Routes, navigation, checkout, analytics | You | | Picker widgets | Header, legend, dock, confirm card, cart, 3D chrome | Flutter, yours to change | | Scope and controller | `SeatLayerPickerScope`, `SeatLayerPickerController`, `SeatLayerPickerState` | The SDK | | Venue map | The drawn venue, pan, pinch, 3D scene | SeatLayer | | API | Live inventory, holds, best available | SeatLayer | Two rules bind the whole design: 1. **The venue map owns the venue; native owns the chrome.** The map knows Flutter owns the furniture, so it draws no seat tooltip, test-mode badge, or attribution of its own. Exactly one of each is drawn in Flutter. 2. **A row name may already contain its section.** The SDK prints row labels with the section prefix removed, so a card reads `Stalls D · Row C` rather than `Stalls D · Row Stalls D C`. ## Picker state is the seam State reaches Flutter as a complete replacement, never a patch. Dart ignores stale state and serialises inventory-changing actions, so a repeated checkout tap cannot produce two holds. | State field | What it carries | | --- | --- | | `event` | `key`, `name`, `venue`, `mode`, `currency`, `startsAt`, `salesClosed` | | `branding` | `brandName`, `logoUrl`, `attributionRequired`, organizer palette | | `categories` | `key`, `label`, `color`, `priceMin`, `priceMax`, `available`, `tiers` | | `sections` | `id`, `label`, `displayLabel`, `zoneId`, `color`, `dominantCategoryKey`, `seatsLeft`, `priceMin`, `priceMax` | | `zones` | `id`, `label`, `color`, plus `bestAvailableZones` | | `map` | `rung`, `focusedSectionId`, `focusedSection`, `viewMode`, `buyerView`, `floors`, `activeFloorId`, `floorMode`, `categoryFilter`, `accessibilityFilter`, `colorblindSafe`, `canZoomIn`, `canZoomOut`, `viewportInsets` | | `selection` | `SelectedSeat` per seat: label, section, row, seat number, price, tiers | | `cartLines` | `SeatLayerCheckoutLineItem` per line, with `cartTotal` and `ticketCount` | | `hold` | `active`, `expiresAt`, `owner`. Never the hold id | | `capabilities` | Which optional features are available on this event | | `accessStatus` | Whether buyer access is configured, and why not | The map state's `rung` is the vocabulary the whole picker navigates by: `venue` is the overview, and `seats` means a section is focused and its seats are revealed. > **The hold id is not in picker state** > > `hold` reports only whether a hold is active, when it expires, and whether the > picker or your app owns it. The `holdId` itself crosses into Dart exactly once, > in `SeatLayerCheckoutHandoff`, so no widget or log line can leak it by > accident. ## Commands go the other way `SeatLayerPickerController` is a `ValueNotifier` and the only thing that drives the map. Its surface is typed Dart, not raw strings: | Group | Methods | | --- | --- | | Navigation | `focusSection`, `overview`, `setRung`, `setFloor`, `showAllFloors` | | Selection | `selectObjects`, `deselectObjects`, `selectCategories`, `deselectCategories`, `removeObject`, `clearSelection`, `setSelectableObjects`, `setMaxSelection`, `setSeatTier` | | Filters | `setCategoryFilter`, `setAccessibilityFilter`, `setLimitedViewHidden`, `setColorblindSafe` | | View | `setViewMode`, `setBuyerView`, `showSeatIn3D`, `openSeatView`, `set3DNavigationMode`, `zoomIn`, `zoomOut`, `zoomToFit` | | Inventory | `bestAvailable`, `setGeneralAdmissionQuantity`, `setTableQuantity`, `resumeHold`, `extendHold` | | Handoff | `checkout`, `rejectCheckoutHandoff`, `releasePickerOwnedHold` | | Surface | `setViewportInsets`, `setMapInteractionEnabled`, `setCartSheetExpanded`, `setThemeMode` | | Lifecycle | `synchronize`, `retry`, `setLifecycle`, `close`, `destroy`, `dispose` | Read state without owning a controller through the scope's static lookups: `SeatLayerPickerScope.stateOf(context)`, `SeatLayerPickerScope.controllerOf(context)`, `SeatLayerPickerScope.brightnessOf(context)`, and `SeatLayerPickerScope.stringsOf(context)` among them. See [building your own layout](/buyer-sdk/flutter/custom-layout) for how a custom widget uses them. ## From a tap to a handoff ![Sequence across four lanes, one each for buyer, native chrome, drawn map and your server: the buyer taps a seat, the map raises a selection event, picker state updates and the native confirm card appears while the map is made inert, the buyer confirms, selection and hold actions run, state updates with the hold, and checkout returns a typed handoff to your callback.](/diagrams/flutter-sequence.svg) Two details in that flow are load-bearing. **The map is made inert, not just ignored.** While native decision chrome is up, the picker makes the map itself inert, and a Flutter `IgnorePointer` sits over it as a second layer. Both are required: the map surface can be hit-tested beneath composited Flutter chrome even when the Flutter child ignores pointers, so the originating tap would otherwise select a second seat underneath. As soon as the prompt closes, the map owns pan and pinch again. **Native chrome tells the map where it can draw.** `setViewportInsets` reports how much of the map surface your chrome covers, so the map frames a focused section, the venue overview, and a best-available result into the part the buyer can actually see. It is a framing inset, not a clip. The venue still draws and pans underneath. The drop-in reports its own rail and dock automatically; a custom layout reports its own. ## The back-navigation ladder ![The back ladder: an expanded cart sheet collapses to its peek, an open confirm card dismisses, a focused section returns to the overview, and only at the overview is your route allowed to pop.](/diagrams/flutter-back-ladder.svg) One `PopScope` owns all four rungs, so Android predictive back and the iOS edge swipe take the same path a buyer expects. A hold already handed to your app is never released by a close, because ownership moved with the handoff. ## The picker fails closed The SDK reports which optional capabilities are available for the current Event. When a feature is unavailable, its control is absent rather than decorative. The picker never shows a button that silently does nothing. That gating is what makes new features additive. `SeatLayerFloorStrip` draws nothing on a venue with fewer than two floors; `SeatLayerSeatViewChrome` prints the seat-view caption natively only where the map has handed those words over, and otherwise leaves the map to draw them. An app pinned to an older release behaves exactly as it did before. ## Where credentials live Buyer access tokens stay in memory. They never appear in a URL, picker state, or application event, and the picker's state model has no field that could carry one. Your app never holds a SeatLayer secret; for gated inventory it supplies a callback that asks *your* backend to mint a short-lived native buyer session. ## Next steps - [Customise the picker](/buyer-sdk/flutter/customise) - [Build your own layout](/buyer-sdk/flutter/custom-layout) - [Back to the Flutter quick start](/buyer-sdk/flutter) # Build your own layout > Place SeatLayerPickerScope and arrange the picker widgets yourself, reading state and issuing commands while SeatLayer keeps holds and checkout. Source: https://docs.seatlayer.io/buyer-sdk/flutter/custom-layout/ · Markdown: https://docs.seatlayer.io/buyer-sdk/flutter/custom-layout/index.md This is rung 3 of the [three ways in](/buyer-sdk/flutter). Reach for it when the *arrangement* has to differ: a persistent sidebar, a map embedded in a tab, a sheet that already exists in your design system. If you only need different colours, wording, sizes, or one replaced part, rung 2 on the [customise page](/buyer-sdk/flutter/customise) is less code and less to maintain. You are rearranging presentation, not reimplementing anything. Inventory, availability, validation, holds, expiry, and the checkout handoff all still come from the same controller. ## Which widget owns which region ![A phone frame mapping each screen region to its widget, with the header at the top, price legend and floor strip over the map, map controls in the corners, confirm card or 3D chrome over the map, dock bar under it, and the cart sheet at the bottom, beside the Flutter tree that produces the same thing.](/diagrams/flutter-custom-layout.svg) ## Step 1: place the scope `SeatLayerPickerScope` starts the session and publishes the state. Everything below it can read that state; nothing above it can. ```dart SeatLayerPickerScope( configuration: SeatLayerConfiguration( event: '', publicKey: '', ), themeMode: SeatLayerThemeMode.auto, options: const SeatLayerPickerOptions(), child: MyPickerLayout(), ) ``` Pass a `controller:` when your own code needs to drive the picker; the scope creates and disposes one otherwise. A controller you create is yours to `dispose()`, and you should `await controller.close()` before deliberately removing an inline picker, so a picker-owned hold is acknowledged as released. ## Step 2: arrange the widgets ```dart class MyPickerLayout extends StatefulWidget { const MyPickerLayout({super.key}); @override State createState() => _MyPickerLayoutState(); } class _MyPickerLayoutState extends State { bool expanded = false; @override Widget build(BuildContext context) { return Column( children: [ SeatLayerPickerHeader(compact: true, onClose: () => Navigator.pop(context)), Expanded( child: Stack( children: [ const Positioned.fill(child: SeatLayerChart()), const Positioned( top: 8, left: 0, child: SeatLayerPriceLegend(compact: true), ), const Positioned(top: 40, left: 0, child: SeatLayerFloorStrip()), const Positioned.fill( child: SeatLayerPickerMapControls(compact: true), ), const Positioned.fill(child: SeatLayerVenue3D()), const Positioned(left: 0, right: 0, bottom: 0, child: SeatLayerDockBar()), ], ), ), SeatLayerCartSheet( expanded: expanded, onExpandedChanged: (value) => setState(() => expanded = value), onCheckout: (handoff) => bookOnYourBackend(handoff.holdId), ), const SeatLayerPickerAttribution(), ], ); } } ``` `SeatLayerChart` needs bounded space and owns pan and pinch. Give it an `Expanded`, a `SizedBox` with a definite height, or a full-screen route, never a competing gesture-driven scroll view. > **Two things you must include** > > A manual composition must still render `SeatLayerPickerAttribution` and > `SeatLayerPickerTestModeIndicator`. They are required chrome, and the drop-in > places them for you; when you own the layout, you place them. ## Step 3: read state in your own widgets The scope exposes static lookups rather than a single `of`. Each one subscribes your widget to exactly what it asked for. | Lookup | Returns | | --- | --- | | `SeatLayerPickerScope.stateOf(context)` | `SeatLayerPickerState`, the current read-only picker state | | `SeatLayerPickerScope.controllerOf(context)` | `SeatLayerPickerController` | | `SeatLayerPickerScope.configurationOf(context)` | `SeatLayerConfiguration` | | `SeatLayerPickerScope.optionsOf(context)` | `SeatLayerPickerOptions` | | `SeatLayerPickerScope.themeOf(context)` | `SeatLayerPickerThemeData?` | | `SeatLayerPickerScope.themeModeOf(context)` | `SeatLayerThemeMode` | | `SeatLayerPickerScope.brightnessOf(context)` | `Brightness`, the resolved side | | `SeatLayerPickerScope.stringsOf(context)` | `SeatLayerPickerStrings` | | `SeatLayerPickerScope.callbacksOf(context)` | `SeatLayerPickerCallbacks` | | `SeatLayerPickerScope.cartSheetExpandedOf(context)` | `bool` | A custom section list, in full: ```dart class MySectionList extends StatelessWidget { const MySectionList({super.key}); @override Widget build(BuildContext context) { final state = SeatLayerPickerScope.stateOf(context); final picker = SeatLayerPickerScope.controllerOf(context); final strings = SeatLayerPickerScope.stringsOf(context); return ListView( children: [ for (final section in state.snapshot?.sections ?? const []) ListTile( title: Text(section.displayLabel ?? section.label), subtitle: section.seatsLeft == null ? null : Text(strings.seatsLeft(section.seatsLeft!)), selected: section.id == state.snapshot?.map.focusedSectionId, onTap: () => picker.focusSection(section.id), ), TextButton( onPressed: picker.overview, child: Text(strings.overview), ), ], ); } } ``` `SeatLayerPickerController` is a `ValueNotifier`, so a `ValueListenableBuilder` works too when you hold the controller directly. ## Step 4: drive the picker The commands a custom layout reaches for most: | You want | Call | | --- | --- | | Focus a section | `picker.focusSection(sectionId)` | | Go back to the venue | `picker.overview()` | | Move to a floor | `picker.setFloor(floorId)` · `picker.showAllFloors()` | | Select or drop seats | `picker.selectObjects([...])` · `picker.deselectObjects([...])` | | Select a whole category | `picker.selectCategories([...])` · `picker.deselectCategories([...])` | | Remove one ticket | `picker.removeObject(objectId)` | | Constrain the buyer | `picker.setSelectableObjects([...])` · `picker.setMaxSelection(4)` | | Filter the map | `picker.setCategoryFilter({'stalls'}, focus: true)` | | Find seats | `picker.bestAvailable(quantity: 4, categoryKey: 'stalls')` | | Switch view | `picker.setBuyerView(SeatLayerBuyerView.venue3D)` for 3D. `picker.setViewMode(mode)` accepts only flat; older values show the flat map | | Inspect one seat | `picker.showSeatIn3D(seat)` · `picker.openSeatView(seat)` | | Restore a hold | `picker.resumeHold(holdId)` · `picker.extendHold()` | | Take the handoff | `await picker.checkout()` | Every method returns a `Future`, and inventory-changing calls are serialised, so a double tap cannot produce two holds. ## Step 5: tell the map where your chrome is The SDK frames a focused section, the venue overview, and a best-available result into the part of the map the buyer can actually see. Report what your chrome covers, or those framings will centre behind it: ```dart SeatLayerPickerScope.setViewportInsets( context, const SeatLayerViewportInsets(top: 96, bottom: 140), ); ``` `SeatLayerViewportInsets` has `top`, `right`, `bottom`, and `left`, all defaulting to `0`, plus `SeatLayerViewportInsets.zero`. It is a framing inset, not a clip. The venue still draws and pans underneath. The applied value comes back as `viewportInsets` inside the public state's `map` field. `SeatLayerPickerController.setViewportInsets` is the same call when you hold the controller. ## Step 6: lock the map behind your own prompt Whenever your own chrome covers the map, disable map interaction for exactly as long as it is up, and restore it in a `finally`: ```dart await picker.setMapInteractionEnabled(false); try { await showMyNativeSeatPrompt(); } finally { await picker.setMapInteractionEnabled(true); } ``` This makes the map itself inert. Keep a Flutter `IgnorePointer` over your chrome as well, because the map surface can still be hit-tested beneath composited Flutter widgets, so the tap that opened your prompt cannot select a seat underneath it. Do not wrap the map in an app-level drag or scale recogniser, and do not forward raw touch coordinates to it. Both compete with the map and break its tap-versus-pan suppression. ## Step 7: own the back ladder The drop-in ships a `PopScope` that collapses the sheet, then dismisses the confirm card, then returns to the overview, before letting your route pop. A custom layout owns that ladder itself: ```dart PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) async { if (didPop) return; final map = SeatLayerPickerScope.stateOf(context).snapshot?.map; if (expanded) { setState(() => expanded = false); } else if (map?.focusedSectionId != null) { await picker.overview(); } else { if (context.mounted) Navigator.of(context).pop(); } }, child: MyPickerLayout(), ) ``` ## Step 8: close cleanly Before removing an inline picker you control, await `close()` so a picker-owned hold is acknowledged as released, then dispose a controller you created: ```dart await picker.close(); picker.dispose(); ``` A hold already handed to your app through `onCheckout` is **not** released, because ownership moved with the handoff. Process termination cannot guarantee a release, so the server-side TTL remains the final safety boundary. ## Next steps - [Customise the picker instead](/buyer-sdk/flutter/customise) - [Understand the architecture](/buyer-sdk/flutter/architecture) - [Back to the Flutter quick start](/buyer-sdk/flutter) # Customise the picker > Every knob on the ready-made Flutter picker: theme modes, colour schemes, style slots, chrome switches, layout sizes, strings, builders, and callbacks. Source: https://docs.seatlayer.io/buyer-sdk/flutter/customise/ · Markdown: https://docs.seatlayer.io/buyer-sdk/flutter/customise/index.md This is rung 2 of the [three ways in](/buyer-sdk/flutter): the same `SeatLayerPicker` widget, changed to look and read like your product. Nothing here requires you to lay out a single widget. ![Four depths of customisation on the same widget: recolour everything at once from a theme or ColorScheme, restyle one control through a style slot, hide or resize a part through chrome options and layout sizes, or replace one part entirely through a builder slot.](/diagrams/flutter-customisation-levels.svg) Every parameter below is accepted by `SeatLayerPicker`, `SeatLayerPickerPage`, `showSeatLayerPicker`, and `SeatLayerPickerScope` alike. ## The parameters at a glance | Parameter | Type | Default | What it changes | | --- | --- | --- | --- | | `configuration` | `SeatLayerConfiguration` | required | Event, credentials, currency | | `onCheckout` | `SeatLayerCheckoutCallback` | required | Where the hold is handed off | | `themeMode` | `SeatLayerThemeMode` | `.auto` | Light or dark side | | `theme` | `SeatLayerPickerThemeData?` | `null` | Colours, radii, typeface, sizes, style slots | | `options` | `SeatLayerPickerOptions` | `const …()` | Behaviour, chrome switches, strings, haptics | | `builders` | `SeatLayerPickerBuilders` | `const …()` | Replacements for individual parts | | `callbacks` | `SeatLayerPickerCallbacks` | `const …()` | Everything that happens, reported | | `controller` | `SeatLayerPickerController?` | `null` | Drive the picker from your own code | | `onClose` | `VoidCallback?` | `null` | The buyer dismissed it | ## Recipes Each recipe is complete on its own. Combine as many as you need. ## Walkthrough: a branded native picker without rebuilding inventory logic This page is for a Flutter buyer surface that should look like the host app while retaining SeatLayer's live selection, hold, recovery, and checkout handoff. It composes ordinary Flutter chrome around the shared SeatLayer venue renderer; it is not a fully native replacement for the venue engine. ```dart title="branded_ticket_screen.dart" import 'package:flutter/material.dart'; import 'package:seatlayer/seatlayer.dart'; Future openHostCheckout(String holdId) async { // Post this opaque ID to a trusted server; do not post browser prices or seats. debugPrint('Create checkout for hold $holdId'); } class BrandedTicketScreen extends StatelessWidget { const BrandedTicketScreen({super.key, required this.eventKey}); final String eventKey; @override Widget build(BuildContext context) => SeatLayerPicker( configuration: SeatLayerConfiguration( event: eventKey, publicKey: '', ), themeMode: SeatLayerThemeMode.auto, theme: const SeatLayerPickerThemeData( accent: Color(0xFFE54558), radius: 16, ), options: const SeatLayerPickerOptions( chrome: SeatLayerPickerChromeOptions(showPriceRail: false), strings: SeatLayerPickerStrings(holdAndCheckout: 'Continue'), ), builders: SeatLayerPickerBuilders( confirmCard: (context, part) => DecoratedBox( decoration: const BoxDecoration(color: Color(0xFFFFF7F7)), child: part.defaultChild, ), ), onCheckout: (handoff) => openHostCheckout(handoff.holdId), ); } ``` Replace the proof-only `debugPrint` in `openHostCheckout` with the app's trusted backend call. That backend inspects the hold, calculates the payable total, takes payment, and books with its stable `bookingRef`. Do not change client display formatting or the confirmation card into a source of inventory or price truth. For a layout that moves whole native parts, continue to [build your own layout](/buyer-sdk/flutter/custom-layout/); the map, one picker state, and the hold contract remain shared. ### Brand it from your app's colours ```dart SeatLayerPicker( configuration: configuration, theme: SeatLayerPickerThemeData.of(context), onCheckout: openCheckout, ) ``` `.of(context)` reads your `Theme.of(context).colorScheme` and body typeface; `.fromColorScheme(scheme)` takes a scheme you build yourself. Either way `primary` and `onPrimary` become the accent and its ink, so `Continue`, `Select`, `Find N best seats`, the hold pill, and the Map/3D control all carry your brand at once, while `surface`, `onSurface`, `onSurfaceVariant`, `outlineVariant`, and `error` become the grounds, ink, hairlines, and failures. Because a `ColorScheme` is a complete ground palette, this pins the picker to the side that scheme is on. That is what you want, since it came from your theme, and `.of(context)` follows your own dark-mode switch on rebuild. ### Brand it with one colour, and keep following the device ```dart SeatLayerPicker( configuration: configuration, themeMode: SeatLayerThemeMode.auto, theme: const SeatLayerPickerThemeData(accent: Color(0xFFE54558)), onCheckout: openCheckout, ) ``` The default constructor sets only the roles you name, so every ground role still comes from `themeMode`. Use it when you want a brand accent *and* live light and dark. > **A preset pins the mode** > > `SeatLayerPickerThemeData.light()` and `.dark()` supply a whole explicit ground > palette, and explicit roles win over the resolved mode, so `themeMode: auto` > with `.light()` never goes dark. Reach for a preset when you want one fixed > side, and pick between them yourself if you want both. ### Square off one button, keep the rest ```dart theme: SeatLayerPickerThemeData.light( styles: SeatLayerPickerStyles( continueButtonStyle: FilledButton.styleFrom( shape: const RoundedRectangleBorder(), ), ), ), ``` Or restyle that one instance where it is placed, which wins over the theme: ```dart SeatLayerDockBar( style: const SeatLayerSurfaceStyle(shape: RoundedRectangleBorder()), ) ``` ### Hide the price legend ```dart options: const SeatLayerPickerOptions( chrome: SeatLayerPickerChromeOptions(showPriceRail: false), ), ``` ### Replace the confirm card with your own ```dart builders: SeatLayerPickerBuilders( confirmCard: (context, part) => MyConfirmCard( state: part.state, controller: part.controller, fallback: part.defaultChild, ), ), ``` The builder receives the immutable state, the live controller, and the widget the drop-in would have rendered, so you can decorate the default, or ignore it and draw your own, calling `part.controller.selectObjects([...])` yourself. ### Speak the buyer's language ```dart options: SeatLayerPickerOptions( strings: SeatLayerPickerStrings.forLocale(Localizations.localeOf(context)), ), ``` ### Reword one line ```dart options: SeatLayerPickerOptions( strings: SeatLayerPickerStrings( holdAndCheckout: 'Réserver et payer', seatsLeft: (count) => '$count restants', ), ), ``` ### Ship dark only ```dart SeatLayerPicker( configuration: configuration, themeMode: SeatLayerThemeMode.dark, theme: const SeatLayerPickerThemeData.dark(accent: Color(0xFFFF5A6F)), onCheckout: openCheckout, ) ``` ### Everything at once ```dart SeatLayerPicker( configuration: configuration, themeMode: SeatLayerThemeMode.auto, theme: SeatLayerPickerThemeData.light( accent: const Color(0xFFE54558), onAccent: Colors.white, radius: 14, buttonRadius: 0, fontFamily: 'YourSans', layout: const SeatLayerPickerLayout(dockBarHeight: 60), styles: SeatLayerPickerStyles( continueButtonStyle: FilledButton.styleFrom( shape: const RoundedRectangleBorder(), ), ), ), options: SeatLayerPickerOptions( chrome: const SeatLayerPickerChromeOptions(showPriceRail: false), strings: SeatLayerPickerStrings.forLocale(Localizations.localeOf(context)), ), builders: SeatLayerPickerBuilders( cartSheet: (context, part) => MyOwnSheet(state: part.state, fallback: part.defaultChild), ), callbacks: SeatLayerPickerCallbacks( onSeatSelected: (seat) => analytics.log('seat', seat.label), ), onCheckout: openCheckout, ) ``` ## Theme modes `themeMode` takes `SeatLayerThemeMode.auto`, `.light`, or `.dark`. The resolved theme updates the native controls and drawn venue together while keeping the selection, focused section, and camera. There is no reload. **Precedence: an explicit `themeMode`, then your app's theme, then the device.** From `0.3.1`, `auto` reads `Theme.of(context).brightness` first, the switch a buyer actually moves inside your app, which a Cupertino theme reports too. It falls back to `MediaQuery.platformBrightness` only where there is no Material or Cupertino theme above the picker. Both readings are live. `SeatLayerPickerCallbacks.onThemeResolved` reports the side that won. From `0.3.1` the picker also dresses the device's status and navigation bars from the resolved palette, because that surface is the picker's, and forces the dark style for the immersive 3D scene whatever side the picker is painted on. Opt out with `SeatLayerPickerChromeOptions(manageSystemOverlays: false)`; `seatLayerPickerOverlayStyle(resolvedTheme)` is exported so a host that opts out can still ask what the picker would have set. ## `SeatLayerPickerThemeData` Every field is optional. Unset roles come from the resolved mode. | Field | Type | Role | | --- | --- | --- | | `accent` | `Color?` | Primary actions, hold pill, active controls | | `onAccent` | `Color?` | Ink on the accent | | `background` | `Color?` | The recessed page behind the chrome | | `surface` | `Color?` | Cards, sheets, bars | | `text` | `Color?` | Primary ink | | `mutedText` | `Color?` | Secondary ink | | `divider` | `Color?` | Hairlines | | `error` | `Color?` | Failures and recoverable errors | | `warning` | `Color?` | Commercial warnings on ticket lines | | `fontFamily` | `String?` | The chrome's typeface | | `radius` | `double?` | Cards, sheets, surfaces | | `buttonRadius` | `double?` | Every action, as its own role | | `logo` | `ImageProvider?` | Header mark | | `mapTheme` | `SeatLayerMapThemeData?` | The drawn canvas, row labels, selection ring | | `layout` | `SeatLayerPickerLayout?` | Every size | | `styles` | `SeatLayerPickerStyles?` | Per-element style slots | Constructors: the default one above, the `.light()` and `.dark()` presets, `.fromColorScheme(scheme)`, and `.of(context)`. > **Ticket categories are deliberately left alone** > > The price chips, the dock's section dot, and the seats themselves carry the > organizer's categories, which stand for a price. Recolouring them to your > brand would make the dot disagree with the chip it is supposed to match. `buttonRadius` is its own role rather than a fraction of `radius`, so `SeatLayerPickerThemeData.light(radius: 20)` rounds the cards and sheets without growing pill actions. Actions default to 8 logical pixels, which is what the web picker's own buttons measure; exactly three things stay true pills: the hold countdown, the price-legend chips, and the Map/3D segmented control. ## Style slots | Slot | Type | Applies to | | --- | --- | --- | | `primaryButtonStyle` | `ButtonStyle?` | `Select`, `Find N best seats`, primary actions | | `secondaryButtonStyle` | `ButtonStyle?` | `Cancel`, `Back to venue`, secondary actions | | `continueButtonStyle` | `ButtonStyle?` | `Continue` and `Hold seats & checkout` | | `iconButtonStyle` | `ButtonStyle?` | Round map and stepper controls | | `chipShape` | `OutlinedBorder?` | Chip shape across the picker | | `legendChipStyle` | `SeatLayerSurfaceStyle?` | Price-legend chips | | `floorStripStyle` | `SeatLayerSurfaceStyle?` | Floor chips | | `dockBarStyle` | `SeatLayerSurfaceStyle?` | The dock under the map | | `confirmCardStyle` | `SeatLayerSurfaceStyle?` | The seat decision card | | `sheetStyle` | `SeatLayerSurfaceStyle?` | The cart sheet | | `headerStyle` | `SeatLayerSurfaceStyle?` | The header line | | `pillStyle` | `SeatLayerSurfaceStyle?` | The hold countdown pill | | `seatViewChromeStyle` | `SeatLayerSurfaceStyle?` | The seat-view caption strip | Button slots take a Material `ButtonStyle`. Surface slots take a `SeatLayerSurfaceStyle`, whose fields are `color`, `shape`, `elevation`, `padding`, and `textStyle`. Every widget that owns a slot also accepts a `style:` parameter, which wins over the theme for that one instance. ## `SeatLayerPickerLayout` Every number in the design is a default, not a constant. | Field | Default | Field | Default | | --- | ---: | --- | ---: | | `phoneBreakpoint` | 640 | `confirmCardGutter` | 12 | | `wideBreakpoint` | 840 | `confirmCardMaxWidth` | 310 | | `headerHeight` | 38 | `confirmIdentityHeight` | 42 | | `headerLogoSize` | 22 | `confirmPhotoHeight` | 64 | | `topRailHeight` | 44 | `confirmActionHeight` | 44 | | `dockBarHeight` | 52 | `selectorHeight` | 44 | | `peekHeight` | 58 | `accessibilityControlSize` | 36 | | `sheetMaxHeightFraction` | 0.72 | `mapControlSize` | 36 | | `emptyTrayMaxHeight` | 380 | `attributionHeight` | 18 | | `cartCardMinHeight` | 52 | `legendChipFontSize` | 11 | These are the `0.12.0` defaults. The class has more fields for the cart sheet, its handle, and the seat card; see the [tagged layout source](https://github.com/seatlayer/seatlayer-flutter/blob/v0.12.0/lib/src/picker/picker_layout.dart) for the full list. `denseLineHeight` and `denseVisibleLines` were removed in `0.9.0` along with the dense ticket list: the cart now shows one card per ticket. ## `SeatLayerPickerChromeOptions` | Switch | Default | Hides | | --- | --- | --- | | `showHeader` | `true` | The event header line | | `showPriceRail` | `true` | The price legend | | `showFloorSelector` | `true` | The wide-layout floor selector | | `showFloorStrip` | `true` | The phone floor chips | | `showMapControls` | `true` | All corner map controls | | `showOverviewControl` | auto | The explicit overview button | | `showZoomControls` | `true` | The zoom controls | | `showZoomToFitControl` | `true` | Show whole venue | | `showViewModeControl` | `true` | The Map/3D segmented control | | `showColorblindControl` | auto | The colorblind toggle | | `showAccessibilityControl` | `true` | The accessibility sheet | | `showTicketPanel` | `true` | The cart panel | | `showDockBar` | `false` | The dock under the map | | `showConfirmCard` | `true` | The seat decision card | | `showVenue3DChrome` | `true` | The 3D scene's own chrome | | `showSeatViewChrome` | `true` | The native seat-view caption | | `showHoldPill` | `true` | The hold countdown | | `showExtendHoldPrompt` | auto | The "Need more time?" card in the last minute of a hold | | `seatCardGlass` | `true` | The frosted veil behind the seat card | | `manageSystemOverlays` | `true` | Status and navigation bar styling | The switches marked *auto* default to hidden on a phone and shown on a wide layout: the colorblind palette sits inside the phone's accessibility sheet, and the phone shows one hold timer in the header. Since `0.9.1` no layout draws the dock bar unless you set `showDockBar: true`. Pass an explicit boolean to override any default. > **Attribution is not a visibility switch** > > The required `Powered by SeatLayer` line and the single test-event badge have > no chrome switch and no builder. They inherit your theme, while visibility > follows the SeatLayer account configuration and cannot be overridden in the > client. Hiding `showSeatViewChrome` means the host owns that disclosure itself; the SDK does not add a second copy behind the custom interface. ## `SeatLayerPickerOptions` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `layout` | `SeatLayerPickerLayoutMode` | `.adaptive` | Force `.compact` or `.wide` | | `holdTtl` | `Duration?` | `null` | Request a hold window | | `initialHoldId` | `String?` | `null` | Restore a host-owned hold | | `readOnly` | `bool` | `false` | Inspect without changing inventory | | `confirmSelection` | `bool` | `true` | Require the confirm card | | `enableBestAvailable` | `bool` | `true` | Offer Best Seats | | `enable3D` | `bool` | `true` | Offer the immersive venue scene | | `enableSeatView` | `bool` | `true` | Offer the view from a seat | | `max3DSeats` | `int?` | `null` | Cap 3D by seat count | | `hideEventDetails` | `bool` | `false` | Drop the event identity line | | `eventName` | `String?` | `null` | Header title when you want your own | | `panelInitiallyCollapsed` | `bool` | `true` | Open on the peek | | `persistColorblindPreference` | `bool` | `true` | Remember the palette choice | | `refreshOnResume` | `bool` | `true` | Recheck availability when the app returns | | `announceHoldLapse` | `bool` | `true` | Tell the buyer when a hold lapses | | `showBookedOverlay` | `bool` | `true` | Show the booked state after the sale | | `chrome` | `SeatLayerPickerChromeOptions` | `const …()` | The switches above | | `languages` | `List` | `const []` | Offer a language switch | | `pricing` | `SeatLayerPickerPricing?` | `null` | Price display overrides | | `strings` | `SeatLayerPickerStrings` | `const …()` | Every buyer-facing word | | `haptics` | `bool` | `true` | Feedback on selection and expiry | ## Strings and locales `SeatLayerPickerStrings` is a plain value class: every buyer-facing word is a field you can override. Most are `String`: `close`, `chooseSeats`, `overview`, `cancel`, `select`, `viewFromHere`, `venue3D`, `openVenue360`, `backToVenue`, `bestSeats`, `holdAndCheckout`, `accessibility`, `fitVenue`, `fitWholeVenue`, `mapView`, `retry`, `notAvailable`, `undo`, `allFloors`, and more. A few are functions, because they take a value: ```dart final String Function(int count) ticketCount; final String Function(int count) seatsLeft; final String Function(int count) seatsFree; final String Function(int count) findBestSeats; final String Function(int count) moreCount; final String Function(String money) fromPrice; final String Function(String money) continueWithTotal; final String Function(String clock) heldFor; final String Function(List parts) seatIdentity; ``` `accessNeeds` is a `Map` of access-need labels. `SeatLayerPickerStrings.forLocale(locale)` returns the same reviewed wording the drawn map uses, across **37 languages**. It resolves by language, and by script for Chinese. An untranslated locale keeps its English default, as does any single entry the SDK has no wording for, and the result is an ordinary `SeatLayerPickerStrings`, so you can still override any entry on top of it: ```dart final strings = SeatLayerPickerStrings.forLocale( Localizations.localeOf(context), ); // then override just the one line you word differently ``` `allFloors` is the one string the SDK has no dictionary entry for and keeps English in every locale until it does. Override it if you ship a multi-floor venue in another language. ## Motion and haptics Every animation spends a duration from `SeatLayerPickerMotion`, for example `enter`, `exit`, `dock`, `sheet`, `fly`, `pop`, `bump`, `crossfade`, `toast`, and `immersive`. Every one of them collapses under `MediaQuery.disableAnimations`, so a buyer's reduced-motion preference is honoured without a setting of yours. Haptics are one switch, `SeatLayerPickerOptions(haptics: false)`. The cues are `PickerHapticCue.selectionAdded`, `.sectionFocused`, `.holdCreated`, and `.holdExpired`; the last fires from SeatLayer's expiry signal rather than a local timer. ## Callbacks All optional, all on `SeatLayerPickerCallbacks`. | Callback | Payload | Fires when | | --- | --- | --- | | `onReady` | `ReadyInfo` | The chart is ready | | `onChartLoad` | `SeatLayerChartLoad` | One chart load finished, for your own analytics | | `onSelectionChanged` | `List` | The selection changes | | `onSelectionValidityChanged` | `SelectionValidity` | Validation state changes | | `onSeatSelected` | `SelectedSeat` | A seat is added | | `onSeatRemoved` | `String` | A seat is removed | | `onSectionFocused` | `String` | A section is focused | | `onSeatViewOpened` | `SelectedSeat` | The view from a seat opens | | `onHoldChanged` | hold and handoff | A hold is created or replaced | | `onHoldExpired` | None | The server expired the hold | | `onBooked` | `SeatLayerCheckoutHandoff` | The handed-off hold settled to booked | | `onContinue` | `SeatLayerCheckoutHandoff` | Checkout is confirmed | | `onThemeResolved` | `Brightness` | The resolved side changes | | `onAccessExpired` | `BuyerAccessExpiredEvent` | Buyer access lapsed | | `onAccessUnavailable` | `BuyerAccessUnavailableEvent` | Access could not be granted | | `onSelectedObjectUnavailable` | `SelectedObjectUnavailableEvent` | A held seat was taken | | `onError` | `SeatLayerError` | A recoverable action failed | | `onClosed` | `SeatLayerPickerCloseReason` | The picker closed | ## Builder slots Each takes `Widget Function(BuildContext, SeatLayerPickerPartContext)`, and the part context carries `state`, `controller`, and `defaultChild`. `header`, `priceRail`, `legend`, `sectionNavigator`, `floorStrip`, `dockBar`, `accessibilityFilters`, `map`, `mapControls`, `bestAvailable`, `seatConfirmation`, `confirmCard`, `generalAdmissionPrompt`, `tablePrompt`, `selectionTray`, `cartSheet`, `venue3D`, `seatViewChrome`, `holdCountdown`, `actionError`, `checkoutBar`, `loading`, `error`, and `empty`. The overall adaptive layout, the test marker, and the required attribution deliberately have no builder: theme colours and typography stay customisable, but required chrome cannot be hidden by returning an empty widget. ## Next steps - [Build your own layout](/buyer-sdk/flutter/custom-layout) - [Understand the architecture](/buyer-sdk/flutter/architecture) - [Back to the Flutter quick start](/buyer-sdk/flutter) # Lifecycle and recovery > Keep the SeatLayer Flutter 0.12.0 picker current across routes, backgrounding, hold lapse, unavailable seats, and checkout ownership changes. Source: https://docs.seatlayer.io/buyer-sdk/flutter/lifecycle-and-recovery/ · Markdown: https://docs.seatlayer.io/buyer-sdk/flutter/lifecycle-and-recovery/index.md Mobile time is discontinuous. A buyer can background the app, open checkout on another route, lock the device, or return after another buyer has purchased the same seats. SeatLayer Flutter `0.12.0` reconciles those moments with server truth instead of trusting an in-app timer that may not have run. > **Available since seatlayer 0.7.2** > > Foreground availability refresh, hold-lapse recovery, > `SeatLayerPicker.routeObserver`, and > `SeatLayerPickerController.refreshAvailability()` have been included since > `0.7.2`. The current release is `0.12.0`. ## Ready-made picker behavior The drop-in picker observes application lifecycle automatically. When it returns to the foreground it: 1. reports foreground lifecycle to SeatLayer; 2. applies any hold-lapse or lost-inventory state already available; 3. optionally asks for a fresh availability read; and 4. reconciles authoritative picker state before accepting another buyer action. Selection, camera, floor, focused section, and cart presentation remain in place unless authoritative inventory makes a selected object unavailable. ## Observe routes as well as app lifecycle Register the SDK route observer when another Flutter route can cover the picker: ```dart MaterialApp( navigatorObservers: [SeatLayerPicker.routeObserver], home: const App(), ); ``` Without it, background/foreground reconciliation still works. The observer adds the route-return half: a picker covered by an in-app checkout or details route can refresh when that route pops. ## Control automatic behavior ```dart SeatLayerPicker( configuration: configuration, options: const SeatLayerPickerOptions( refreshOnResume: true, announceHoldLapse: true, ), callbacks: SeatLayerPickerCallbacks( onHoldExpired: returnBuyerToSelection, onSelectedObjectUnavailable: explainLostInventory, ), onCheckout: (handoff) async { await checkoutBackend.begin(holdId: handoff.holdId); }, ) ``` `refreshOnResume: false` suppresses the SDK's optional explicit availability request. It does **not** discard a lifecycle outcome the SDK already reported; ignoring that result could leave the cart showing inventory the server has released. `announceHoldLapse: false` disables the built-in visible lapse message while state and callbacks still update. Use it when your host provides an equivalent, accessible explanation. ## What happens when a hold lapses When an owned hold is gone, the picker stops its countdown immediately, clears the hold state, calls `onHoldExpired`, and announces the outcome once. Seats that remain available can be offered back through the built-in `SeatLayerHoldLapseNotice`; selecting the recovery action reselects and holds them again. The offer is not a promise. A seat can sell between the availability read and the buyer's recovery tap. That race returns a typed failure and leaves the UI in a truthful unheld state. ## Unavailable selected objects A refresh can discover that another buyer purchased a selected seat while this app was away. The picker deselects that object and reports it through `onSelectedObjectUnavailable`. Do not silently put it back into the cart or calculate payment from the stale local selection. ## Custom layout responsibility `SeatLayerPickerScope` retains the picker state and lifecycle machinery. A custom component can request a read explicitly: ```dart final picker = SeatLayerPickerScope.controllerOf(context); final result = await picker.refreshAvailability(); ``` Use the typed result and current `SeatLayerPickerState`; do not compare local timers. If a completely headless host takes over lifecycle control, it must serialize refresh/synchronize with other inventory-changing actions. ## Hold ownership across navigation Before checkout, a picker-owned hold is released during orderly picker close. After `SeatLayerCheckoutHandoff` crosses into the host, ownership has moved to your app, so dismissing the picker does not release it. If `onCheckout` throws because server validation or navigation failed, the ready picker rejects that exact handoff and returns to a recoverable state. Do not catch the error and pretend checkout opened. Persist an opaque `holdId` only when the product promises checkout restoration. On a new picker session, use `SeatLayerPickerOptions(initialHoldId: ...)`; that restored hold is host-owned and picker cart controls do not release it. ## Optional picker prewarm `SeatLayerPicker.prewarm()` prepares the picker before navigation. It does not contain an event, credential, selection, or hold, so it does not create a hidden buyer session. ```dart @override void initState() { super.initState(); SeatLayerPicker.prewarm(); } @override void dispose() { SeatLayerPicker.cancelPrewarm(); super.dispose(); } ``` Cancel an unused prewarm when the source screen no longer leads to the picker. ## Verification matrix | Scenario | Expected outcome | | --- | --- | | App backgrounds with selection only | Selection stays unless refreshed inventory invalidates it | | App backgrounds with an open hold | Server hold state and expiry replace the paused local timer | | Checkout route covers picker | Route return triggers reconciliation when the observer is registered | | Held seat expires while away | Countdown stops, expiry callback fires, buyer receives one recovery explanation | | Selected seat sells while away | Seat is deselected and reported as unavailable | | Host checkout callback throws | Handoff is rejected and picker remains recoverable | | Host-owned restored hold closes picker | Picker does not release the host's hold | Test these paths on physical iOS and Android devices with a live test event. ## Related pages - [Flutter quick start](/buyer-sdk/flutter) - [Architecture](/buyer-sdk/flutter/architecture) - [Custom layout](/buyer-sdk/flutter/custom-layout) - [Holds and checkout](/buyer-sdk/holds-and-checkout) - [Shared troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) - [Tagged picker controller source](https://github.com/seatlayer/seatlayer-flutter/blob/v0.12.0/lib/src/picker/seat_layer_picker_controller.dart) # Raw map > Use SeatLayerView and SeatLayerController from Flutter 0.12.0 when your application intentionally owns every buyer control and hold transition. Source: https://docs.seatlayer.io/buyer-sdk/flutter/raw-map/ · Markdown: https://docs.seatlayer.io/buyer-sdk/flutter/raw-map/index.md `SeatLayerView` is the low-level Flutter map. It remains source-compatible with the original `0.2.x` surface inside `seatlayer: 0.12.0`. Choose it only when your app will own confirmation, cart, hold timing, validation messages, test-mode disclosure, required attribution, recovery, and checkout. If you want a different arrangement without reimplementing those rules, use [`SeatLayerPickerScope`](/buyer-sdk/flutter/custom-layout) instead. ## Create and own the controller ```dart title="raw_seat_map.dart" import 'dart:async'; import 'package:flutter/material.dart'; import 'package:seatlayer/seatlayer.dart'; class RawSeatMap extends StatefulWidget { const RawSeatMap({super.key}); @override State createState() => _RawSeatMapState(); } class _RawSeatMapState extends State { final controller = SeatLayerController(); StreamSubscription? holdExpired; @override void initState() { super.initState(); holdExpired = controller.onHoldExpired.listen((_) { returnBuyerToSelection(); }); } @override void dispose() { holdExpired?.cancel(); controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SeatLayerView( controller: controller, configuration: SeatLayerConfiguration( event: '', publicKey: '', currency: 'USD', ), onReady: (info) => debugPrint('SeatLayer mode: ${info.mode.raw}'), onLoadError: showLoadError, ); } } ``` The controller and configuration are required. Keep one controller for the mounted view lifecycle, and give the map a definite height or full-screen parent. For gated inventory, replace `publicKey` with the renewable provider: ```dart final configuration = SeatLayerConfiguration( event: 'ev_private', buyerAccessTokenProvider: (request) => buyerBackend.mintSeatLayerAccess(request.reason), ); ``` Your backend creates the native buyer-access session through [buyer access sessions](/server-api/buyer-access-sessions), returns only its short-lived token to the authenticated app, and treats SDK access details as opaque. It never exposes a SeatLayer secret. ### Configuration map | Concern | `SeatLayerConfiguration` fields | | --- | --- | | Identity and access | `event`, `publicKey`, `buyerAccessToken`, `buyerAccessTokenProvider` | | Selection policy | `maxSelection`, `selectedObjects`, `selectableObjects`, `numberOfPlacesToSelect`, `selectionValidators` | | Presentation | `locale`, `messages`, `currency`, `colorblindSafe`, `initialView`, `showsWebSeatTooltip` | | Operations | `commandTimeout`, `handshakeTimeout`, `hostInfo` | See the [complete tagged constructor and field documentation](https://github.com/seatlayer/seatlayer-flutter/blob/v0.12.0/lib/src/seat_layer_configuration.dart) for types, defaults, and validation. ## Common command groups This table is a task-oriented map, not a substitute for the complete tagged API. | Group | `SeatLayerController` methods | | --- | --- | | Read | `getSelection`, `getSelectionValidity`, `getCurrentHold`, `getGAAreas`, `getFloors`, `getViewMode` | | Select | `selectObjects`, `deselectObjects`, `clearSelection`, `selectCategories`, `deselectCategories`, `setSelectableObjects`, `setMaxSelection`, `setSeatTier` | | Hold | `hold`, `resumeHold`, `extendHold`, `release`, `releaseLabels`, `bestAvailable`, `holdGA` | | Access | `refreshAccess` | | Map | `setFloor`, `setColorblindSafe`, `setViewMode`, `zoomIn`, `zoomOut`, `zoomToFit` | | Session | `destroy`, `dispose` | Use the [tagged `SeatLayerController` source](https://github.com/seatlayer/seatlayer-flutter/blob/v0.12.0/lib/src/seat_layer_controller.dart) for exact parameters, return types, and every method in `0.12.0`. Feature controls should also respect reported capability state; a public method does not prove a particular Event supports the operation. Commands return `Future`s and throw `SeatLayerError` at the awaited call site. ```dart try { final hold = await controller.hold(); if (hold != null) { await checkoutBackend.begin(holdId: hold.holdId); } } on SeatLayerError catch (error) { showInventoryMessage(error.code, error.message); } ``` ## Typed streams Important streams include `onReady`, `onSelectionChanged`, `onSelectionValidityChanged`, `onHold`, `onHoldRestored`, `onHoldExpired`, `onBuyerAccessExpired`, `onBuyerAccessUnavailable`, `onSelectedObjectsUnavailable`, `onGAClick`, `onSeatHover`, `onError`, and `onUnknownEvent`. Subscribe once, retain each `StreamSubscription`, and cancel it from `dispose`. Unknown future enum values and events remain inspectable rather than crashing an older application. | Stream | Payload and host responsibility | | --- | --- | | `onSelectionChanged` | Full current `List`; replace local selection rather than appending | | `onSelectionValidityChanged` | `SelectionValidity`; enable host confirmation only when the configured rules are satisfied | | `onHold`, `onHoldRestored`, `onHoldExpired` | Authoritative hold changes; expiry carries no reusable local hold | | `onBuyerAccessExpired`, `onBuyerAccessUnavailable` | Refresh outcome or access refusal; never silently widen private inventory | | `onSelectedObjectsUnavailable` | Labels and reason for inventory that must be removed from host UI | | `onError`, `onUnknownEvent` | Typed operational failure or forward-compatible raw event for diagnostics | ## Raw-map responsibilities The complete picker normally owns these behaviors. A raw integration must provide them explicitly: - selection confirmation and validation feedback; - general-admission quantity and tier selection; - test-mode disclosure and required attribution; - hold countdown, server-authoritative expiry, resume, and release; - background/foreground reconciliation; - unavailable-inventory recovery; - gesture-safe overlays and accessible controls; - back-navigation behavior; and - secure checkout handoff. Do not duplicate venue behavior by forwarding touch coordinates or placing the map inside another pan/zoom surface. Use typed commands and let the canvas own its gestures. ## Security boundary `HoldResult` contains a display-oriented view of a temporary hold. Send only `holdId` plus your own order context to your backend. The backend inspects the hold, calculates the authoritative price, takes payment, and reuses the same `bookingRef` for retries. ## Related pages - [Flutter quick start](/buyer-sdk/flutter) - [Picker architecture](/buyer-sdk/flutter/architecture) - [Build a custom picker layout](/buyer-sdk/flutter/custom-layout) - [Lifecycle and recovery](/buyer-sdk/flutter/lifecycle-and-recovery) - [Shared troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) - [Tagged runnable example](https://github.com/seatlayer/seatlayer-flutter/tree/v0.12.0/example) - [Tagged changelog](https://github.com/seatlayer/seatlayer-flutter/blob/v0.12.0/CHANGELOG.md) # Handle hold expiry > Configure the checkout window, display trusted remaining time, extend safely, and recover when inventory is released. Source: https://docs.seatlayer.io/buyer-sdk/hold-expiry/ · Markdown: https://docs.seatlayer.io/buyer-sdk/hold-expiry/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. Holds deliberately expire so abandoned carts do not lock inventory indefinitely. Expiry is a normal checkout state, not an exceptional system failure. > **Read the actual expiry** > > The default hold window is 15 minutes, but an event setting or widget request may change it. Drive buyer UI from the returned `expiresAt`; never hardcode a countdown duration. ## TTL precedence SeatLayer resolves a new hold’s lifetime in this order: 1. the event’s configured seat hold duration; 2. `ttlMs` supplied by `hold()` or `bestAvailable()`; or 3. the 15-minute default. An explicit event setting overrides a value embedded in an already-deployed widget, so an organizer can change new holds immediately from the dashboard. Widget values are clamped between one second and 60 minutes. Event-level configuration is clamped between one and 60 minutes. Changes affect future holds, not holds already active. ## Configure an event window ```js title="server/set-checkout-window.js" const response = await fetch( "https://api.seatlayer.io/v1/events//hold-ttl", { method: "POST", headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ holdTtlMs: 20 * 60 * 1000, }), }, ); const { holdTtlMs } = await response.json(); ``` Send `null` to clear the event override. SeatLayer then uses the widget request, or the 15-minute default when the widget sends no duration. Choose a window long enough for the real payment journey and short enough to return abandoned inventory during peak demand. ## Show a countdown `SeatPicker` displays its own countdown. For a headless `SeatingChart`, render from the absolute expiry: ```js title="browser/hold-countdown.js" const hold = await chart.hold(); if (!hold) return handleSelectionConflict(); const timer = window.setInterval(() => { const remainingMs = hold.expiresAt - Date.now(); if (remainingMs <= 0) { window.clearInterval(timer); returnBuyerToSelection(); return; } renderRemainingTime(remainingMs); }, 1000); ``` Also handle the authoritative SDK callback: ```js title="browser/expiry-callback.js" const chart = new seatlayer.SeatingChart({ container: "#chart", event: "", publicKey: "", onHoldExpired: () => { clearCheckoutCart(); returnBuyerToSelection(); }, }); ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. The visible countdown improves expectation-setting; server state remains authoritative. ## Offer more time ```js title="browser/need-more-time.js" const refreshed = await chart.extendHold(); if (refreshed) { renderRemainingTime(refreshed.expiresAt - Date.now()); } else { returnBuyerToSelection(); } ``` A hold can be extended at most three times. Do not automatically renew indefinitely: ask for active buyer intent. ## Recover at each boundary ### Hold failed `hold()` returns `null`. Nothing was reserved. Refresh inventory and let the buyer select again. ### Inspection failed Your server cannot resolve an active hold. Do not create a new charge; return a recoverable checkout response. ### Booking conflicted Booking returns `409`. Void or refund based on payment state and let the buyer reselect. ### Client expired `onHoldExpired` fires. Clear local cart state and explain that the seats were released. ## Buyer-facing copy Prefer: > Your seat reservation expired and the seats were released. Please choose again. Avoid implying that the buyer was charged or that a permanent booking was cancelled when only a hold expired. ## Expiry checklist - [ ] Countdown uses `expiresAt`. - [ ] A warning appears before zero. - [ ] Extension has an intentional buyer action. - [ ] Extension refusal enters the same recovery path as expiry. - [ ] Server booking handles `409`. - [ ] Payment has a tested void/refund path. - [ ] Restored carts revalidate the hold. - [ ] Async order processing can mark an expired hold stale. Continue to [Holds and checkout handoff](/buyer-sdk/holds-and-checkout) or [Idempotency and conflicts](/server-api/idempotency-and-conflicts). # Holds and checkout handoff > Protect a buyer's selection in the browser, hand an opaque hold to your backend, and release or restore it safely. Source: https://docs.seatlayer.io/buyer-sdk/holds-and-checkout/ · Markdown: https://docs.seatlayer.io/buyer-sdk/holds-and-checkout/index.md Yes, the Buyer SDK works with your own checkout and your own payment provider. Your platform keeps the cart, the checkout pages, the payment provider, the commercial orders, and the tickets; SeatLayer only protects the seats while the buyer pays. The hold the browser creates is neither a ticket nor a payment: it is a short, revocable claim on inventory, and your server turns it into a permanent booking after the payment succeeds. A hold temporarily protects inventory while a buyer completes checkout. It is not a sale. The Buyer SDK creates the hold; your server inspects and books it. For box-office, invoice, or partner reservations that never touch a browser, your backend can create one directly with the [server-side hold endpoint](/server-api/holds/). > **Using SeatPicker?** > > `SeatPicker` creates the hold when the buyer uses its checkout action and returns a complete handoff through `onCheckout`. Use the headless `SeatingChart` methods only when your product owns the surrounding selection and checkout controls. ## SeatPicker handoff > **Choose the event's browser-access lane** > > An ordinary Public sale on a Platform/SDK event uses its `publicKey` from an > exact registered Embed domain. Login, presale, partner, and channel audiences > use a short-lived `bse_…` from your authenticated > [`buyerAccessTokenProvider`](/server-api/buyer-access-sessions/); that explicit > credential always wins over `publicKey`. Managed public/unlisted events stay > anonymous and use only the event key. An embed-only Platform event with none > of its applicable credentials deliberately returns `404`. ```js title="browser/seat-picker.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", // Public Platform sale: publishable, mode-matched account key. SeatLayer // validates this page's exact registered origin and keeps the grant in memory. publicKey: "", onCheckout: async (hold, seats, handoff) => { await fetch("/api/checkout/seats", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ holdId: handoff.holdId }), }); }, }); await picker.render(); ``` For private inventory, replace `publicKey` with a `buyerAccessTokenProvider` backed by your authenticated server route. The hold handoff below is identical in both lanes. The third callback argument is the stable checkout handoff: ```ts title="CheckoutHandoff.ts" interface CheckoutHandoff { holdId: string; expiresAt: number; currency: string; lineItems: CheckoutLineItem[]; total: number; } ``` Browser totals are useful for presentation, but your server must inspect the hold before creating the trusted order amount. ## Headless SeatingChart flow ```js title="browser/headless-checkout.js" const chart = new seatlayer.SeatingChart({ container: "#chart", event: "", onSelectionChange: (seats) => updateYourCart(seats), onHold: ({ holdId, expiresAt }) => { saveCheckoutHandoff({ holdId, expiresAt }); }, onError: (error) => reportPickerError(error), }); await chart.render(); document.querySelector("#continue").addEventListener("click", async () => { const hold = await chart.hold(); if (!hold) { showMessage("One of those seats is no longer available. Please choose again."); return; } await beginCheckout({ holdId: hold.holdId }); }); ``` `hold()` resolves to: | Field | Meaning | |---|---| | `holdId` | Opaque capability identifying this hold | | `expiresAt` | Absolute expiry timestamp in epoch milliseconds | | `seats` | Held selection for buyer-side UI | | `items` | Resolved hold line items | The public method returns `null` when the hold cannot be created and sends structured unexpected errors to `onError`. ## Inspect from your server ```js title="server/inspect-hold.js" const response = await fetch( `https://api.seatlayer.io/v1/events/${eventKey}/holds/${holdId}`, { headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, }, }, ); if (!response.ok) { return handleInactiveOrInvalidHold(response.status); } const hold = await response.json(); const order = await createOrderFromTrustedItems(hold.items); ``` Do not accept buyer-posted labels, tiers, quantities, prices, or currency as authoritative checkout input. For a channel-priced item, the inspected hold also returns `channelId` and the frozen `channelPricingVersion`; a later organizer price edit does not rewrite that hold. ## Restore a hold `SeatPicker` restores its active hold from `sessionStorage` by default. When your application owns cart persistence: ```js title="browser/restore-hold.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: eventKey, restoreHold: false, initialHoldId: cart.seatlayerHoldId, onHoldChange: (hold) => { persistCart({ seatlayerHoldId: hold?.holdId ?? null, expiresAt: hold?.expiresAt ?? null, }); }, onHoldRestored: (hold) => { renderCountdown(hold.expiresAt); }, }); ``` For a headless chart, call `resumeHold(holdId)`. Restoring verifies the hold and does not extend its expiry. ## Extend a hold The SDK can request more time for the current hold: ```js title="browser/extend-hold.js" const extended = await chart.extendHold(); if (!extended) { returnBuyerToSelection(); } else { renderCountdown(extended.expiresAt); } ``` An active hold may be extended at most three times. A refusal is a normal outcome when the hold is missing, inactive, expired, or has reached the renewal limit. ## Release a hold Release inventory explicitly when the buyer abandons checkout or chooses to start over: ```js title="browser/release-hold.js" await chart.release(); ``` If you do nothing, the server releases the hold at `expiresAt`. ## Conflict behavior | Moment | Signal | Product response | |---|---|---| | Hold creation | `hold()` returns `null` | Refresh selection and explain that availability changed | | Hold inspection | `404` or inactive hold | Do not create a new charge; return to selection | | Booking | `409 conflict` | Void/refund according to payment state and let the buyer reselect | | Restore | `null` / callback not fired | Clear stale cart hold state | | Extend | `null` | Continue expiry recovery; do not promise more time | ## Managed checkout refusals When the event sells through SeatLayer checkout, the checkout start can be refused by the organizer's payment provider. Two answers exist, and they ask for different things from the buyer: | Status | `code` | Body | Meaning | |---|---|---|---| | `409` | `provider_refused` | `{ retryable: false, provider, message, providerCode }` | The provider answered "no" (a key, account or configuration problem on the organizer's side). Retrying cannot help; the organizer can. The seats were released and nothing was charged. `message` is the provider's own sentence, redacted and capped; `providerCode` is its code when it gave one. | | `502` | `gateway_unavailable` | `{ retryable: true, provider }` | The provider did not answer (outage, rate limit, dropped connection). The seats were released and nothing was charged; the buyer may pick again and retry. | The built-in checkout tells the buyer to contact the organizer for a `provider_refused` answer and to try again for `gateway_unavailable`. A host page that surfaces these through `onError` should keep that distinction: never invite a retry on `retryable: false`. ## Verification checklist - [ ] A selection produces one hold and one stored `holdId`. - [ ] A competing browser cannot hold the same seat. - [ ] Refresh restores the active hold only when configured. - [ ] Release returns inventory to other buyers. - [ ] Expiry clears the buyer cart and selection state. - [ ] The backend calculates the charge from inspected hold items. - [ ] No secret key appears in client code. Continue to [Booking held seats](/server-api/booking) and [Handling hold expiry](/buyer-sdk/hold-expiry). # Install the Buyer SDK > Add SeatLayer through a hosted script, browser ESM, JavaScript, React, Vue, or Angular and choose between the complete picker and headless chart. Source: https://docs.seatlayer.io/buyer-sdk/install/ · Markdown: https://docs.seatlayer.io/buyer-sdk/install/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. The browser SDK provides two primary single-Event surfaces and two multi-performance products: - **SeatPicker** — The complete buyer experience: map, confirmation, pricing, selection tray, holds, expiry, success state, mobile layout, and optional 3D. - **SeatingChart** — The headless seating canvas. Use it when your product owns the surrounding controls and checkout UI. - **PerformanceGroupPicker** — One accessible buyer flow that holds assigned seats across every performance in a fixed run; your Platform checkout remains external. - **SeasonPicker** — Selects one same-seat package across a published 2–20-performance Plan, with package review, Seat Rights, and renewal support. The buyer picks exact seats, or your code calls `holdBestAvailable()` in `@seatlayer/js`. There is no native Season picker. All surfaces use the same chart and live inventory model. > **Current public browser packages** > > The current public JavaScript/TypeScript package (`@seatlayer/js`) is `0.106.1` > (verified 4 October 2026). React, Vue, and Angular wrappers are `0.106.1`. > Start from the official > [`@seatlayer/js` npm package](https://www.npmjs.com/package/@seatlayer/js) > or [source repository](https://github.com/seatlayer/seatlayer-sdk), then use > the framework-specific package below. The browser SDK renders the venue and > manages live selection; your application still owns its checkout and server. > **Choose the audience credential** > > Public Platform events use the event's account `publicKey`. SeatLayer verifies > the exact registered browser origin and returns a Public-only in-memory bearer, > chart, and compact inventory status in one direct bootstrap. Login, presale, > partner, and channel inventory use `buyerAccessTokenProvider` or > `buyerAccessToken` from your authenticated backend instead; either explicit > buyer credential takes precedence. Managed public/unlisted event pages retain > anonymous event-key access. > **Building a mobile application?** > > Flutter, React Native, iOS, and Android publish independently maintained > native packages. Compare their current catalog entries in the > [mobile SDK guide](/buyer-sdk/mobile), then choose a > ready-made picker, branded picker, custom native layout, or raw map. > **Event key or public key?** > > Replace `` with your event's key. Find it at the top of the event's page in the dashboard, with a Copy button, or read `meta.key` from the `POST /v1/events` response. It starts with `ev_`. > > The two keys go in different places: > > - `event` takes the **event key** (`ev_…`). It says which event to show. > - `publicKey` takes your **public key** (`pk_test_…` or `pk_live_…`), from **Developer → API keys**. It says which account is asking. > > Both must come from the same account, and a `pk_test_` key only opens test events. A 404 on first load usually means one of the two is in the wrong place, the event key is still the placeholder, or the event sells through SeatLayer checkout rather than your own (those events use the hosted page, not the SDK). ## Choose an installation ### Script tag No build tool is required. The IIFE bundle exposes the `seatlayer` global. ```html title="checkout.html"
``` ### Browser ESM Import the hosted module directly from a module script. ```html title="checkout.html"
``` ### npm Install the JavaScript package with your package manager. ```sh npm install @seatlayer/js@0 pnpm add @seatlayer/js@0 yarn add @seatlayer/js@0 bun add @seatlayer/js@0 ``` ```js title="checkout.js" import { SeatPicker } from "@seatlayer/js"; const picker = new SeatPicker({ container: "#picker", event: "", publicKey: "", onCheckout: (_, __, handoff) => { beginCheckout(handoff.holdId); }, }); await picker.render(); ``` ### React Install the React wrapper: ```sh npm install @seatlayer/react@0 pnpm add @seatlayer/react@0 yarn add @seatlayer/react@0 bun add @seatlayer/react@0 ``` ```tsx title="Checkout.tsx" import { SeatPicker } from "@seatlayer/react"; export function Checkout() { return ( { beginCheckout(handoff.holdId); }} /> ); } ``` Prefer a running app? Clone the [React and Vite example](https://github.com/seatlayer/seatlayer-react-example), add your event key and public key to `.env.local`, and start it. ### Vue and Angular package scope `@seatlayer/vue` provides native `SeatingChart` and `SeasonPicker` components; `@seatlayer/angular` provides `SeatLayerSeatingChartComponent` and `SeatLayerSeasonPickerComponent`. Both also re-export the framework-agnostic `SeatPickerWidget` modal and `attachPickerFrame` helper from `@seatlayer/js`. They do not provide native SeatPicker or Designer components. Since `0.106.0` each has the control room `SeatManager` on its `/manager` entry ([Vue](/platform/vue-seat-manager), [Angular](/platform/angular-seat-manager)). Use `@seatlayer/js` directly when you need the other surfaces in a Vue or Angular application. ```bash npm install @seatlayer/vue npm install @seatlayer/angular ``` ## Use the headless chart `@seatlayer/js`, `@seatlayer/react`, and `@seatlayer/vue` export `SeatingChart`. Angular exports `SeatLayerSeatingChartComponent`; use that component in an Angular template. The vanilla JavaScript example below uses `@seatlayer/js`. ```js title="headless-chart.js" import { SeatingChart } from "@seatlayer/js"; const chart = new SeatingChart({ container: "#chart", event: "", publicKey: "", onSelectionChange: (seats) => { updateYourSelectionUI(seats); }, }); await chart.render(); ``` Choose `SeatPicker` unless your application has a clear reason to own selection controls, confirmation, hold timing, pricing presentation, and mobile behavior. Working in React? Walk through [building a seating chart in React step by step](/buyer-sdk/react-seating-chart), from install to checkout handoff. ## Container requirements The SDK is container-responsive. Give its mount element an explicit width and height. ```css title="picker.css" #picker { width: 100%; height: min(720px, 75vh); min-height: 480px; } ``` Test the actual embed container, not only the full browser viewport, at desktop and mobile widths. ## Versioning - Package-manager examples use the current `0.x` line (`@seatlayer/js@0`); your lockfile records the exact release installed. - The documentation's `seatlayer-js@0/` CDN alias follows the newest `0.x` release. In production, replace it with that release's exact version after testing so a later SDK publish cannot change your buyer flow unexpectedly. - Update an exact CDN version deliberately after validating the newer SDK release. - The bundle names are `seatlayer-buyer.js` for the global build and `seatlayer-buyer.mjs` for ESM. `seatlayer.js` and `seatlayer.mjs` are the same widget plus the organizer surfaces, and stay published for pages that mount them. The legacy global `seatmap` remains an alias for compatibility. New integrations should use `seatlayer`. ## Official package destinations Use the package that matches the application you are shipping. These are the official registry and source destinations, rather than third-party wrappers: - JavaScript: [`@seatlayer/js` on npm](https://www.npmjs.com/package/@seatlayer/js) - React: [`@seatlayer/react` on npm](https://www.npmjs.com/package/@seatlayer/react) - Vue: [`@seatlayer/vue` on npm](https://www.npmjs.com/package/@seatlayer/vue) - Angular: [`@seatlayer/angular` on npm](https://www.npmjs.com/package/@seatlayer/angular) - Shared browser source: [github.com/seatlayer/seatlayer-sdk](https://github.com/seatlayer/seatlayer-sdk) Choose the complete `SeatPicker` when you want a working buyer journey. Choose the headless chart only when your product is prepared to own the selection controls, confirmation, hold presentation, and recovery UI around the same live inventory. ## API origin The default API origin is `https://api.seatlayer.io`. Most integrations should not set `apiBase`. ```js title="custom-origin.js" const picker = new SeatPicker({ container: "#picker", event: "", publicKey: "", apiBase: "https://api.seatlayer.io", }); ``` Only override it for a SeatLayer environment you intentionally control. ## Installation checklist - [ ] The package or hosted asset loads without browser errors. - [ ] The mount container has an explicit usable size. - [ ] A test event renders. - [ ] Selection changes appear immediately. - [ ] Checkout produces a `holdId`. - [ ] No `sk_…` credential exists in the client bundle. - [ ] Public Platform embeds use `publicKey`; private audiences use a provider/token. - [ ] Every `bse_…` bearer stays in memory, never storage or URLs. - [ ] Narrow mobile and keyboard behavior are usable. Continue to the [Quickstart](/start/quickstart), or learn the [core hold and booking model](/start/how-it-works). # iOS SDK > Install SeatLayer iOS 0.3.4 and use the complete SwiftUI/UIKit picker, branded components, a custom native layout, or the preserved raw SeatLayerView. Source: https://docs.seatlayer.io/buyer-sdk/ios/ · Markdown: https://docs.seatlayer.io/buyer-sdk/ios/index.md SeatLayer iOS `0.3.4` ships a complete native buyer picker for SwiftUI and UIKit. Native components own the buyer journey, and the same package preserves the raw `SeatLayerView` for applications that own every surrounding control. > **iOS 0.3.4 Swift package** > > Pin the `v0.3.4` tag. It supports iOS 15+ and Swift 5.9+. > `SeatLayerPicker`, > `SeatLayerPickerViewController`, the customization layers, and the scoped > SwiftUI/UIKit composition APIs are all part of the tagged release. ## Install with Swift Package Manager In Xcode, choose **File → Add Package Dependencies** and enter: ```text https://github.com/seatlayer/seatlayer-ios.git ``` Select exact version `0.3.4`, or declare it in a manifest: ```swift title="Package.swift" dependencies: [ .package( url: "https://github.com/seatlayer/seatlayer-ios.git", exact: "0.3.4" ) ] ``` Add the `SeatLayer` product to the app target, then `import SeatLayer`. ## SwiftUI quick start ```swift title="TicketPicker.swift" import SeatLayer import SwiftUI struct TicketPicker: View { let event: String var body: some View { SeatLayerPicker( configuration: SeatLayerConfiguration( event: event, publicKey: "", maxSelection: 8, locale: "en", currency: "USD" ), onCheckout: { handoff in try await checkoutBackend.begin(holdId: handoff.holdId) } ) } } ``` The ready picker covers loading, live inventory, prices and accessibility, venue/floor/section navigation, confirmation and ticket tiers, GA/table quantities, cart and hold state, expiry recovery, 2D/3D and panorama chrome, and one typed checkout handoff. ## UIKit quick start UIKit hosts the same component tree: ```swift title="TicketPickerViewController.swift" let picker = SeatLayerPickerViewController( configuration: SeatLayerConfiguration( event: "", publicKey: "", currency: "USD" ), onCheckout: { handoff in try await checkoutBackend.begin(holdId: handoff.holdId) }, onClose: { navigationController?.popViewController(animated: true) } ) ``` Call `picker.handleBack()` before popping the host route. It consumes the picker's prompt → cart → confirmation → section → venue ladder first. `updateAppearance(theme:themeMode:strings:styles:)` changes presentation in place without rebuilding the picker or losing camera, selection, or hold state. ## Public and private inventory The examples use a publishable key for Public inventory. For login-gated, presale, partner, channel, or other private inventory, omit `publicKey` and provide renewable buyer access: ```swift var configuration = SeatLayerConfiguration( event: "ev_private", currency: "USD" ) configuration.buyerAccessTokenProvider = { context in try await buyerBackend.mintSeatLayerAccess(reason: context.reason) } ``` Mint the native buyer-access session on your backend through [buyer access sessions](/server-api/buyer-access-sessions), return only its short-lived token to the provider, and treat SDK access details as opaque. Tokens stay in memory and never belong in the binary, URL, logs, analytics, or persistent state. ## Choose an integration level | Path | API | Your application owns | | --- | --- | --- | | Ready SwiftUI | `SeatLayerPicker` | Configuration, callbacks, containing route | | Ready UIKit | `SeatLayerPickerViewController` | Navigation container and dismissal | | Branded picker | Theme, strings, options, styles, builders | Brand and selected complete parts | | Custom SwiftUI | `SeatLayerPickerScope` + public views | Entire SwiftUI hierarchy | | Custom UIKit | `SeatLayerPickerMapView` or `SeatLayerPickerMapViewController` + controller | Entire UIKit hierarchy | | Raw map | `SeatLayerView` | Every buyer surface and hold transition | The native-picker paths retain one controller, one presentation model, ordered inventory actions, supported-feature checks, lifecycle recovery, and the same secure checkout handoff. [Explore customization and the component catalogue](/buyer-sdk/ios/native-picker). ## Checkout and hold ownership The app selects and temporarily holds inventory. Your trusted backend inspects and books after payment or order validation. 1. `onCheckout` receives one `SeatLayerPickerCheckoutHandoff`. 2. Send only its opaque `holdId` and normal order context to the backend. 3. Inspect the hold and calculate the price from trusted server data. 4. Charge through your existing payment provider. 5. Reuse the same `bookingRef` for safe retries. Ordinary picker state deliberately omits `holdId`. Before a successful handoff the picker owns the hold, so a confirmed close may release it. After your callback accepts the handoff, the host owns booking, rejection/release, or expiry. If the callback throws, the SDK rejects only that exact handoff and keeps the buyer in a recoverable picker state. Read [holds and secure checkout](/buyer-sdk/holds-and-checkout) before wiring payment. ## Application and SDK ownership | Native SwiftUI/UIKit owns | SeatLayer SDK owns | | --- | --- | | Header, filters, floor/section navigation, confirmation, tiers/quantities, cart, hold state, checkout, loading/errors, Test Mode, required attribution, Back and adaptive presentation | Seats, labels, geometry, hit testing, pan/pinch, venue camera, authored 3D, and panorama pixels | | Dynamic Type, VoiceOver, safe areas, scene lifecycle, native haptics and presentation | Authoritative inventory state and supported map capabilities | Changing native presentation never creates a second map. The buyer keeps the same camera, selection, and hold state. ## Lifecycle and recovery The ready SwiftUI picker observes `scenePhase`; the UIKit host observes application notifications. On foreground they report lifecycle state, consume lost-inventory or hold-lapse truth, optionally refresh availability, and synchronize before accepting another buyer action. `SeatLayerPickerOptions(refreshOnResume: false)` skips only the optional explicit refresh. Authoritative lifecycle state is still reconciled. `announceHoldLapse: false` keeps reconciliation but leaves the visible message to the host. A custom host calls `controller.lifecycle`, `controller.refreshAvailability()`, and `controller.synchronize()`. `SeatLayerPicker.prewarm()` is an optional API that prepares the picker before navigation: ```swift Task { try await SeatLayerPicker.prewarm() } ``` Prewarm contains no event, public key, buyer token, or hold and does not start a buyer session. ## Layout requirement Give the picker or map a definite height or a full-screen parent. Do not place the venue map in a SwiftUI `ScrollView`, `List`, or UIKit `UIScrollView`; the canvas owns pan, pinch, 3D, and panorama gestures. ## Test Mode and attribution Test Mode follows authoritative event state and must remain visible. The three-bar `Powered by SeatLayer` mark appears at the safe bottom-right edge when required by the SeatLayer account configuration. Client-side options, styles, and builders cannot override that setting. ## Run the tagged example ```bash git clone --branch v0.3.4 --depth 1 https://github.com/seatlayer/seatlayer-ios.git cd seatlayer-ios xcodebuild -scheme SeatLayer -destination 'generic/platform=iOS' xcodebuild -project Example/SeatLayerDemo.xcodeproj \ -scheme SeatLayerDemo -destination 'generic/platform=iOS Simulator' ``` The UIKit example exercises the complete picker. Validate SwiftUI/UIKit, Dynamic Type, VoiceOver, rotation, foreground recovery, interactive dismissal, tiers, 3D/panorama, checkout rejection, Test Mode, and attribution on supported physical devices before rollout. ## Product scope iOS `0.3.4` accepts one published Event per picker. Native Performance Group and Season configuration is not part of this release. ## Next steps - [SwiftUI/UIKit customization and custom layouts](/buyer-sdk/ios/native-picker) - [Raw-map API](/buyer-sdk/ios/raw-map) - [Compare all four native SDKs](/buyer-sdk/mobile) - [Secure checkout](/buyer-sdk/holds-and-checkout) - [Troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) - [`v0.3.4` source](https://github.com/seatlayer/seatlayer-ios/tree/v0.3.4) - [Tagged native picker guide](https://github.com/seatlayer/seatlayer-ios/blob/v0.3.4/Docs/native-picker.md) - [Tagged example](https://github.com/seatlayer/seatlayer-ios/tree/v0.3.4/Example) - [Tagged changelog](https://github.com/seatlayer/seatlayer-ios/blob/v0.3.4/CHANGELOG.md) # SwiftUI and UIKit native picker > Customize the iOS 0.3.4 picker or build a complete SwiftUI/UIKit layout from the public controller, map, and native components. Source: https://docs.seatlayer.io/buyer-sdk/ios/native-picker/ · Markdown: https://docs.seatlayer.io/buyer-sdk/ios/native-picker/index.md iOS `0.3.4` provides one native picker implementation for SwiftUI and UIKit. The ready-made flow, customization layers, public component catalogue, scoped composition, lifecycle recovery, and typed checkout handoff are part of the tagged Swift package. ## Application and SDK ownership | Native SwiftUI/UIKit owns | SeatLayer SDK owns | | --- | --- | | Event header, filters, floor/section navigation, confirmation, tiers and quantities, cart, hold state, checkout, loading/errors, Test Mode, attribution and Back | Seats, labels, venue geometry, hit testing, pan/pinch, camera, authored 3D, and panorama pixels | | Dynamic Type, VoiceOver, safe areas, scene lifecycle, native presentation and haptics | Authoritative inventory state and supported map capabilities | The picker exposes a replaceable native buyer component tree while SeatLayer keeps venue geometry and inventory behavior behind the public SDK API. ## Integration ladder | Path | API | Host ownership | | --- | --- | --- | | Ready SwiftUI | `SeatLayerPicker` | Configuration and callbacks | | Ready UIKit | `SeatLayerPickerViewController` | Navigation and dismissal | | Branded picker | Theme, strings, options, styles, builders | Brand or selected complete parts | | Custom SwiftUI | `SeatLayerPickerScope` + public views | Entire SwiftUI hierarchy | | Custom UIKit | `SeatLayerPickerMapView` or `SeatLayerPickerMapViewController` + controller | Entire UIKit hierarchy | | Raw map | `SeatLayerView` | Every buyer surface and transition | ## Ready-picker contract ```swift SeatLayerPicker( configuration: SeatLayerConfiguration( event: "", publicKey: "", locale: "en", currency: "USD" ), onCheckout: { handoff in try await checkoutBackend.begin(holdId: handoff.holdId) } ) ``` For private inventory, replace `publicKey` with a renewable `buyerAccessTokenProvider` backed by your server. Return only the short-lived native buyer-access token and treat the SDK access context as opaque. UIKit's `SeatLayerPickerViewController` hosts the same public component tree. Call `handleBack()` from host navigation and use `updateAppearance(theme:themeMode:strings:styles:)` for in-place appearance changes. ## Four customization layers ### Theme native and map roles together ```swift let theme = SeatLayerPickerTheme( background: "#0F1522", surface: "#1A2234", text: "#EEF1F8", accent: "#FF6584", onAccent: "#111827", map: SeatLayerPickerMapTheme( background: "#0F1522", rowLabelColor: "#D7DEEA", textColor: "#EEF1F8", selectionColor: "#FF6584" ) ) ``` `.auto` follows the current color scheme without rebuilding the picker. ### Style one surface ```swift var styles = SeatLayerPickerStyles() styles[.checkoutBar] = SeatLayerPickerPartStyle( background: "#FF6584", cornerRadius: 18, horizontalPadding: 12, verticalPadding: 8 ) ``` A style changes appearance, not placement, inventory truth, or hit targets. ### Change optional chrome and wording ```swift let options = SeatLayerPickerOptions( chrome: SeatLayerPickerChromeOptions(floorStrip: false) ) let strings = SeatLayerPickerStrings( overrides: [ SeatLayerPickerStringKey.continueWord.rawValue: "Review order" ], localeIdentifier: "fr-FR" ) ``` The 37 bundled locale dictionaries resolve exact BCP-47 tag, then language, then English. Event and inventory data supplied by SeatLayer stays authoritative. ### Wrap or replace one complete part ```swift var builders = SeatLayerPickerBuilders() builders.header = { context in AnyView( context.defaultContent .background(.ultraThinMaterial) ) } ``` Every builder receives the current read-only state, controller, presentation state, resolved style, and canonical `defaultContent`. Missing or throwing builders fall back to the stock component. Test Mode and attribution deliberately have no builder authority. ## Build your own SwiftUI hierarchy `SeatLayerPickerScope` gives the tree one controller, one presentation model, and one picker state. ```swift let options = SeatLayerPickerOptions(confirmSelection: true) SeatLayerPickerScope(options: options) { controller in ZStack { SeatLayerPickerMap( configuration: SeatLayerConfiguration( event: "", publicKey: "" ), options: options, controller: controller ) VStack { SeatLayerPickerHeader() SeatLayerPickerPriceLegend() SeatLayerPickerFloorStrip() Spacer() SeatLayerPickerDockBar() SeatLayerPickerCartList() } } } ``` Callbacks observe rather than own the flow. Custom UI invokes typed semantic controller actions such as `focusSection`, `overview`, `setFloor`, `setCategoryFilter`, `refreshAvailability`, and `checkout`. UIKit applications that own all chrome use `SeatLayerPickerMapView` or `SeatLayerPickerMapViewController`, observe `pickerController.snapshot`, and call the same public controller actions. ## Public component catalogue The ready picker is composed from these same public surfaces: | Buyer job | Default components | | --- | --- | | Identity and venue navigation | `SeatLayerPickerHeader`, price legend, floor selector/strip, section navigator, dock bar | | Map and filters | `SeatLayerPickerMap`, map controls, accessibility filters | | Decisions | Best-seats form, seat confirmation/confirm card, tier choices, GA and table prompts | | Cart and hold | Cart list/sheet, checkout bar, hold countdown, hold-lapse notice, action error | | Immersive | `SeatLayerVenue3D`, `SeatLayerSeatViewChrome` | | System states | Loading, retryable/fatal error, empty/sold-out/sales-closed | | Truth components | `SeatLayerPickerTestModeIndicator`, `SeatLayerPickerAttribution` | There are 25 replaceable builder parts. Test Mode and attribution are public truth components outside that replacement matrix. ## State and checkout guarantees - Picker state always belongs to the active Event and supersedes older state. - Inventory-changing actions are serialized. - Repeated Continue taps join one checkout flight. - Pending tier choice is applied before confirmation and remains authoritative through cart and handoff. - Ordinary picker state omits `holdId`; only `SeatLayerPickerCheckoutHandoff` exposes it. - A successful callback transfers hold ownership to the host. - A rejected callback rejects only that handoff and returns ownership safely. - `initialHoldId` restores a host-owned hold; `readOnly: true` blocks mutation through both ready-made and custom controller paths. ## Lifecycle and recovery The ready SwiftUI picker observes `scenePhase`; UIKit observes application notifications. Foreground reconciliation consumes hold lapse, unavailable inventory, and authoritative server hold state before new actions. Custom hosts use `lifecycle`, `refreshAvailability()`, and `synchronize()`. Back closes the most local state first: prompt → cart → confirmation → focused section → venue → host. Immersive 3D and panorama actions are capability-gated, so unsupported actions are never presented or sent. ## Test Mode and required attribution Custom hosts must render truthful Test Mode state and the bottom-right attribution when required by the SeatLayer account configuration. Chrome options and builders cannot force or suppress it. ## Production checklist - Pin exact Swift package `0.3.4`. - Validate SwiftUI and UIKit paths used by your product. - Exercise Dynamic Type, VoiceOver, RTL, rotation, foreground/background, interactive dismissal, hold lapse, unavailable inventory, and rejected checkout. - Test real tiers, 3D targets, panorama navigation, Test Mode, and attribution. - Verify supported physical devices; simulator success is not device proof. - Confirm the trusted backend inspects and books the handoff hold. ## Product scope The picker selects one Event. It does not accept native Performance Group or Season configuration. ## Related pages - [iOS quick start](/buyer-sdk/ios) - [Raw-map API](/buyer-sdk/ios/raw-map) - [Compare mobile SDKs](/buyer-sdk/mobile) - [`v0.3.4` source](https://github.com/seatlayer/seatlayer-ios/tree/v0.3.4) - [Tagged picker entry point](https://github.com/seatlayer/seatlayer-ios/blob/v0.3.4/Sources/SeatLayer/Picker/SeatLayerPicker.swift) - [Tagged native-picker guide](https://github.com/seatlayer/seatlayer-ios/blob/v0.3.4/Docs/native-picker.md) - [Tagged example](https://github.com/seatlayer/seatlayer-ios/tree/v0.3.4/Example) - [Tagged changelog](https://github.com/seatlayer/seatlayer-ios/blob/v0.3.4/CHANGELOG.md) - [Troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) # Raw map API > Use SeatLayerView, SeatLayerConfiguration, async commands, and delegate events from the preserved raw-map surface in iOS 0.3.4. Source: https://docs.seatlayer.io/buyer-sdk/ios/raw-map/ · Markdown: https://docs.seatlayer.io/buyer-sdk/ios/raw-map/index.md This page covers the preserved raw-map API in the iOS `v0.3.4` tag. ## Core types | Type | Responsibility | | --- | --- | | `SeatLayerView` | `UIView` map host, load lifecycle, and async commands | | `SeatLayerConfiguration` | Event, locale, currency, selection rules, credential provider, and timeouts | | `SeatLayerViewDelegate` | Typed readiness and SDK events | | `BuyerAccessTokenProvider` | `@Sendable` async closure that renews private buyer access | | `HoldResult` | Opaque hold ID, server expiry, selected seats, and display line items | | `SeatLayerError` | Typed load, access, timeout, compatibility, and command failures | ## Configuration ```swift var configuration = SeatLayerConfiguration( event: "", maxSelection: 6, locale: "en", currency: "USD", colorblindSafe: false ) configuration.buyerAccessTokenProvider = { context in try await buyerBackend.mintSeatLayerAccess(reason: context.reason) } ``` The provider returns `BuyerAccessToken(token:expiresAt:)`. Tokens remain in memory and do not belong in URLs, logs, analytics, or persistent storage. Mint the native buyer-access session on your backend through [buyer access sessions](/server-api/buyer-access-sessions), return only its short-lived token, and treat SDK access details as opaque. See the [tagged configuration source](https://github.com/seatlayer/seatlayer-ios/blob/v0.3.4/Sources/SeatLayer/SeatLayerConfiguration.swift) for every field in `0.3.4`: identity/access, selection policy, locale/messages, currency, view mode, deadlines, host diagnostics, and development overrides. ## Load and readiness ```swift seatMap.delegate = self do { let info = try await seatMap.load(configuration) switch info.mode { case .test: showTestMode() case .live: break default: break } } catch { showLoadError(error) } ``` Await readiness before sending a command. A failed command throws at its own call site; an out-of-band SDK failure reaches the delegate. ## Common command groups This is a task map, not an exhaustive replacement for the tagged declarations. | Group | Methods | | --- | --- | | Read | `getSelection`, `getSelectionValidity`, `getCurrentHold`, `getGAAreas`, `getFloors`, `getViewMode` | | Select | `selectObjects`, `deselectObjects`, `clearSelection`, `selectCategories`, `deselectCategories`, `setSelectableObjects`, `setMaxSelection`, `setSeatTier` | | Hold | `hold`, `resumeHold`, `extendHold`, `release`, `releaseLabels`, `bestAvailable`, `holdGA` | | Access | `refreshAccess` | | Map | `setFloor`, `setColorblindSafe`, `setViewMode`, `zoomIn`, `zoomOut`, `zoomToFit` | | Session | `destroy` | Use the [tagged `SeatLayerView` API](https://github.com/seatlayer/seatlayer-ios/blob/v0.3.4/Sources/SeatLayer/SeatLayerView.swift) for exact labels, return types, and all methods. Use reported SDK capabilities to decide whether to show an optional control. Capability failures are typed. Do not show a native control merely because a method exists; use the reported capability information and handle unsupported commands deliberately. ## Delegate model Implement the `SeatLayerViewDelegate` methods your screen needs. Important event families are: - chart ready and structured error; - complete current selection and selection validity; - buyer-access expiry or unavailability; - hold changed, restored, or expired; - general-admission area tap and seat hover; - selected inventory becoming unavailable; and - unknown future event. Unknown values remain inspectable so an additive SDK event does not crash an older binary. Exact signatures include: ```swift func seatLayerView( _ view: SeatLayerView, selectionDidChange seats: [SelectedSeat] ) func seatLayerView( _ view: SeatLayerView, selectionValidityDidChange validity: SelectionValidity ) func seatLayerViewHoldDidExpire(_ view: SeatLayerView) func seatLayerView( _ view: SeatLayerView, didFailWith error: SeatLayerError ) ``` Selection callbacks carry complete replacement state. Hold expiry is authoritative, and `didFailWith` is for out-of-band failure; awaited commands throw at their own call site. The [tagged delegate declaration](https://github.com/seatlayer/seatlayer-ios/blob/v0.3.4/Sources/SeatLayer/SeatLayerViewDelegate.swift) lists every event and payload. ## Restore a checkout ```swift if let restored = try await seatMap.resumeHold(holdId: persistedHoldId) { updateCheckoutExpiry(restored.expiresAt) } else { returnBuyerToSelection() } ``` Persist a hold ID only when your product promises checkout restoration. Never log it, and never infer that a hold still exists from a local countdown. ## SwiftUI wrapper boundary When intentionally using the raw map from SwiftUI, one `UIViewRepresentable` owns the `SeatLayerView` and delegate coordinator. Updating unrelated SwiftUI state must not recreate the map. Most SwiftUI applications should use the complete `SeatLayerPicker` or `SeatLayerPickerScope` instead. ## Related pages - [iOS SDK overview](/buyer-sdk/ios) - [SwiftUI and UIKit native picker](/buyer-sdk/ios/native-picker) - [Holds and checkout](/buyer-sdk/holds-and-checkout) - [Lifecycle and foreground recovery](/buyer-sdk/ios#lifecycle-and-recovery) - [Shared troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) - [Tagged UIKit example](https://github.com/seatlayer/seatlayer-ios/tree/v0.3.4/Example) - [Tagged changelog](https://github.com/seatlayer/seatlayer-ios/blob/v0.3.4/CHANGELOG.md) # Native app SDKs > Compare SeatLayer SDKs for Flutter, React Native, Swift and Kotlin. Choose a complete native buyer UI, custom controls, composed layout or raw seat map. Source: https://docs.seatlayer.io/buyer-sdk/mobile/ · Markdown: https://docs.seatlayer.io/buyer-sdk/mobile/index.md The four first-party native seat map packages are `@seatlayer/react-native` for React Native, `seatlayer` on pub.dev for Flutter, the `seatlayer-ios` Swift package for iOS, and `io.seatlayer:seatlayer-android` with `io.seatlayer:seatlayer-android-compose` for Android; each renders live seat availability with platform-native controls composed around one shared renderer. They share inventory, selection, hold, recovery, and checkout concepts. Each package has its own version: Flutter `0.11.0`, React Native `0.4.0`, iOS `0.3.4`, and Android `0.3.5`, as verified in the SDK catalog on 25 September 2026. The native event picker accepts one published Event per picker. Performance Groups and Fixed Renewable Seasons are browser/API/server products and are not native SDK configurations: use the browser [PerformanceGroupPicker](/buyer-sdk/performance-groups) and [SeasonPicker](/buyer-sdk/seasons) for them. Do not pass those products to a native SDK's `event` configuration until a platform release documents a versioned native contract for them. To compare native SDKs with web SDKs, server SDKs, and REST, start at [Choose an SDK or API](/sdks/). Use this page to choose the platform package and the amount of buyer UI your application owns. Every platform supports the same four-level product ladder: drop in the complete picker, customise it, compose your own layout from public parts, or use only the raw map. ## SDK capability matrix | Platform | Install | Complete picker | Host-owned layout | Raw map | | --- | --- | --- | --- | --- | | Flutter | `seatlayer: 0.11.0` | `SeatLayerPicker` | `SeatLayerPickerScope` + Flutter widgets | `SeatLayerView` + controller | | React Native | `@seatlayer/react-native@0.4.0` | `SeatLayerPicker` or `SeatLayerPickerModal` | `SeatLayerPickerScope` + React Native components | `SeatLayerView` + controller | | iOS | Swift package `0.3.4` | `SeatLayerPicker` or `SeatLayerPickerViewController` | SwiftUI scope/components or UIKit map/controller | `SeatLayerView` | | Android | Core + Compose `0.3.5` | Compose `SeatLayerPicker` or View/XML `SeatLayerPickerView` | Compose scope/components or headless Android Views | `SeatLayerView` | > **Machine-readable SDK catalog** > > Check the [SDK catalog](/sdk-catalog.json) before an automated install > or upgrade. It records the verified installable version, primary surface, > integration levels, and registry for every SDK. The four mobile > records carry individual verification dates and registry/source links. Do not > infer that every package shares a release number. ## Choose an integration path - **Ship one cross-platform screen** — Choose [Flutter](/buyer-sdk/flutter) or [React Native](/buyer-sdk/react-native). Both packages provide the complete picker and public components for a custom layout. - **Use the platform-native stack** — Choose [iOS](/buyer-sdk/ios) for SwiftUI/UIKit or [Android](/buyer-sdk/android) for Compose/View/XML. The buyer chrome is native and every layer remains replaceable. - **Build a completely custom experience** — Use the platform scope/state holder, one map, the typed controller, and public components. You own hierarchy and expression without rebuilding inventory, holds, recovery, or checkout state. - **Keep only the venue surface** — Use the preserved raw map when your application intentionally owns every surrounding control, confirmation, cart, timer, recovery path, and hold transition. ## What “native picker” means The SDKs divide responsibility at a deliberate seam: | Native application UI owns | SeatLayer SDK owns | | --- | --- | | Header, event identity, filters, navigation, confirmation, quantities, cart, hold status, checkout, loading and errors | Seats, labels, section geometry, map hit testing, pan and pinch, venue camera, authored 3D, and panorama pixels | | Platform layout, accessibility semantics, back navigation, safe areas, app lifecycle and haptics | Authoritative inventory projection and map capabilities | The native picker is therefore not a screenshot or generic hosted checkout. Its buyer chrome is made from Flutter widgets, React Native components, SwiftUI or UIKit, or Compose/Android Views. SeatLayer keeps venue geometry, inventory, and map behavior consistent behind the public native APIs. The map itself uses the shared WebView renderer; native UI refers to the controls and layout around it. ## The customization ladder All four SDKs follow the same product model: 1. **Drop in the complete flow.** Supply an event configuration and a checkout callback. 2. **Keep the flow, change its expression.** Apply theme, strings, options, style slots, or replace one complete part through a builder. 3. **Own the hierarchy.** Place the SDK scope/state holder, map, controller, and public components in a layout your app controls. 4. **Use only the raw map.** Own every piece of buyer UI and hold orchestration yourself. Moving down the ladder does not move booking into the app. Every path ends at an opaque `holdId` that goes to your trusted backend. > **Event key or public key?** > > Replace `` with your event's key. Find it at the top of the event's page in the dashboard, with a Copy button, or read `meta.key` from the `POST /v1/events` response. It starts with `ev_`. > > The two keys go in different places: > > - `event` takes the **event key** (`ev_…`). It says which event to show. > - `publicKey` takes your **public key** (`pk_test_…` or `pk_live_…`), from **Developer → API keys**. It says which account is asking. > > Both must come from the same account, and a `pk_test_` key only opens test events. A 404 on first load usually means one of the two is in the wrong place, the event key is still the placeholder, or the event sells through SeatLayer checkout rather than your own (those events use the hosted page, not the SDK). ## Install the SDKs ### Flutter 0.11.0 ```yaml title="pubspec.yaml" dependencies: seatlayer: 0.11.0 ``` ```dart title="ticket_screen.dart" import 'package:seatlayer/seatlayer.dart'; SeatLayerPicker( configuration: SeatLayerConfiguration( event: '', publicKey: '', ), themeMode: SeatLayerThemeMode.auto, onCheckout: (handoff) async { await checkoutBackend.begin(holdId: handoff.holdId); }, ); ``` Continue with the [Flutter guide](/buyer-sdk/flutter), [customisation](/buyer-sdk/flutter/customise), or a [custom layout](/buyer-sdk/flutter/custom-layout). ### React Native 0.4.0 ```bash npm install @seatlayer/react-native@0.4.0 react-native-webview ``` ```tsx title="TicketPickerScreen.tsx" import React, { useMemo } from 'react'; import { SeatLayerPicker, type SeatLayerConfiguration, } from '@seatlayer/react-native'; export function TicketPickerScreen() { const configuration = useMemo( () => ({ event: '', publicKey: '', currency: 'USD', }), [], ); return ( checkoutBackend.begin({ holdId: handoff.holdId }) } /> ); } ``` Continue with the [React Native guide](/buyer-sdk/react-native), [native-picker composition](/buyer-sdk/react-native/native-picker), or [raw map](/buyer-sdk/react-native/raw-map). ### iOS 0.3.4 ```swift title="Package.swift" dependencies: [ .package( url: "https://github.com/seatlayer/seatlayer-ios.git", exact: "0.3.4" ) ] ``` ```swift title="TicketPicker.swift" import SeatLayer import SwiftUI struct TicketPicker: View { var body: some View { SeatLayerPicker( configuration: SeatLayerConfiguration( event: "", publicKey: "", currency: "USD" ), onCheckout: { handoff in try await checkoutBackend.begin(holdId: handoff.holdId) } ) } } ``` Continue with the [iOS guide](/buyer-sdk/ios), [SwiftUI/UIKit composition](/buyer-sdk/ios/native-picker), or [raw map](/buyer-sdk/ios/raw-map). ### Android 0.3.5 ```kotlin title="app/build.gradle.kts" dependencies { implementation("io.seatlayer:seatlayer-android:0.3.5") implementation("io.seatlayer:seatlayer-android-compose:0.3.5") } ``` ```kotlin title="CheckoutActivity.kt" class CheckoutActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { SeatLayerPicker( configuration = SeatLayerConfiguration( event = "", publicKey = "", currency = "USD", ), modifier = Modifier.fillMaxSize(), onCheckout = { handoff -> checkoutBackend.begin(holdId = handoff.holdId) }, onError = ::reportPickerError, onClose = ::finish, ) } } } ``` Continue with the [Android guide](/buyer-sdk/android), [Compose/View composition](/buyer-sdk/android/native-picker), or [raw map](/buyer-sdk/android/raw-map). The examples use a publishable key for Public inventory. For login-gated, presale, partner, channel, or other private inventory, omit `publicKey` and use the platform's renewable `buyerAccessTokenProvider`. Implement that provider with [`POST /v1/events/:key/buyer-access-sessions`](/server-api/buyer-access-sessions) on your authenticated backend. Mint a native buyer-access session for the Event, return only the short-lived token to the provider, and treat the SDK's access context as opaque. Keep the token in memory and never expose a SeatLayer secret to the application. ## Shared security boundary The mobile app may select inventory and create or resume a temporary hold. It must not decide the amount to charge or book inventory with a secret key. 1. Send the opaque `holdId` and your own order context to your backend. 2. Inspect the hold and calculate the price from trusted server data. 3. Charge through your payment provider. 4. Reuse the same `bookingRef` so retries remain idempotent. 5. Treat expiry and inventory conflicts as recoverable selection states. Read [holds and checkout](/buyer-sdk/holds-and-checkout) before connecting a production order flow. ## Troubleshooting Start with the typed SDK error or event; do not infer failure from an empty screen or a paused countdown. | Symptom or signal | Likely boundary | Safe response | | --- | --- | --- | | `404` on first load | The event key is still ``, is the public key (`pk_…`) by mistake, or belongs to another account or mode | Put the `ev_…` event key in `event` and the `pk_…` key in `publicKey`, both from the same account; a `pk_test_` key only opens test events. | | Load fails before ready | No network, temporary service failure, or a zero-sized/unmounted view | Give the view a definite mounted size, verify connectivity, and retry through the SDK's public reload path. Do not issue commands until ready. | | `accessUnavailable`, `origin_mismatch`, or an access-provider failure | The native buyer-access session is invalid, expired, revoked, or the provider did not return a token | Mint again from your backend through [buyer access sessions](/server-api/buyer-access-sessions), return only the token to the provider, and keep it in memory. Do not fall back from private to Public inventory. | | `sl_incompatible` or the platform's compatibility error | The installed app SDK is no longer compatible with the service contract | This is not retryable in place. Upgrade to a current SDK version, validate it, and ship an app update. | | `sl_timeout` | A command did not receive its correlated response before the configured deadline | Show a retryable state. For inventory-changing commands, inspect `getCurrentHold()` or resume the known hold before retrying so a late success is not duplicated. | | `destroyed` or a command after unmount | The view/controller lifecycle ended | Stop sending commands and create one new view/controller pair for the next session. | | `holdExpired` or a null hold after foreground/restoration | Server ownership ended while the app timer was paused | Clear checkout state, return to selection, and let the buyer choose again. Never extend or book from a local countdown alone. | | `selectedObjectsUnavailable`, conflict details, or `sold_out` | Inventory changed concurrently | Remove only the reported unavailable objects, explain the change, and let the buyer reselect. Do not silently recreate a charge. | | Test inventory appears live | The host ignored `ReadyInfo.mode` | Render an unmistakable Test Mode disclosure; test Events do not create live bookings. | | Pan/pinch is unreliable or controls cannot be reached | The venue map is nested in another scrolling/gesture surface or covered by an intercepting overlay | Let the map own its gestures, keep host overlays accessible, and use typed camera/navigation commands rather than raw touch forwarding. | Platform guides add the exact error types, event names, lifecycle hooks, and tagged source references for their package. ## Production checklist - Pin the exact package version or release tag you validated. - Give the map a definite size; do not place it inside another pan/zoom surface. - Keep buyer tokens in memory and secrets on the server. - Preserve an open `holdId` only when your checkout promises restoration. - Test background/foreground reconciliation, rotation, safe areas, back navigation, expiry, release, conflict, and retry behavior. - Verify test/live mode disclosure and any required SeatLayer attribution. - Smoke-test supported physical iOS and Android devices before rollout. - Confirm the backend derives price from the inspected hold, not app input. ## Platform guides - [Flutter SDK](/buyer-sdk/flutter) - [React Native SDK](/buyer-sdk/react-native) - [iOS SDK](/buyer-sdk/ios) - [Android SDK](/buyer-sdk/android) # Performance Group picker > Let a buyer choose seats across a fixed or flexible-date performance run, hold the selected allocations together, and hand the group to your checkout. Source: https://docs.seatlayer.io/buyer-sdk/performance-groups/ · Markdown: https://docs.seatlayer.io/buyer-sdk/performance-groups/index.md `PerformanceGroupPicker` is the complete buyer seating step for a run of two to eight performances that share one published seating chart. It is available in `@seatlayer/js` `0.64.0` and later. Try the buyer flow first in the [multi-performance seat selection demo](https://app.seatlayer.io/demo/performance-groups). Use this page for picker configuration and behavior. Read the [end-to-end Performance Group integration](/integrations/performance-groups/) for provisioning, checkout, booking, and fulfilment, or the [Performance Groups API](/server-api/performance-groups/) for exact trusted operations. It is not a season-ticket, subscription, pass, or renewable season membership. The organizer chooses whether every date is required or partial seats may be sold only on their available dates. Your application still owns the offer, checkout, payment, commercial order, ticket delivery, and support. Use the separate [Fixed Renewable Season integration](/integrations/seasons/) when the offer needs an immutable 2–20-Event Plan, incumbent Seat Rights, and renewal offers rather than one Performance Group purchase. > **The organizer controls date inclusion** > > Every performance must use the same published chart, venue, timezone, > currency, environment, and Platform checkout ownership. Existing groups and > groups created without `inclusionMode` use `fixed`. Only a group created with > `inclusionMode: "flexible_dates"` authorizes a subset hold; a browser cannot > turn a fixed package into a flexible one. ## Choose the inclusion policy | `inclusionMode` | Buyer experience | Compatibility | |---|---|---| | `fixed` | Every date is included. A seat is selectable only when it is available on every performance. | Default; existing integrations keep their current behavior. | | `flexible_dates` | A partial seat shows which dates are available. The buyer can select it for those dates and continue from the same chart. | Same-seat flow; the selected dates are returned in the checkout handoff. | The flexible flow loads one chart and one per-performance availability projection. Selecting or clearing a partial seat re-collapses those cached availability planes locally; it does not load a chart for each date. ## Choose the selection mode | Mode | Buyer experience | Use it when | |---|---|---| | `same_seat` | The buyer selects once; those exact seats are held for every included performance. | The run promises the same seat each night. | | `per_performance` | The buyer chooses an equal number of seats on each dated tab; seats may differ by performance. | The buyer needs the same party size but not the same physical seats. | Both modes create **one all-or-nothing group hold**. The browser never makes a separate hold for each Event. ## Before you render Your trusted backend must create the Performance Group, activate it, and mint a short-lived `bsg_…` buyer-access session for the exact browser origin. See the [Performance Groups API](/server-api/performance-groups) for that server-side setup. Return only `{ token, expiresAt }` to the browser. Do not put the token in a URL, persistent browser storage, analytics, or logs. ## Same seat for every performance ```ts title="browser/group-picker.ts" import { PerformanceGroupPicker } from "@seatlayer/js"; const picker = new PerformanceGroupPicker({ container: "#seat-picker", performanceGroup: "pg_opening_weekend", buyerAccessTokenProvider: async () => { const response = await fetch("/api/seatlayer/group-access", { method: "POST", credentials: "same-origin", cache: "no-store", }); if (!response.ok) throw new Error("Unable to open seat selection"); return response.json(); // { token: "bsg_…", expiresAt } }, onCheckout: (handoff) => { // This starts your checkout. It does not book inventory yet. startCheckout({ performanceGroup: handoff.performanceGroup.key, holdId: handoff.holdId, operationId: handoff.operationId, }); }, onError: (error) => reportSeatSelectionError(error), }); await picker.render(); ``` The picker presents the complete date list, one shared chart, an accessible selection summary, and one **Hold seats for all performances** action. When the hold completes, the primary action becomes **Continue to checkout**. For a `flexible_dates` group, a two-tone partial seat can be opened to see its available and unavailable dates. **Select for 2 available dates** updates the persistent date summary, selects the seat, and submits one atomic hold for only those two performances. Clearing the selection restores the full date view. ## Different seats on each date Use `per_performance` only when your offer permits different seats. The picker reuses one chart and shows dated tabs; it requires the configured party size on every performance before the hold action becomes available. ```ts title="browser/per-performance-picker.ts" const picker = new PerformanceGroupPicker({ container: "#seat-picker", performanceGroup: "pg_three-night-run", selectionMode: "per_performance", numberOfPlacesToSelect: 2, buyerAccessTokenProvider: getGroupBuyerAccess, onCheckout: (handoff) => { beginCheckout({ holdId: handoff.holdId, operationId: handoff.operationId, }); }, }); await picker.render(); ``` For example, a two-person purchase must have two seats selected on each tab. The buyer cannot hold two seats on Friday and one on Saturday. ## Constructor options | Option | Type | Notes | |---|---|---| | `container` | `string \| HTMLElement` | Required inline mount target. | | `performanceGroup` | `string` | Required active `pg_…` key. Do not pass an Event key. | | `selectionMode` | `"same_seat" \| "per_performance"` | Defaults to `same_seat`. This is an offer decision made before the picker opens. | | `numberOfPlacesToSelect` | `number` | Required and at least one for `per_performance`; each tab must contain this many places. | | `buyerAccessTokenProvider` | `({ reason }) => Promise<{ token, expiresAt }>` | Preferred group-and-origin-bound credential refresh path. | | `buyerAccessToken` | `string \| { token, expiresAt }` | Non-refreshing alternative; prefer the provider. | | `holdTtlMs` | `number` | Requested common group hold duration; the server clamps it. | | `initialHoldId` + `initialOperationId` | `string` pair | Restore only a matched group hold/operation pair. Supply both or neither. | | `restoreHold` | `boolean` | Defaults to `true`; group storage is separate from single-Event holds. | | `locale`, `messages`, `languages`, `theme`, `colorblindSafe` | Presentation options | Use the same accessibility and branding controls as `SeatPicker`. | | `enable3D`, `max3DSeats`, `seatView` | Presentation options | The canonical shared chart is rendered once. | | `maxSelection`, `selectedObjects`, `selectableObjects`, `selectionValidators` | Selection options | In per-performance mode, exact party size is still required for every date. | `event`, custom `transport`, Best Available, runtime pricing overrides, and hosted checkout are intentionally unavailable. Passing a group through `SeatPicker` or calling one Event's hold route would make the purchase unsafe. ## Checkout handoff `onHold` and `onCheckout` receive this group-specific object: ```ts title="PerformanceGroupCheckoutHandoff.ts" interface PerformanceGroupCheckoutHandoff { operationId: string; holdId: string; expiresAt: number; performanceGroup: PerformanceGroupDescriptor; selectionMode: "same_seat" | "per_performance"; performanceKeys: string[]; seatLabels: string[]; allocations?: Array<{ eventKey: string; seatLabels: string[]; }>; } ``` `performanceKeys` is the chronological set actually held. It contains every member for a fixed group and the selected available dates for a flexible group. Treat it, `seatLabels`, and `allocations` as buyer display data only. Send the opaque `holdId` and `operationId` to your backend; it must inspect the group hold before calculating a charge or booking inventory. The handoff deliberately never includes child Event hold IDs, authoritative prices, payment data, or ticket information. ## Hold, recovery, and release | Buyer state | Picker behavior | Host behavior | |---|---|---| | Selecting | Seats are candidates only. | Do not create an order yet. | | Securing seats | Interaction is locked while the single group operation resolves. | Keep the buyer on the picker; do not start a second hold. | | Held | Every selected performance is protected by one common expiry. | Start checkout using the opaque handoff. | | Conflict | Nothing is presented as held after safe cleanup. | Explain that availability changed and let the buyer choose again. | | Recovery | The picker checks the same operation after a lost response. | Do not create another operation or guess the outcome. | | Expired or released | Selection resets and availability refreshes. | Clear the in-progress checkout/cart reference. | Call `picker.destroy()` when the host route unmounts. The picker clears its timers, chart instance, and polling connection. It never books inventory; booking belongs to your server. Continue with the [end-to-end integration guide](/integrations/performance-groups) and [group hold inspection and booking](/server-api/performance-groups). # React seat map SDK > Install @seatlayer/react from npm, compare its typed React components, review package requirements, and choose the right seat-map surface for your app. Source: https://docs.seatlayer.io/buyer-sdk/react/ · Markdown: https://docs.seatlayer.io/buyer-sdk/react/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. [`@seatlayer/react`](https://www.npmjs.com/package/@seatlayer/react) is the official SeatLayer package for React 17 and later. It ships typed React components for a complete buyer picker, a headless seating chart, embedded venue design, and live inventory operations. The buyer components render into your DOM rather than an iframe, and TypeScript definitions ship for both ESM and CommonJS consumers. Use this page to choose an export, confirm package requirements, and inspect the React component contract. To build one checkout flow from install through hold handoff, follow the procedural [React seat-map tutorial](/buyer-sdk/react-seating-chart). [Inspect the React source](https://github.com/seatlayer/seatlayer-sdk), learn about the [SeatLayer reserved-seating platform](https://seatlayer.io/), or try the [browser seat-map demo](https://app.seatlayer.io/demo/play/grand-theatre). > **The app selects and holds; your server books** > > Never put a SeatLayer secret key in browser code. Send the opaque hold id to > your trusted backend, inspect the hold there, calculate the charge from > server data, and book with a stable `bookingRef`. ## Choose a React export | Export | Use it when | Detailed reference | | --- | --- | --- | | `SeatPicker` | You want the map, legend, selection tray, hold timing, pricing presentation, responsive layout, and checkout handoff together. | [SeatPicker reference](/buyer-sdk/seat-picker) | | `SeatingChart` | Your React app owns the cart, controls, timer, messages, and surrounding buyer interface. | [SeatingChart reference](/buyer-sdk/seating-chart) | | `SeatManager` | Authorized staff need an embedded live-inventory control room inside your product. | [SeatManager guide](/platform/seat-manager) | | `EmbeddedDesigner` | Organizers need to create and edit venue charts without leaving your product. | [Embedded Designer guide](/platform/embedded-designer) | The package also re-exports `SeatPickerWidget` and `attachPickerFrame` from `@seatlayer/js` for hosts that deliberately choose those framework-agnostic delivery helpers. ## Install the package ```bash npm install @seatlayer/react ``` React 17 or later is supported, with ESM and CommonJS builds and bundled TypeScript types. ## Render the headless component ```tsx import { SeatingChart } from '@seatlayer/react'; function EventSeatMap() { return ( console.log('mode', info.mode)} onSelectionChange={(seats) => updateBasket(seats)} onHoldExpired={() => returnBuyerToMap()} /> ); } ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. The component owns its container and live updates; identity props such as the event key rebuild the chart cleanly. Calling a handle method before the chart exists returns an empty answer rather than throwing, so effects and early renders stay safe. For a complete `SeatPicker` walkthrough instead of a headless reference example, continue with [the React tutorial](/buyer-sdk/react-seating-chart). ## Browser and server boundary Keep the security boundary explicit: - The browser selects seats and creates a temporary hold. - Your backend inspects the hold and derives the amount to charge. - Your payment and order workflow stays on your server. - Your backend books with a stable `bookingRef` so retries are safe. - Expiry and inventory conflicts return buyers to a recoverable selection state. Continue with [holds and checkout](/buyer-sdk/holds-and-checkout) before connecting a production order flow. ## Package questions ### What does `@seatlayer/react` export? The primary buyer exports are `SeatPicker` and `SeatingChart`. The same package also exposes `SeatManager`, `EmbeddedDesigner`, typed component handles, and the framework-agnostic widget helpers listed above. Use the linked detailed references for each surface rather than treating every export as the same buyer component. ### Is this a real React component or an iframe? `SeatPicker`, `SeatingChart`, and `SeatManager` render a plain `div` in your React tree and drive their runtime directly. `EmbeddedDesigner` is the deliberate exception: the organizer Designer is isolated in a hosted iframe. Props, callbacks, and imperative handles are typed. ### Does the package include TypeScript definitions? Yes. The npm package includes declarations for ESM and CommonJS imports, plus typed props, callback payloads, and imperative component handles. ### Does it work with Next.js? Yes. The seat map is a browser component, so render it from a component you mark with `"use client"` and it works in the App Router and Pages Router alike. ### Which React and module versions are supported? React 17 or later is the declared peer requirement. The package publishes ESM and CommonJS builds so modern bundlers can select the matching entry point. ## Next steps - [Build the complete React flow step by step](/buyer-sdk/react-seating-chart) - [Install @seatlayer/react on npm](https://www.npmjs.com/package/@seatlayer/react) - [Run or inspect the SDK source](https://github.com/seatlayer/seatlayer-sdk) - [Understand the SeatLayer platform](https://seatlayer.io/) - [Explore the 3D seating chart](https://seatlayer.io/3d-seat-map/) - [Connect holds to secure checkout](/buyer-sdk/holds-and-checkout) # React Native SDK > Install SeatLayer React Native 0.4.0 and choose a ready-made picker, branded flow, custom component layout, or typed raw map for Expo and Community CLI apps. Source: https://docs.seatlayer.io/buyer-sdk/react-native/ · Markdown: https://docs.seatlayer.io/buyer-sdk/react-native/index.md SeatLayer React Native `0.4.0` ships a complete native buyer picker for Expo and React Native Community CLI applications. The package includes the ready-made flow, a controlled modal, themes and locale strings, 62 style slots, 27 complete-part builders, public components for a host-owned layout, and the preserved low-level `SeatLayerView` API. > **React Native 0.4.0 on npm** > > `@seatlayer/react-native@0.4.0` supports React 18.2+, React Native 0.72+, > and `react-native-webview` 13+, and includes ESM, CommonJS, and TypeScript > declarations. Every integration level described here is part of that > package version. > **Upgrading from 0.3.x** > > `0.4.0` is a breaking release. The dense phone ticket list and the > `From €X` peek line are gone: the cart shows one card per ticket. > `SeatLayerPeekLine`, the checkout button's `peekLine`, `fromPriceText`, > `totalText`, and `showPrices` inputs, the `denseLine*` style slots (now > `cartCard*`), `strings.applyFilters`, `strings.accessNoneLeft` (use > `notAvailable`), `onCartUndo`, and `SeatLayerBookButtonProps.compact` were > removed. The dock bar is off by default on every width; set `showDockBar` > to bring it back. See the > [tagged changelog](https://github.com/seatlayer/seatlayer-react-native/blob/v0.4.0/CHANGELOG.md) > for the full list. ## Install ### Expo ```bash npm install @seatlayer/react-native@0.4.0 npx expo install react-native-webview ``` ### Community CLI ```bash npm install @seatlayer/react-native@0.4.0 react-native-webview npx pod-install ``` React Native autolinks `react-native-webview` on iOS and Android. SeatLayer ships no additional native module, pod, or manual linking step. On iOS, the seat-note and accessibility icons need a vector renderer. Add the optional `react-native-svg` peer and register it once at start-up: ```tsx import Svg, { Circle, Path } from 'react-native-svg'; import { installSeatLayerPickerSvgIcons } from '@seatlayer/react-native'; installSeatLayerPickerSvgIcons({ Svg, Path, Circle }); ``` Without it, iOS keeps the words and drops the icon beside them. Give the picker a full-screen or otherwise bounded parent; the map cannot infer height from an unbounded layout. ## Render the complete picker Memoize the configuration with `useMemo`. A publishable key is appropriate for Public inventory; private or audience-restricted inventory uses the renewable provider shown below. ```tsx title="TicketPickerScreen.tsx" import React, { useMemo } from 'react'; import { View } from 'react-native'; import { SeatLayerPicker, type SeatLayerConfiguration, type SeatLayerPickerCheckoutHandoff, } from '@seatlayer/react-native'; export function TicketPickerScreen({ event }: { readonly event: string }) { const configuration = useMemo( () => ({ event, publicKey: '', currency: 'USD', maxSelection: 8, }), [event], ); const continueWithHandoff = async ( handoff: SeatLayerPickerCheckoutHandoff, ) => { await checkoutBackend.begin({ holdId: handoff.holdId }); }; return ( ); } ``` The default tree covers loading, live availability, price and accessibility filters, venue/floor/section navigation, confirmation and ticket tiers, GA and table quantities, a cart with one card per ticket, temporary holds, expiry recovery, 2D/3D and panorama chrome, and one typed checkout handoff. ## Public and private inventory For login-gated, presale, partner, channel, or other private inventory, omit `publicKey` and provide renewable buyer access from your authenticated backend: ```tsx const configuration = useMemo( () => ({ event, buyerAccessTokenProvider: ({ reason }) => buyerBackend.mintSeatLayerAccess(reason), }), [event], ); ``` Mint the native buyer-access session through [buyer access sessions](/server-api/buyer-access-sessions), return only its short-lived token to the provider, and treat the SDK access context as opaque. Tokens stay in memory. Never put a SeatLayer secret key in JavaScript, the app bundle, a URL, a React key, an event payload, logs, or analytics. ## Choose an integration level | Level | Entry point | Your application owns | | --- | --- | --- | | Complete screen | `SeatLayerPicker` | Route, configuration, callbacks, checkout destination | | Controlled presentation | `SeatLayerPickerModal` | Visibility, barrier behavior, and dismissal | | Branded flow | Picker + options, theme, strings, styles, builders | Brand and selected complete parts | | Custom hierarchy | `SeatLayerPickerScope` + public components | The complete React Native layout | | Raw map | `SeatLayerView` + `SeatLayerController` | Every buyer surface and hold transition | All five paths use the same inventory source. The first four keep the native picker's selection rules, hold ownership, lifecycle recovery, supported-feature checks, and checkout handoff. ### Present it as a modal ```tsx setPickerOpen(false)} barrierDismissible /> ``` The modal uses the same scope and semantic Back ladder as the in-page picker; it does not create a second product surface. ### Brand or replace individual parts ```tsx defaultChild }} onCheckout={continueWithHandoff} /> ``` Use options for behavior and visibility, theme roles for coordinated native/map color, locale and strings for wording, style slots for aesthetic changes, and a builder when a complete part needs different structure. Builders receive the live scope and canonical child; they do not become inventory authority. [See every integration layer and public component](/buyer-sdk/react-native/native-picker). ## Checkout and hold ownership The picker selects and holds inventory. Your trusted backend inspects and books the hold after payment or order validation. 1. `onCheckout` receives one opaque `SeatLayerPickerCheckoutHandoff`. 2. Send only `handoff.holdId` and your normal order context to the backend. 3. Inspect the hold and calculate payable totals from trusted server data. 4. Charge through your payment provider. 5. Reuse the same `bookingRef` so retries are idempotent. Ordinary picker state deliberately omits the hold ID. Before a successful handoff, the picker owns the hold and may release it on close. After your callback accepts the handoff, your product owns booking, rejection/release, or expiry. If the callback rejects, the SDK rejects that exact handoff and returns the buyer to a recoverable picker state. Read [holds and secure checkout](/buyer-sdk/holds-and-checkout) before wiring payment. ## Application and SDK ownership | React Native components own | SeatLayer SDK owns | | --- | --- | | Header, price/floor/section navigation, accessibility filters, confirmation, quantities, cart, hold state, checkout, loading/errors, Test Mode, required attribution, Back and adaptive presentation | Seats, labels, geometry, hit testing, pan/pinch, camera, authored venue 3D, and panorama pixels | | Safe areas, app lifecycle, native component layout, haptics, and accessibility semantics | Authoritative inventory state and supported map capabilities | Changing native presentation does not create another map or discard camera, selection, or hold state. ## Lifecycle and recovery The ready picker handles foreground synchronization, availability refresh, hold-lapse recovery, viewport insets, and a deterministic Back ladder. Keep the picker mounted for the screen lifecycle and keep configuration identity unchanged. - A resumed app reconciles the server hold instead of trusting a paused timer. - Seats sold while the app was away are removed through authoritative unavailable-object state. - Repeated checkout taps join one flight. - A rejected checkout remains recoverable in the picker. - Unmount permanently ends that picker session. - Restore a host-owned hold only when your checkout promises restoration. Raw-map applications must implement these host responsibilities themselves; see the [raw-map reference](/buyer-sdk/react-native/raw-map). ## Run the tagged example ```bash git clone --branch v0.4.0 --depth 1 https://github.com/seatlayer/seatlayer-react-native.git cd seatlayer-react-native pnpm install cd example pnpm install EXPO_PUBLIC_SEATLAYER_EVENT="" \ EXPO_PUBLIC_SEATLAYER_PUBLIC_KEY="" \ pnpm start ``` The Expo example mounts the ready-made picker and exercises the secure checkout handoff. Validate real inventory, lifecycle, safe areas, rotation, accessibility, 3D/panorama, and checkout on supported physical iOS and Android devices before rollout. ## Product scope React Native `0.4.0` accepts one published Event per picker. Native Performance Group and Season configuration is not part of this release; use their browser pickers until a native release documents those contracts. ## Next steps - [Native picker, customization, and component composition](/buyer-sdk/react-native/native-picker) - [Raw-map API](/buyer-sdk/react-native/raw-map) - [Compare all four native SDKs](/buyer-sdk/mobile) - [Secure checkout](/buyer-sdk/holds-and-checkout) - [Troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) - [`v0.4.0` source](https://github.com/seatlayer/seatlayer-react-native/tree/v0.4.0) - [Tagged example](https://github.com/seatlayer/seatlayer-react-native/tree/v0.4.0/example) - [Tagged TypeScript exports](https://github.com/seatlayer/seatlayer-react-native/blob/v0.4.0/src/index.ts) - [Tagged changelog](https://github.com/seatlayer/seatlayer-react-native/blob/v0.4.0/CHANGELOG.md) # Native picker and custom layouts > Use the React Native 0.4.0 picker as a ready-made flow, customise any complete part, or compose a host-owned layout from public components. Source: https://docs.seatlayer.io/buyer-sdk/react-native/native-picker/ · Markdown: https://docs.seatlayer.io/buyer-sdk/react-native/native-picker/index.md React Native `0.4.0` ships the complete native picker on npm. It adds a ready-made screen, controlled modal, generated themes and locale strings, standalone scoped chrome, adaptive presentation, and capability-aware actions while preserving the raw-map API. > **Installable release** > > Install `@seatlayer/react-native@0.4.0`; do not pin a repository commit. The > public APIs on this page are exported by that npm package. ## What is native React Native components own the header, price/floor/section navigation, accessibility filters, confirmation, tier and quantity decisions, cart, hold status, checkout, loading, errors, and controls around immersive views. SeatLayer owns seat geometry, labels, map gestures, venue 3D, panorama, and authoritative inventory behavior behind the public SDK API. That seam gives the host a real React Native hierarchy without duplicating the venue engine, inventory state, or hold contract. ## Integration ladder | Level | API | What changes | | --- | --- | --- | | Complete flow | `SeatLayerPicker` | Configuration and callbacks only | | Controlled modal | `SeatLayerPickerModal` | Visibility and dismissal | | Branded flow | Theme, strings, options, styles, builders | Expression or selected complete parts | | Custom hierarchy | `SeatLayerPickerScope` + public components | Entire React Native layout | | Raw map | `SeatLayerView` + `SeatLayerController` | Host owns every buyer state and transition | The first four levels retain one picker state, ordered inventory actions, supported-feature checks, hold ownership, lifecycle recovery, and the same typed checkout handoff. ## Ready-made picker ```tsx ', publicKey: '', currency: 'USD', }} themeMode="auto" onCheckout={(handoff) => checkoutBackend.begin({ holdId: handoff.holdId }) } /> ``` Use `SeatLayerPickerModal` when the picker is controlled by a parent route or dialog. Both use the same adaptive compact/wide tree and semantic Back ladder. ## Customize without forking The layers have separate ownership: | Layer | Use it for | It must not change | | --- | --- | --- | | `options` | Behavior, adaptive layout, optional chrome, haptics, languages, display price formatter | Inventory prices, state truth, safe areas | | `themeMode`, `themeOptions` | Semantic native and map color roles, typography, radii, logos | Authoritative Event branding | | `locale`, `strings` | One of 37 generated dictionaries and exact host overrides | Event data supplied by SeatLayer | | `styles` | 62 aesthetic slots for color, type, borders, radii, shadows, opacity | SDK placement, viewport insets, 44-point minimum targets | | `builders` | Wrap or replace one of 27 complete parts | Inventory state, actions, hold ownership | ```tsx new Intl.NumberFormat('fr-FR', { style: 'currency', currency, }).format(amount), }, }} locale="fr-FR" strings={{ holdAndCheckout: 'Continue' }} styles={{ headerContainer: { backgroundColor: '#172033' }, continueButton: { backgroundColor: '#5B4B8A' }, }} builders={{ header: ({ defaultChild }) => defaultChild, }} onCheckout={continueWithHandoff} /> ``` The pricing formatter changes display only. Selection, hold totals, and the trusted server remain authoritative. ## Compose a host-owned layout `SeatLayerPickerScope` owns one controller and the latest read-only picker state. Every standalone part reads the same scope, resolved theme, capabilities, presentation state, and actions. ```tsx import { View } from 'react-native'; import { SeatLayerCartSheet, SeatLayerDockBar, SeatLayerFloorStrip, SeatLayerMapControls, SeatLayerPickerAccessibilityFilters, SeatLayerPickerChart, SeatLayerPickerHeader, SeatLayerPickerHoldCountdown, SeatLayerPickerScope, SeatLayerPickerViewModeControl, SeatLayerPriceLegend, } from '@seatlayer/react-native'; ``` Call `useSeatLayerPicker()` inside the scope when a host component needs the latest state, resolved theme, capability availability, presentation state, or typed actions. Do not create a second chart for the same buyer flow. ## Public component groups | Buyer job | Public parts | | --- | --- | | Identity and navigation | `SeatLayerPickerHeader`, `SeatLayerFloorStrip`, section navigator, `SeatLayerDockBar` | | Map and filters | `SeatLayerPickerChart`, `SeatLayerMapControls`, view-mode control, price legend, accessibility filters | | Decisions | Confirmation, tier choice, GA/table prompts, best-available form | | Cart and holds | Cart cards, cart list/sheet, countdown, hold-lapse notice | | Immersive views | Venue 3D navigation and seat-view/panorama chrome | | System states | Loading, retryable/fatal error, empty/sold-out/sales-closed, action error | | Truth indicators | Test Mode and required bottom-right SeatLayer attribution | The component catalogue also exports ticket-line utilities (`resolveSeatLayerTicketLines` and related helpers) so custom carts can reuse the canonical ordering rather than rebuilding it from low-level inventory data. ## State and action guarantees - One scope owns one controller and one picker state. - Published state is read-only and represents the complete current picker. - Stale updates and updates for another event are ignored. - Inventory-changing actions are serialized. - Optional controls appear only when the negotiated capability exists. - Repeated checkout taps join one flight. - Ordinary picker state omits the opaque hold ID. - `SeatLayerPickerCheckoutHandoff` crosses that boundary exactly once. - A rejected host callback rejects that handoff and restores a recoverable state. Use `useSeatLayerPicker()` actions rather than mutating picker state locally. ## Lifecycle, Back, and recovery The ready picker observes app state, refreshes availability when supported, reconciles a lapsed hold, and removes seats that became unavailable while the app was away. A local countdown is only presentation; the server expiry and current SDK state are authoritative. The Back ladder closes the most local decision first: quantity or confirmation, expanded cart, panorama/3D, focused section, venue, then the host route. Custom layouts must keep equivalent behavior around the same scoped actions. ## Test Mode and attribution Test Mode follows authoritative Event state and must remain visible. `0.4.0` keeps required `Powered by SeatLayer` attribution at the safe bottom-right edge in compact and wide layouts. Attribution follows the SeatLayer account configuration and cannot be overridden by client-side styles or builders. ## Production validation - Pin `@seatlayer/react-native@0.4.0` and `react-native-webview` deliberately. - Validate Expo Go/development builds and Community CLI paths you support. - Test compact/wide layout, safe areas, rotation, Back, background/foreground, process restoration, hold lapse, checkout rejection, and picker load failure. - Exercise real tiers, accessibility filters, GA/tables, 3D, panorama, Test Mode, and attribution with hosted events. - Test supported physical iOS and Android devices before rollout. - Confirm the backend inspects the hold and reuses one booking reference for retries. ## Product scope The native picker selects one Event. It does not accept a Performance Group or Season as native configuration. ## Related pages - [React Native quick start](/buyer-sdk/react-native) - [Raw-map API](/buyer-sdk/react-native/raw-map) - [Compare mobile SDKs](/buyer-sdk/mobile) - [`v0.4.0` source](https://github.com/seatlayer/seatlayer-react-native/tree/v0.4.0) - [Tagged picker entry point](https://github.com/seatlayer/seatlayer-react-native/blob/v0.4.0/src/picker/SeatLayerPicker.tsx) - [Tagged scope](https://github.com/seatlayer/seatlayer-react-native/blob/v0.4.0/src/picker/SeatLayerPickerScope.tsx) - [Tagged example](https://github.com/seatlayer/seatlayer-react-native/tree/v0.4.0/example) - [Tagged changelog](https://github.com/seatlayer/seatlayer-react-native/blob/v0.4.0/CHANGELOG.md) - [Troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) # Raw map API > Use SeatLayerView and SeatLayerController from React Native 0.4.0 when your application intentionally owns every buyer control and hold transition. Source: https://docs.seatlayer.io/buyer-sdk/react-native/raw-map/ · Markdown: https://docs.seatlayer.io/buyer-sdk/react-native/raw-map/index.md This page is the focused reference for the preserved raw-map API shipped in `@seatlayer/react-native@0.4.0`. > **Raw means your UI** > > The map renders and mutates authoritative SeatLayer inventory. Your app owns > selection explanation, confirmation, cart, hold timing, checkout, errors, > test-mode disclosure, and any required attribution around it. ## Public component contract ```tsx ``` `controller` and `configuration` are required. `style`, `reloadKey`, `onReady`, and `onLoadError` are optional. The component is not a `forwardRef` surface. Use `reloadKey` only for an intentional new chart generation. Ordinary React rerenders should keep both the controller and configuration identity unchanged. ## Buyer access The package's dependable path is a renewable provider backed by your server: ```tsx import type { SeatLayerConfiguration } from '@seatlayer/react-native'; const configuration = useMemo( () => ({ event, locale: 'en', currency: 'USD', maxSelection: 6, buyerAccessTokenProvider: ({ reason }) => buyerBackend.mintSeatLayerAccess(reason), }), [event], ); ``` The provider returns a short-lived `BuyerAccessToken` in memory. An explicit one-shot `buyerAccessToken` is also supported, but a provider can renew after expiry or authorization failure without rebuilding the view. Create the native buyer-access session on your backend through [buyer access sessions](/server-api/buyer-access-sessions), return only its short-lived token to the provider, and treat SDK access details as opaque. For Public inventory, use the matching publishable key: ```tsx const configuration = useMemo( () => ({ event, publicKey: '' }), [event], ); ``` ### Configuration map | Concern | Configuration fields | | --- | --- | | Identity/access | `event`, `publicKey`, `buyerAccessToken`, `buyerAccessTokenProvider` | | Selection policy | `maxSelection`, `selectedObjects`, `selectableObjects`, `numberOfPlacesToSelect`, `selectionValidators` | | Presentation | `locale`, `messages`, `currency`, `colorblindSafe`, `initialView`, `showsWebSeatTooltip` | | Deadlines/diagnostics | `commandTimeoutMs`, `handshakeTimeoutMs`, `hostInfo` | | Non-default API | `apiBase`; leave unset for SeatLayer production | See the [tagged `SeatLayerConfiguration` declaration](https://github.com/seatlayer/seatlayer-react-native/blob/v0.4.0/src/types.ts) for exact types and payload models. ## Subscribe to controller events `controller.on` returns an unsubscribe function, so it fits directly into a React effect: ```tsx useEffect(() => { const stopSelection = controller.on('selectionChanged', setSelectedSeats); const stopExpiry = controller.on('holdExpired', returnBuyerToMap); const stopError = controller.on('error', reportSeatLayerError); return () => { stopSelection(); stopExpiry(); stopError(); }; }, [controller]); ``` Event payloads are typed. Unknown future events are retained rather than crashing an older application. | Event | Payload/meaning | | --- | --- | | `selectionChanged` | Full `SelectedSeat[]`; replace, do not append to, host state | | `selectionValidityChanged` | `SelectionValidity` for exact-count and validator UI | | `holdChanged`, `holdRestored`, `holdExpired` | Authoritative hold state and expiry | | `accessExpired`, `accessUnavailable` | Refresh outcome or private-access refusal | | `selectedObjectsUnavailable` | Labels/reason that must be removed from buyer UI | | `error`, `unknownEvent` | Typed operational error or forward-compatible raw event | The host explicitly calls `hold()` and hands only `holdId` to its backend. No map event is booking authority or should launch raw-map checkout by itself. ## Common command groups These are common task groups, not an exhaustive API substitute. | Group | Methods | | --- | --- | | Read | `getSelection`, `getSelectionValidity`, `getCurrentHold`, `getGAAreas`, `getFloors`, `getViewMode` | | Select | `selectObjects`, `deselectObjects`, `clearSelection`, `selectCategories`, `deselectCategories`, `setSelectableObjects`, `setMaxSelection`, `setSeatTier` | | Hold | `hold`, `resumeHold`, `extendHold`, `release`, `releaseLabels`, `bestAvailable`, `holdGA` | | Access | `refreshAccess` | | Map | `setFloor`, `setColorblindSafe`, `setViewMode`, `zoomIn`, `zoomOut`, `zoomToFit` | | Session | `destroy` | Use the [tagged controller source](https://github.com/seatlayer/seatlayer-react-native/blob/v0.4.0/src/controller.ts) for exact arguments, returns, and every method in `0.4.0`. Gate optional host controls from negotiated bundle capabilities and handle `unsupported_command`; method presence alone does not prove an Event supports the feature. Commands return promises. Catch `SeatLayerError` at the awaited call so a sold seat, incompatible capability, timeout, or destroyed session stays attached to the action that failed. ```tsx try { const hold = await controller.bestAvailable(4); if (hold) await checkoutBackend.begin({ holdId: hold.holdId }); } catch (error) { if (error instanceof SeatLayerError) { showInventoryMessage(error.code, error.message); } } ``` ## Hold ownership - A hold is temporary inventory ownership, not a booking. - Keep its opaque ID out of logs and analytics. - Call `resumeHold(holdId)` only when restoring a checkout your product owns. - Treat `holdExpired` as authoritative even if an in-app countdown had time remaining. - Send only `holdId` to the server. Cart lines and totals in the app are for display, not payment authority. ## View and gesture requirements - Give `SeatLayerView` a definite size. - Do not mount it in a parent that competes for pan or pinch gestures. - Keep application overlays accessible without intercepting the map accidentally. - Use the SDK's typed camera and floor commands; do not forward raw touch coordinates into the map. ## Cleanup `useSeatLayerController()` owns controller cleanup for the hook lifecycle. If a different integration constructs and owns a controller directly, destroy that session when its screen is permanently finished. Do not send commands after destruction. ## Related pages - [React Native SDK overview](/buyer-sdk/react-native) - [Native picker and custom layouts](/buyer-sdk/react-native/native-picker) - [Holds and checkout](/buyer-sdk/holds-and-checkout) - [Lifecycle and foreground recovery](/buyer-sdk/react-native#lifecycle-and-recovery) - [Shared troubleshooting matrix](/buyer-sdk/mobile#troubleshooting) - [Tagged example](https://github.com/seatlayer/seatlayer-react-native/tree/v0.4.0/example) - [Tagged changelog](https://github.com/seatlayer/seatlayer-react-native/blob/v0.4.0/CHANGELOG.md) # Add a seat map to a React app > Follow a step-by-step React tutorial to install SeatPicker, render live seats, authorize buyers, handle selection, and pass a hold to checkout. Source: https://docs.seatlayer.io/buyer-sdk/react-seating-chart/ · Markdown: https://docs.seatlayer.io/buyer-sdk/react-seating-chart/index.md To add a seat map to a React app, install `@seatlayer/react`, render the `SeatPicker` component with an event key and a public key, and pass the hold it returns to your own checkout. The five numbered steps below do exactly that: install, mount, authorize the audience, react to selection and holds, then hand off to checkout. This tutorial walks from an empty React checkout page to a working `SeatPicker` with live selection, temporary holds, private-audience authorization, and a secure checkout handoff. The picker already includes the map, selection tray, hold countdown, and mobile layout; the steps below connect those pieces to your application. Looking for package exports, React requirements, module formats, or the headless component contract? Use the [`@seatlayer/react` package reference](/buyer-sdk/react) instead. Test mode is free with no time limit and no card, so you can complete every step below before a live account exists. See [SeatLayer pricing](https://seatlayer.io/pricing/) for what happens after that, or the [seat map SDK and API overview](https://seatlayer.io/developers/) for how the pieces fit together. ## 1. Install the package ```sh npm install @seatlayer/react@0 pnpm add @seatlayer/react@0 yarn add @seatlayer/react@0 bun add @seatlayer/react@0 ``` For package requirements and the complete export directory, see the [React SDK reference](/buyer-sdk/react). For script-tag, browser ESM, plain JavaScript, Vue, and Angular routes, see the [install options for all frameworks](/buyer-sdk/install). ## 2. Render the seating chart The React wrapper exports `SeatPicker`. Give it an event key and an explicit size. The SDK is container-responsive, so its mount element needs a definite width and height. ```tsx title="Checkout.tsx" import { SeatPicker } from "@seatlayer/react"; export function Checkout() { return ( { beginCheckout(handoff.holdId); }} /> ); } ``` > **Public sale starts directly** > > For a public Platform event, `publicKey` lets the SDK call SeatLayer directly. > SeatLayer verifies the event, key mode, and exact registered browser origin, > then returns a Public-only in-memory bearer with the chart and compact > inventory status in one bootstrap response. The public key is publishable; > never put the matching `sk_…` secret in React code. ## 3. Authorize private audiences Skip this step when every buyer sees only Public sale. For login, presale, partner, or channel inventory, `buyerAccessTokenProvider` is called with a reason and returns a scoped token and expiry from your own authenticated backend. Keep the function outside the component (or in a `useCallback`) so it is not recreated on every render. ```tsx title="Checkout.tsx" import { SeatPicker } from "@seatlayer/react"; async function getSeatLayerBuyerAccess({ reason }) { const response = await fetch("/api/seatlayer/buyer-access", { method: "POST", credentials: "same-origin", cache: "no-store", headers: { "content-type": "application/json" }, body: JSON.stringify({ reason }), }); if (!response.ok) throw new Error("Unable to open seat selection"); return response.json(); } export function Checkout() { return ( { startCheckout({ holdId: handoff.holdId }); }} /> ); } ``` The `bse_…` token stays in memory. No `sk_…` credential belongs in a client bundle. If both `publicKey` and a provider/token are supplied, the explicit buyer credential takes precedence and the SDK never falls back to anonymous Public sale. ## 4. React to selection and holds The component takes the same options and callbacks as the [SeatPicker reference](/buyer-sdk/seat-picker), passed as props. Two are useful almost immediately: - `onSelectionChange`: `(seats) => void`, fires whenever the selection changes. - `onHoldChange`: `(hold, seats, handoff) => void`, fires when a hold is created, restored, extended, partially released, or released. ```tsx title="Checkout.tsx" const [seats, setSeats] = useState([]); return ( { startCheckout({ holdId: handoff.holdId }); }} /> ); ``` `SeatPicker` already renders its own selection tray and hold countdown, so treat this state as context for the rest of your page rather than a cart you have to build. ## 5. Hand off to your checkout A hold temporarily protects inventory while a buyer completes checkout. It is not a sale. The Buyer SDK creates the hold; your server inspects and books it. The third `onCheckout` argument is the handoff: ```ts interface CheckoutHandoff { holdId: string; expiresAt: number; currency: string; lineItems: CheckoutLineItem[]; total: number; } ``` 1. **Hand off only holdId** Your checkout route receives the hold identity and your own cart context, never a secret key or trusted price. 2. **Build the trusted order on your server** It inspects the active hold, calculates the charge from its returned items, and runs your payment workflow. 3. **Start a visible timer from server time** Count down from server `expiresAt`, not a locally invented duration. The browser handoff is excellent display context, but payment still uses a fresh server-side inspection. The complete flow, including release and restore, is in [holds and checkout handoff](/buyer-sdk/holds-and-checkout). ## 6. Optional: go headless with SeatingChart `@seatlayer/react` also exports `SeatingChart`, the headless seating canvas without SeatLayer's cart/tray chrome. Reach for it only when your application owns totals, tier/GA controls, the checkout button, countdown, and buyer messages. ```tsx title="CustomSeatMap.tsx" import { SeatingChart } from "@seatlayer/react"; export function CustomSeatMap() { return ( updateYourSelectionUI(seats)} /> ); } ``` Choose `SeatPicker` unless your application has a clear reason to own selection controls, confirmation, hold timing, pricing presentation, and mobile behavior. Full options are in the [SeatingChart headless reference](/buyer-sdk/seating-chart). If you are weighing that against building the map yourself, the [JavaScript seating chart guide](https://seatlayer.io/guides/javascript-seating-chart/) walks through what a hand-built chart still leaves you owning. ## Before you ship - [ ] The package loads without browser errors. - [ ] The mount container has an explicit usable size. - [ ] A test event renders. - [ ] Selection changes appear immediately. - [ ] Checkout produces a `holdId`. - [ ] No `sk_…` credential exists in the client bundle. - [ ] Public Platform embeds use `publicKey`; scoped private audiences use a provider/token, and every `bse_…` bearer stays in memory. - [ ] Narrow mobile and keyboard behavior are usable. ## Troubleshoot the walkthrough ### Why is the seat map blank or collapsed? Give the component a definite height, then check that the event key, key mode, and exact registered browser origin match. A responsive picker cannot infer a usable height from a collapsed parent. ### When should I use `publicKey` or `buyerAccessTokenProvider`? Use the mode-matched publishable `publicKey` for an event where every buyer sees Public sale. Use a backend-backed provider for login, presale, partner, or channel inventory. If both are present, the explicit buyer credential wins. ### What should the React app send to checkout? Send the opaque `holdId` plus your own cart context. Your backend inspects the active hold, derives trusted line items and pricing, runs payment, and books with a stable `bookingRef`; do not trust a browser-submitted amount. ## Runnable examples Two complete applications put the steps above together, ready to clone: - [seatlayer-react-example](https://github.com/seatlayer/seatlayer-react-example): Vite and React, with the headless `SeatingChart`, best available, holds, and a checkout handoff. - [seatlayer-nextjs-example](https://github.com/seatlayer/seatlayer-nextjs-example): the same flow on Next.js 15 App Router, plus an `/api/hold` server route that shows where the trusted half belongs. Both read an `.env.local` with your event key and public key, and both carry one-click deploy buttons in their READMEs. Continue to the [`@seatlayer/react` package reference](/buyer-sdk/react), the [Quickstart](/start/quickstart), or the [core hold and booking model](/start/how-it-works). # Season picker > Let a buyer choose one seat package for every performance in a fixed renewable Season, then hand the inventory hold to your checkout. Source: https://docs.seatlayer.io/buyer-sdk/seasons/ · Markdown: https://docs.seatlayer.io/buyer-sdk/seasons/index.md `SeasonPicker` is the buyer seating journey for a fixed renewable Season of 2–20 performances that share one published seating chart. The buyer chooses once, reviews the full package, and keeps those exact seats for every included performance. Use this page for picker configuration and behavior. Follow the [end-to-end Season integration](/integrations/seasons/) for Plan publication, rehearsal, checkout, booking, rights, and renewal, or the [Seasons API](/server-api/seasons/) for exact trusted operations. This is different from a [Performance Group](/buyer-sdk/performance-groups): a Season has an immutable published Plan, durable Seat Rights for incumbents, and renewal offers. A buyer cannot remove dates or choose a different seat for each performance. > **Released Season buyer boundary** > > Season selection is in `@seatlayer/js`, `@seatlayer/react`, `@seatlayer/vue`, > and `@seatlayer/angular`, and requires `0.72.1` or later (current release > `0.106.1`). The buyer picks exact seats on the map. Season Best Available is > a method on the `@seatlayer/js` picker, `holdBestAvailable()`, since `0.73.0`; > the React, Vue, and Angular handles do not expose it yet. React Native, > Flutter, iOS, and Android do not expose a native `SeasonPicker`. Organizers > can create and run Seasons from the dashboard's **Seasons** page, or through > the documented API and server SDKs. | Product | Best suited to | What the buyer chooses | |---|---|---| | Single Event | One show, match, or screening | Seats for that Event | | Performance Group | A short run or flexible multi-date bundle | Same seats across a run, or an equal party size per date | | Season | Theatre, sports, memberships, and subscriptions with future renewal | One seat package for the complete published Plan | > **SeatLayer owns inventory, not commerce** > > The picker creates an atomic same-seat hold. Your trusted server still owns > package pricing, discounts, member benefits, checkout, payment, commercial > orders, tickets or passes, refunds, and customer support. ## JavaScript Mint a short-lived Season buyer session on your server, then render the picker: ```bash npm install @seatlayer/js ``` ```ts title="browser/season-picker.ts" import { SeasonPicker } from "@seatlayer/js"; const picker = new SeasonPicker({ container: "#season-picker", season: "sea_main_stage_2027", buyerAccessTokenProvider: async () => { const response = await fetch("/api/seatlayer/season-access", { method: "POST", credentials: "same-origin", cache: "no-store", }); if (!response.ok) throw new Error("Unable to open Season seat selection"); return response.json(); // { token: "bss_…", expiresAt } }, offer: { eyebrow: "2027 membership", priceLabel: "From $480", compareAtPriceLabel: "From $600 bought separately", savingsLabel: "Save $120", priceNote: "Final package price depends on your seat and is confirmed at checkout", benefits: ["Priority entry", "Free ticket exchange", "Renewal priority"], renewalLabel: "Same-seat renewal eligible", }, onHold: (handoff) => { // Persist operationId so the same hold can be recovered after a lost response. sessionStorage.setItem("seasonOperationId", handoff.operationId); }, onContinue: (handoff) => { beginCheckout({ season: handoff.seasonKey, holdId: handoff.holdId, operationId: handoff.operationId, planActivationId: handoff.planActivationId, }); }, onError: (error) => reportSeatSelectionError(error), }); await picker.render(); ``` The picker shows the Season promise, venue, complete performance list, benefits, aggregate availability, interactive chart, selection summary, and a clear choose → review → checkout progression. Package copy in `offer` is display-only; calculate and verify the authoritative amount again on your server. Event-level chart amounts, ticket tiers, section price ranges, and totals are suppressed in Season mode so they cannot be mistaken for the package price. If your Season price varies by seat category, present a truthful starting price or update your surrounding storefront from `onSelectionChange`; never multiply an Event amount in the browser. ### Best Available for a Season `holdBestAvailable(qty, operationId, options?)` asks SeatLayer for the best `qty` seats that are free on every performance in the Plan, then holds them exactly as a map selection would. It resolves to the same `SeasonCheckoutHandoff` that `onHold` receives. ```ts title="browser/season-best-available.ts" const handoff = await picker.holdBestAvailable(2, `sop_${crypto.randomUUID()}`, { categoryKey: "stalls", // optional zoneId: "zone_lower", // optional accessibility: ["wheelchair"], // optional, any-of across every Event requireContiguous: true, // refuse scattered seats when no block of 2 exists }); sessionStorage.setItem("seasonOperationId", handoff.operationId); ``` - `operationId` must start with `sop_` and stay the same on a retry, so a lost response recovers the same hold instead of creating a second one. - `qty` is 1 to 10 seats per call, and never more than `maxSelection`. - One call runs at a time. A second call while the first is pending fails with `season_best_available_in_progress`. - The server route is `POST /pub/seasons/:key/best-available`, using the same Season buyer session as the picker. - In `0.106.1` the React `SeasonPickerHandle` and the Vue and Angular components expose `holdSameSeat()` but not `holdBestAvailable()`. Use `@seatlayer/js` directly for this call. ## React ```bash npm install @seatlayer/react ``` ```tsx title="components/SeasonSeats.tsx" import { SeasonPicker } from "@seatlayer/react"; export function SeasonSeats() { return ( beginCheckout(handoff)} /> ); } ``` The React component mounts and destroys the canonical JavaScript picker. It does not duplicate Season inventory logic. ## Vue ```bash npm install @seatlayer/vue ``` ```vue title="components/SeasonSeats.vue" ``` ## Angular ```bash npm install @seatlayer/angular ``` Import `SeatLayerSeasonPickerComponent` into a standalone component or NgModule, then render its `seatlayer-season-picker` selector: ```html title="season-seats.component.html" ``` All four browser/framework packages use the same protected Season protocol and return the same opaque, price-free handoff. ## Checkout boundary Treat the browser handoff as an inventory reference, not proof of purchase: 1. Mint the `bss_…` session on your trusted server for the exact Season, published Plan, environment, and browser origin. 2. Let `SeasonPicker` create one all-or-nothing hold for the selected labels across every included Event. 3. Inspect that operation from your server and calculate the authoritative package price and benefits. The inspection is identity-only: it includes Event, seat, category, optional tier, and quantity, never an amount. 4. Take payment in your platform. 5. Book the same operation with a caller-stable action ID and your `bookingRef`. 6. Issue the pass or tickets only after booking reaches a terminal success. See the [Seasons server API](/server-api/seasons#buyer-session-inspect-book-cancel) for mint, inspect, book, cancellation, renewal, and recovery operations. ## Important behavior - Availability is the intersection across the complete published Plan. A seat unavailable on one performance is unavailable for the Season. - Holds are atomic: every allocation succeeds or none is presented as held. - `operationId` and release/book action IDs are caller-stable recovery keys. - `onHold` means inventory is temporarily held. `onContinue` means the buyer pressed the explicit checkout action. Neither means payment or booking. - The buyer picks exact seats on the map. To let SeatLayer choose, call `holdBestAvailable()` from `@seatlayer/js` (see below). - `createRenewalIntent(offerId)` records intent only. Your platform confirms renewal price, payment, order, and fulfilment. ## Core options | Option | Type | Notes | |---|---|---| | `container` | `string \| HTMLElement` | Required JavaScript mount target. | | `season` | `string` | Required `sea_…` key with a published Plan. | | `buyerAccessTokenProvider` | `() => Promise<{ token, expiresAt }>` | Preferred origin-bound credential refresh path. | | `buyerAccessToken` | `string \| { token, expiresAt }` | Non-refreshing alternative. | | `offer` | `SeasonOfferPresentation` | Display-only package price, savings, benefits, and renewal copy. | | `maxSelection` | `number` | Maximum seats in one Season package hold. | | `initialOperationId` | `string` | Restores the same accepted or pending hold after interruption. | | `initialView` / `enable3D` / `theme` | picker options | Reuses the standard chart presentation controls. | | `onHold` / `onHoldChange` | callbacks | Observe the opaque inventory handoff. | | `onContinue` | callback | Start host checkout after the buyer reviews the held package. | | `onStatusChange` / `onError` | callbacks | Present operational and failure states in the host application. | # SeatPicker reference > Complete buyer widget options, callbacks, methods, checkout handoff, modal mode, 3D, holds, and responsive behavior. Source: https://docs.seatlayer.io/buyer-sdk/seat-picker/ · Markdown: https://docs.seatlayer.io/buyer-sdk/seat-picker/index.md `SeatPicker` is the complete buyer experience: live map, confirmation, tier/GA controls, selection tray, hold countdown, best available, 2D/3D, view from seat, mobile bottom sheet, sold-out/closed states, and checkout handoff. For how it sits beside the server and mobile packages, see the [seat map API overview](https://seatlayer.io/developers/). ```js title="browser/picker.js" import { SeatPicker } from "@seatlayer/js"; const picker = new SeatPicker({ container: "#picker", event: "", publicKey: "", onCheckout: (_hold, _seats, handoff) => { startCheckout({ holdId: handoff.holdId }); }, }); await picker.render(); ``` For a public Platform event, `publicKey` creates one direct, origin-bound, Public-sale-only bootstrap. The response carries chart geometry and compact inventory status together; its short-lived bearer stays in memory. Login, presale, partner, and channel inventory instead use `buyerAccessTokenProvider` or `buyerAccessToken` from your authenticated backend. Either explicit buyer credential takes precedence over `publicKey`. ## Choose the surface | | `SeatPicker` | `SeatingChart` | |---|---|---| | Map canvas | Included | Included | | Cart/tray, CTA, timer, states | Included | Host builds | | Seat confirmation and 360 view | Included | Host builds | | Modal helper | `SeatPicker.open()` | No | | Best for | Complete, styleable buyer flow | Fully custom cart and controls | For a buyer purchasing a fixed multi-performance run, use [`PerformanceGroupPicker`](/buyer-sdk/performance-groups) instead. Do not pass an Event array to `SeatPicker` or create several event holds in the browser. ## Constructor options | Option | Type | Default/notes | |---|---|---| | `container` | `string \| HTMLElement` | Required inline; omit for `open()` | | `event` | `string` | Required event key | | `apiBase` | `string` | `https://api.seatlayer.io` | | `transport` | `PickerTransport` | Built-in public transport; useful for controlled adapters/tests | | `operationalTelemetry` | `boolean` | Omit for built-in production-only timing, `true` to opt in for another API/custom transport, or `false` to disable | | `publicKey` | `string` | Publishable key for direct, exact-origin, Public-sale-only Platform bootstrap | | `buyerAccessTokenProvider` | `({ reason }) => Promise<{ token, expiresAt }>` | Refreshable private/login/presale/channel access from your authenticated backend; takes precedence over `publicKey` | | `buyerAccessToken` | `string \| { token, expiresAt }` | Non-refreshing escape hatch; prefer the provider | | `maxSelection` | `number` | `10` | | `selectedObjects` | `string[]` | Initial object ids or public labels, applied after live availability loads | | `selectableObjects` | `string[] \| null` | Buyer-selectable allow-list; omit/null for every free object | | `numberOfPlacesToSelect` | `number` | Require exactly this many seated/table/GA guest units before checkout | | `selectionValidators` | `PickerSelectionValidator[]` | Optional minimum-count, consecutive-seat, and no-orphan sale guards | | `locale` | `string` | Browser language → `en`; built-in `en`, `es`, `de`, `fr` | | `messages` | `Record` | Per-message copy overrides | | `currency` | `string` | `USD` fallback; event/org data wins | | `colorblindSafe` | `boolean` | `false` | | `initialView` | `RendererViewMode` | Only `flat`. The older `isometric` and `perspective` values still work and show the flat map | | `enable3D` | `boolean` | `true` when WebGL2 is available | | `max3DSeats` | `number` | `60,000` desktop; `30,000` on small/low-core devices; host overrides are accepted | | `onBuyerViewChange` | `({ view, seatId? }) => void` | Map/3D entry, exit, and targeted-seat changes | | `onAnalytics` | `(event, props) => void` | Optional host-owned Preview load/3D event sink; separate from automatic anonymous load diagnostics | | `hideBadge` | `boolean` | Deprecated, no effect: the badge follows account white label, included with any credit pack | | `hideEventDetails` | `boolean` | Hide duplicate host/event identity while inline; fullscreen restores context | | `theme` | `SeatPickerTheme` | Partial host overrides | | `pricing` | `SeatPickerPricing` | Buyer display overrides; not server ledger authority | | `holdTtlMs` | `number` | Fallback hold duration; an event-dashboard setting takes priority | | `initialHoldId` | `string` | Verify/restore an existing event hold | | `restoreHold` | `boolean` | `true`; uses per-event session storage | | `readOnly` | `boolean` | Render live inventory without selection, holds, or checkout | | `confirmSelection` | `boolean` | `true` | | `seatView` | `boolean` | `true` | | `checkout` | `"handoff" \| "hosted"` | `handoff`; hosted is an explicit managed-commerce opt-in | | `returnUrl` | `string` | Validated hosted-checkout return location | ## Callbacks | Callback | Signature | Meaning | |---|---|---| | `onCheckout` | `(hold, seats, handoff) => void` | Hold succeeded and buyer chose checkout | | `onCheckoutUnavailable` | `({ reason, handoff }) => void` | Hosted checkout cannot run; the hold remains available for host handoff | | `onOrderConfirmed` | `(order) => void` | Hosted payment was confirmed | | `onBooked` | `(handoff) => void` | Realtime state confirms this handed-off hold was booked | | `onOfferAvailabilityChange` | `(availability) => void` | Active ticket-release/offer display state changed | | `onSelectionChange` | `(seats) => void` | Selection changes | | `onSelectionValidityChange` | `(state) => void` | Exact-count state after each selection change | | `onSelectionValid` / `onSelectionInvalid` | callback | Exact-count validity transition | | `onSelectionLimit` | `(max) => void` | A selection attempt reached the active cap | | `onHoldChange` | `(hold, seats, handoff) => void` | Hold created/restored/extended/partially released/released | | `onHoldExpired` | `() => void` | Current hold expired and widget reset | | `onHoldRestored` | `(hold, seats, handoff) => void` | Prior hold verified and restored | | `onClose` | `() => void` | Modal closes | | `onAccessExpired` | `(state) => void` | Buyer access session expired; state says whether refresh recovered it | | `onAccessUnavailable` | `(state) => void` | Scoped private inventory is no longer available | | `onSelectedObjectUnavailable` | `(state) => void` | A selected, unheld object left this buyer's projection | | `onError` | `(error) => void` | Render/transport operation fails | Use the third `onCheckout` argument for new integrations: ```ts interface CheckoutHandoff { holdId: string; expiresAt: number; currency: string; lineItems: CheckoutLineItem[]; total: number; } interface CheckoutLineItem { label: string; displayLabel?: string; displayType?: string; objectId: string; objectType: "seat" | "booth" | "ga" | "table"; categoryKey: string; tierId: string | null; unitPrice: number; currency: string; quantity: number; } ``` The browser handoff is excellent display context, but payment still uses a fresh server hold inspection. ### Scoped channel prices need no browser configuration When `buyerAccessTokenProvider` returns a session scoped to a priced sales channel, `SeatPicker` receives only that session's compact price projection and uses it for seats, tables, booths, GA quantities, ticket tiers, totals, and the checkout handoff. You do not pass a channel id or duplicate the channel price book in browser options. The hold endpoint resolves the same channel price independently and freezes it with `channelPricingVersion`. Continue to treat the inspected hold as the checkout authority. `setPricing()` remains a host-side presentation override; it cannot replace or weaken the server's channel price. ## Public methods | Method | Result | |---|---| | `render()` | `Promise` | | `close()` | Close modal or destroy inline picker | | `getSelection()` | Current `PickerSeat[]` | | `selectObjects(objects)` / `deselectObjects(objects)` | Select/deselect by engine id or public label | | `clearSelection()` | Clear every unheld selection | | `selectCategories(keys)` / `deselectCategories(keys)` | Change every selectable object in named categories | | `setSelectableObjects(objects)` | Replace the buyer allow-list without remounting | | `setMaxSelection(max)` | Change the active order cap without remounting | | `getSelectionValidity()` | Rule state, including typed `violations`, held lines, and pending GA tickets | | `setMapTheme(map)` | Repaint the canvas map without remounting or losing selection | | `setEventDetailsHidden(hidden)` | Hide/show duplicate host event identity in place | | `setPricing(pricing)` | Repaint buyer display prices without changing server-authoritative hold prices | | `isColorblindSafe()` | Current accessible palette state | | `setColorblindSafe(on)` | Change accessible palette state | | `setViewMode(mode)` / `getViewMode()` | Accept only `flat`; older values show the flat map. For 3D use `setBuyerView('venue3d')` | | `getBuyerView()` | `"map" \| "venue3d"` | | `setBuyerView(view, { flyToSeatId?, resetView? })` | Switch buyer surface, run a targeted 3D tour, or reset the venue overview | | `getCurrentHold()` | `HoldResult \| null` | | `resumeHold(holdId)` | Verify and restore a hold | | `removeHeldTicket(label)` | Remove one held item and keep the rest | | `bestAvailable(qty, categoryKey?, options?)` | Hold a server-selected group | | `release()` | Release current hold and reset | | `refreshAccess()` | Ask the configured provider for a fresh buyer-access session | | `destroy()` | Remove DOM, socket, listeners, and timers | For a fixed-size purchase flow, set `numberOfPlacesToSelect`. For guided buyer selection, add one or more local rules: ```js selectionValidators: [ { type: "minimumSelectedPlaces", minimum: 2 }, { type: "consecutiveSeats" }, { type: "noOrphanSeats" }, ] ``` `consecutiveSeats` requires one uninterrupted run in the same row and ticket category. `noOrphanSeats` prevents a choice from newly stranding one free seat between unavailable neighbours. The built-in CTA stays disabled and shows localized guidance until every rule is satisfied. `onSelectionValidityChange` exposes `{ isValid, count, required, remaining, seats, violations }` for custom host chrome. The full picker counts seated places, grouped-table guest quantities, held lines, and pending GA quantities together. ## Modal mode ```js const picker = await SeatPicker.open({ event: "", onCheckout: (_hold, _seats, handoff) => { startCheckout({ holdId: handoff.holdId }); }, onClose: () => restoreHostUI(), }); picker.close(); ``` The modal traps focus, closes through Escape/scrim/close button, restores scroll and prior focus, and fires `onClose`. ## Holds and restore The picker persists the current hold id in `sessionStorage` by default. On a return visit it verifies that hold against the same event before showing it as owned. Set `restoreHold: false` when your router/cart owns persistence, then supply `initialHoldId` or call `resumeHold()`. Clear host cart state when `onHoldChange(null, …)` fires. The built-in countdown offers an extension in the final minute and resets on confirmed extension. Server limits and renewal caps remain authoritative. ## Buyer 3D and analytics {/* MAINTAINER RELEASE GATE: publish operationalTelemetry documentation only with the matching SDK runtime. */} `enable3D` controls whether the Map/3D affordance is offered. The 3D module is lazy-loaded only after use and falls back to the complete 2D experience when WebGL2 is unavailable. Use `setBuyerView("venue3d", { flyToSeatId })` for a guided tour and `onAnalytics` to forward Preview journey events to your own system. With the built-in transport and `apiBase: "https://api.seatlayer.io"`, the picker separately sends one anonymous operational load timing after a successful render. Custom transports, other API origins, and localhost stay silent unless `operationalTelemetry: true`; `false` always opts out. The request goes only to the configured `apiBase`, omits credentials and referrer, and never includes an access token. It is not routed through `onAnalytics`. See [3D buyer view](/buyer-sdk/3d-view) and [buyer analytics](/buyer-sdk/analytics). ## Responsive behavior Layout responds to the picker container, not only the viewport: - wide containers show a side panel and docked details; - narrow containers use a bottom sheet and touch-first map controls; and - fullscreen uses the native API or a platform fallback. Give the container a definite height. Test the actual embed width, mobile browser chrome, keyboard, reduced motion, and colorblind-safe mode. ## Lifecycle checklist - [ ] Construct once per mounted host view. - [ ] Await `render()` and expose failures through `onError`. - [ ] Send only `holdId` to the booking server boundary. - [ ] Clear host state when the hold disappears. - [ ] Use stable order ids for booking retries. - [ ] Call `destroy()` during application unmount. - [ ] Test inline/modal, narrow/wide, 2D/no-WebGL, expiry, and conflict paths. Continue with [holds and checkout](/buyer-sdk/holds-and-checkout), [customization](/customization/buyer-experience), or the [SeatingChart reference](/buyer-sdk/seating-chart). # SeatingChart reference > Headless map options, callbacks, methods, live holds, GA, groups, floors, and error behavior. Source: https://docs.seatlayer.io/buyer-sdk/seating-chart/ · Markdown: https://docs.seatlayer.io/buyer-sdk/seating-chart/index.md `SeatingChart` mounts the live map canvas without SeatLayer's cart/tray chrome. Your application owns totals, tier/GA controls, the checkout button, countdown, and buyer messages. The [reserved seating SDK and API overview](https://seatlayer.io/developers/) shows which surface to choose before you commit to the headless route. ```js title="browser/chart.js" import { SeatingChart } from "@seatlayer/js"; const chart = new SeatingChart({ container: "#chart", event: "", publicKey: "", onSelectionChange: renderCart, onError: reportPickerError, }); await chart.render(); ``` For a public Platform event, `publicKey` creates one direct, origin-bound, Public-sale-only bootstrap; chart geometry and compact inventory status arrive together, and the resulting bearer remains in memory. For login, presale, partner, or channel inventory, supply `buyerAccessTokenProvider` or `buyerAccessToken` from your authenticated backend instead. Either explicit buyer credential takes precedence over `publicKey`. ## Constructor options and callbacks | Option | Type | Notes | |---|---|---| | `container` | `string \| HTMLElement` | Required, definite dimensions | | `event` | `string` | Required event key | | `apiBase` | `string` | Defaults to production API | | `publicKey` | `string` | Publishable key for direct, exact-origin, Public-sale-only Platform bootstrap | | `buyerAccessTokenProvider` | `({ reason }) => Promise<{ token, expiresAt }>` | Refreshable private/login/presale/channel access from your authenticated backend; takes precedence over `publicKey` | | `buyerAccessToken` | `string \| { token, expiresAt }` | One-shot escape hatch; prefer the refreshable provider | | `maxSelection` | `number` | Default `10` | | `selectedObjects` | `string[]` | Initial object ids or public labels; applied after live availability loads | | `selectableObjects` | `string[] \| null` | Allow-list of object ids or public labels; omit/null for every free object | | `numberOfPlacesToSelect` | `number` | Require an exact seated/table guest count and cap selection at that count | | `selectionValidators` | `PickerSelectionValidator[]` | Minimum-count, consecutive-seat, and no-orphan local guards | | `locale` / `messages` | `string` / overrides | Localized on-map copy | | `currency` | `string` | Display fallback | | `colorblindSafe` | `boolean` | Accessible palette and hollow booked state | | `initialView` | `RendererViewMode` | Only `flat`. The older `isometric` and `perspective` values still work and show the flat map | | `seatTooltip` | `boolean` | Built-in hover tooltip, default `true` | | `onSeatHover` | `(details \| null) => void` | Build a custom hover surface | | `onSelectionChange` | `(seats) => void` | Manual, group, or tier change | | `onSelectionValidityChange` | `(state) => void` | Rule state and typed `violations` after each selection change | | `onSelectionValid` / `onSelectionInvalid` | callback | Rule-validity transition | | `onSelectionLimit` | `(max) => void` | A selection attempt reached the active cap | | `onHint` | `(message \| null) => void` | Non-blocking localized advice | | `onHold` | `(hold) => void` | Any successful hold | | `onHoldRestored` | `(hold) => void` | `resumeHold()` succeeds | | `onHoldExpired` | `() => void` | Server expiry reached | | `onGAClick` | `(area) => void` | Host should request quantity and call `holdGA()` | | `onDeckTap` | `(floorId) => void` | Synchronize custom floor controls | | `onError` | `(error) => void` | Public async methods report failures here | ## Selection and hold methods | Method | Signature/behavior | |---|---| | `getMode()` | `"live" \| "test" \| null`; null before render | | `getSelection()` | Current `SelectedSeat[]` | | `selectObjects(objects)` / `deselectObjects(objects)` | Select/deselect by engine id or public label | | `clearSelection()` | Clear every unheld selection | | `selectCategories(keys)` / `deselectCategories(keys)` | Change every selectable object in named categories | | `setSelectableObjects(objects)` | Replace the allow-list without remounting; `null` restores all free objects | | `setMaxSelection(max)` | Change the active selection cap without remounting | | `getSelectionValidity()` | Exact-count state, or `null` when exact selection is disabled | | `hold({ ttlMs? })` | Hold current selection; `null` on failure/conflict | | `resumeHold(holdId)` | Restore without extending | | `extendHold(ttlMs?)` | Extend active hold within server limits | | `getCurrentHold()` | Current `HoldResult \| null` | | `getGAAreas()` | Live GA capacity records | | `holdGA(areaId, qty, { tierId?, ttlMs? })` | Quantity hold | | `bestAvailable(qty, categoryKey?, options?)` | Adjacent group hold | | `setSeatTier(seatId, tierId)` | Change selected tier; null resets | | `release()` | Release the full hold | | `releaseLabels(labels)` | Partially release listed labels | `hold()`, `resumeHold()`, `holdGA()`, and `bestAvailable()` catch public transport errors, call `onError`, and resolve `null`. Do not wait for an exception to render buyer recovery. ## Controlled and validated selection ```js const chart = new SeatingChart({ container: "#chart", event: "", publicKey: "", selectedObjects: ["A-10"], selectableObjects: ["A-10", "A-11", "A-12"], selectionValidators: [ { type: "minimumSelectedPlaces", minimum: 2 }, { type: "consecutiveSeats" }, { type: "noOrphanSeats" }, ], onSelectionValidityChange: ({ isValid, remaining, violations }) => { checkoutButton.disabled = !isValid; selectionHint.textContent = isValid ? "Ready" : violations.join(", "); }, }); await chart.render(); chart.selectObjects(["A-11"]); ``` References accept the stable engine id returned on a selected object or the public inventory label used by hold/book APIs. Unavailable objects are ignored. Grouped-table references always select or deselect the whole booking unit. `hold()` returns `null` and calls `onError` before transport while a rule is invalid. Existing exact-count failures keep `code: "selection_count_mismatch"`; validator failures use `code: "selection_invalid"` and include the full selection state. ## View and lifecycle methods | Method | Behavior | |---|---| | `render()` | Fetch, mount, seed live state; idempotent after success | | `getFloors()` / `setFloor(id)` | Custom multi-floor navigation | | `setColorblindSafe(on)` | Runtime accessible palette | | `setViewMode(mode)` / `getViewMode()` | Accept only `flat`; older values show the flat map | | `zoomIn()` / `zoomOut()` / `zoomToFit()` | Host map controls | | `destroy()` | Socket/listener/timer/DOM cleanup | The full 3D buyer experience belongs to `SeatPicker`; `SeatingChart.initialView` does not enable that venue view. ## Core result types ```ts interface HoldResult { holdId: string; expiresAt: number; seats?: SelectedSeat[]; items?: HoldLineItem[]; } interface BestAvailableResult extends HoldResult { labels: string[]; zoneId?: string; } interface GAAreaAvailability { id: string; label: string; capacity: number; available: number; categoryKey: string; price: number; currency: string; displayLabel?: string; displayType?: string; tiers?: Array<{ id: string; name: string; price: number }>; } ``` `HoldLineItem.unitPrice`, currency, tier, and quantity come from SeatLayer, but your server still re-inspects the hold before payment. ## Build the custom cart safely 1. **Render selection** Derive immediate UI from `onSelectionChange`; keep stable ids separate from buyer labels. 2. **Create the hold** Call the appropriate hold method when the buyer continues. Disable duplicate submissions while it runs. 3. **Start a visible timer** Count down from server `expiresAt`, not a locally invented duration. 4. **Hand off only holdId** Your backend inspects authoritative items, runs payment/order logic, and books. 5. **Recover intentionally** Clear stale selection/cart on expiry, show adjacency-specific errors, and reuse `bookingRef` for uncertain server retries. ## Integration checklist - [ ] The host container has a stable width and height. - [ ] Every callback updates host state idempotently. - [ ] `null` async results have explicit buyer copy. - [ ] GA, tier, best-available, and partial release paths are tested if used. - [ ] Hold time is based on `expiresAt`. - [ ] Server checkout ignores browser prices. - [ ] `destroy()` runs during unmount/navigation. - [ ] Keyboard, touch, zoom, and colorblind-safe behavior pass. Continue with [SeatPicker](/buyer-sdk/seat-picker), [groups and GA](/integrations/groups-and-ga), or [best available](/buyer-sdk/best-available). # Ticket tiers > Carry Adult, Child, Senior, and other per-seat price choices from the chart into a trusted checkout. Source: https://docs.seatlayer.io/buyer-sdk/ticket-tiers/ · Markdown: https://docs.seatlayer.io/buyer-sdk/ticket-tiers/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. A category can offer multiple ticket tiers for the same inventory, for example Adult, Child, and Senior. The buyer chooses a seat and a tier; SeatLayer freezes that combination into the hold. > **SeatPicker includes the tier UI** > > `SeatPicker` renders tier controls in its tray and includes the selected tier > in `CheckoutHandoff.lineItems`. Use the methods below with `SeatingChart` when > your application owns the cart UI. ## Tier model ```ts interface CategoryTier { id: string; name: string; price: number; } ``` Tiers are authored on a category. A category without tiers keeps its single base price. ## Read selected-seat tiers ```js title="browser/tier-controls.js" const chart = new seatlayer.SeatingChart({ container: "#chart", event: "", publicKey: "", onSelectionChange: (seats) => { for (const seat of seats) { if (!seat.tiers?.length) continue; renderTierSelect({ seatId: seat.id, options: seat.tiers, selected: seat.tierId, onChange: (tierId) => chart.setSeatTier(seat.id, tierId), }); } }, }); await chart.render(); ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. The selected seat's `price` updates when `tierId` changes. Use that value for immediate buyer display, but use hold inspection on your server for payment. ## Change or reset a tier ```js chart.setSeatTier("seat-42", "child"); // Revert to the category's default/first tier. chart.setSeatTier("seat-42", null); ``` An invalid tier/seat combination leaves selection unchanged. ## Carry the tier through checkout 1. **Buyer chooses** Selection state carries the tier id and buyer-facing price. 2. **SDK holds** SeatLayer validates the tier against the selected category and freezes the line item. 3. **Browser hands off** Send the opaque `holdId` to your server. Do not send tier or price as the authority. 4. **Server inspects** `GET /v1/events/:eventKey/holds/:holdId` returns `tierId`, `unitPrice`, `currency`, and `quantity` for each authoritative item. 5. **Server books** Charge from inspected items and book using `holdId` and `bookingRef`. ```js title="server/tier-total.js" const hold = await inspectSeatLayerHold(eventKey, holdId); const total = hold.items.reduce( (sum, item) => sum + item.unitPrice * (item.quantity ?? 1), 0, ); await createPayment({ currency: hold.items[0]?.currency, amountMajor: total, lines: hold.items.map((item) => ({ label: item.label, tierId: item.tierId, unitPrice: item.unitPrice, quantity: item.quantity ?? 1, })), }); ``` If a buyer changes a tier after a hold exists, create the updated hold before continuing checkout so authoritative items match the choice. Clear any stale server cart data when the active hold changes. ## UX and policy guidance - Use buyer-facing tier names, but store stable tier ids on order lines. - Explain eligibility requirements for discounted tiers in your own checkout. - Do not infer age or accessibility eligibility from the seat itself. - Keep tier selection keyboard accessible and associate it with the seat label. - Recalculate displayed totals immediately, then verify against server inspection. - If a tier disappears after a chart/event update, return the buyer to a clear choice instead of silently substituting another tier. ## Verification checklist - [ ] Every tier has a stable id, readable name, and intended price. - [ ] Default tier behavior is explicit. - [ ] Selection updates when `setSeatTier()` succeeds. - [ ] The browser forwards only `holdId`. - [ ] Server totals use `unitPrice`, currency, and quantity from inspection. - [ ] Tier changes replace stale hold/cart state. - [ ] Eligibility and accessibility copy belongs to the host checkout. Continue with [holds and checkout](/buyer-sdk/holds-and-checkout) or [complete checkout](/examples/complete-checkout). # Vue seating chart > Install @seatlayer/vue, render an interactive Vue 3 seating chart with live availability and seat selection, create holds, and hand booking to a trusted server. Source: https://docs.seatlayer.io/buyer-sdk/vue/ · Markdown: https://docs.seatlayer.io/buyer-sdk/vue/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. The official SeatLayer Vue seating chart component adds an interactive seat map and seat picker to reserved-seating apps built with Vue 3. The [`@seatlayer/vue` package](https://www.npmjs.com/package/@seatlayer/vue) renders a real DOM component, not an iframe, with typed props, emits, and an imperative handle for live availability, seat selection, temporary holds, and best-available seating. TypeScript definitions ship in the package. [Inspect the Vue source](https://github.com/seatlayer/seatlayer-sdk), learn about the [SeatLayer reserved-seating platform](https://seatlayer.io/), or try the [browser seat-map demo](https://app.seatlayer.io/demo/play/grand-theatre). > **The app selects and holds; your server books** > > Never put a SeatLayer secret key in browser code. Send the opaque hold id to > your trusted backend, inspect the hold there, calculate the charge from > server data, and book with a stable `bookingRef`. ## Install the package ```bash npm install @seatlayer/vue ``` Vue 3 is supported, with ESM and CommonJS builds and bundled TypeScript types. ## Render a seating chart ```vue ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. The component owns its container and live updates; identity props such as the event key rebuild the chart cleanly, and calling a handle method before the chart exists returns an empty answer rather than throwing. ## Select, hold, and hand off checkout Keep the security boundary explicit: - The browser selects seats and creates a temporary hold. - Your backend inspects the hold and derives the amount to charge. - Your payment and order workflow stays on your server. - Your backend books with a stable `bookingRef` so retries are safe. Continue with [holds and checkout](/buyer-sdk/holds-and-checkout) before connecting a production order flow. ## Frequently asked questions ### Is this a real Vue component or an iframe? `SeatingChart` renders a plain `div` in your DOM and drives the buyer renderer directly, with no iframe. Props, emits, and the imperative handle are all typed. ### How do temporary seat holds work? Selecting seats creates a temporary hold that reserves inventory against concurrent buyers for a limited window. The hold expires automatically if checkout does not complete. The hold-expired emit tells the app to return the buyer to the map, and holds can be extended or resumed for longer checkouts. ### Can I use my own payment provider? Yes. SeatLayer never processes payment inside the seat map. The browser hands the hold id to your backend, and your backend charges through any payment provider you already use before booking the hold. ### Is there a React or Angular version? Yes. [`@seatlayer/react`](https://www.npmjs.com/package/@seatlayer/react) and [`@seatlayer/angular`](https://www.npmjs.com/package/@seatlayer/angular) share the same typed contract, and [`@seatlayer/js`](https://www.npmjs.com/package/@seatlayer/js) covers any framework or none. ## Next steps - [Install @seatlayer/vue on npm](https://www.npmjs.com/package/@seatlayer/vue) - [Run or inspect the SDK source](https://github.com/seatlayer/seatlayer-sdk) - [Understand the SeatLayer platform](https://seatlayer.io/) - [Connect holds to secure checkout](/buyer-sdk/holds-and-checkout) # Web SDKs > Choose the SeatLayer JavaScript, React, Vue, or Angular SDK and decide between the complete SeatPicker flow and a custom SeatingChart experience. Source: https://docs.seatlayer.io/buyer-sdk/web/ · Markdown: https://docs.seatlayer.io/buyer-sdk/web/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. The SeatLayer web SDK puts a live seat map for one of your events on a web page. The buyer picks seats on the map, SeatLayer holds them for a limited time, and your checkout takes over from there. The map shows real availability, updates while other buyers book, and works on desktop and phone browsers. A 3D view of the venue is offered where the browser supports WebGL2. All four packages share the same inventory, selection, hold, and checkout rules. `@seatlayer/js` is the core; the React, Vue, and Angular packages wrap it for their framework's lifecycle. The current release of all four is `0.106.1` (checked 4 October 2026). The [SDK catalog](/sdk-catalog.json) always lists the latest verified versions. To compare the web SDKs with native app SDKs, server SDKs, and REST, start at [Choose an SDK or API](/sdks/). ## Choose a package | Application | Package | What it provides | Guide | |---|---|---|---| | Plain HTML, JavaScript, or TypeScript | `@seatlayer/js` | `SeatPicker`, `SeatingChart`, `PerformanceGroupPicker`, `SeasonPicker`, `SeatManager` | [Install the Buyer SDK](/buyer-sdk/install/) | | React or Next.js | `@seatlayer/react` | `SeatPicker`, `SeatingChart`, and `SeasonPicker` components with refs | [React SDK](/buyer-sdk/react/) | | Vue | `@seatlayer/vue` | `SeatingChart` and `SeasonPicker` components | [Vue SDK](/buyer-sdk/vue/) | | Angular | `@seatlayer/angular` | `SeatLayerSeatingChartComponent` and `SeatLayerSeasonPickerComponent` | [Angular SDK](/buyer-sdk/angular/) | In Vue and Angular, use `@seatlayer/js` directly for the complete `SeatPicker` flow. Without a build step, load the hosted script from the CDN as shown on the [install page](/buyer-sdk/install/). ## Minimal example Give the map a container with a real height, then render the picker with your event key and your account's public key: ```bash npm install @seatlayer/js ``` ```html title="checkout.html"
``` ```js title="checkout.js" import { SeatPicker } from "@seatlayer/js"; const picker = new SeatPicker({ container: "#picker", event: "", publicKey: "", onCheckout: (_hold, _seats, handoff) => { // Send the opaque hold id to your server, which books the seats. beginCheckout(handoff.holdId); }, }); await picker.render(); ``` The public key only works from browser origins you registered for the account. For presale, member, or partner inventory, pass a buyer access token from your backend instead; see [install](/buyer-sdk/install/) for both options. > **Event key or public key?** > > Replace `` with your event's key. Find it at the top of the event's page in the dashboard, with a Copy button, or read `meta.key` from the `POST /v1/events` response. It starts with `ev_`. > > The two keys go in different places: > > - `event` takes the **event key** (`ev_…`). It says which event to show. > - `publicKey` takes your **public key** (`pk_test_…` or `pk_live_…`), from **Developer → API keys**. It says which account is asking. > > Both must come from the same account, and a `pk_test_` key only opens test events. A 404 on first load usually means one of the two is in the wrong place, the event key is still the placeholder, or the event sells through SeatLayer checkout rather than your own (those events use the hosted page, not the SDK). ## Choose a buyer surface - **Ready-made SeatPicker** — Map, seat confirmation, prices, cart, hold timer, and checkout handoff in one component. Start here. See the [SeatPicker reference](/buyer-sdk/seat-picker/). - **Composable SeatingChart** — The map alone. Your application draws the cart, selection controls, and hold workflow. See the [SeatingChart reference](/buyer-sdk/seating-chart/). To sell several dates as one package, use [Performance Groups](/buyer-sdk/performance-groups/) for a fixed run of dates or the [Season picker](/buyer-sdk/seasons/) for a renewable season. ## Keep secrets on the server Never put a secret key in browser code. The web SDK creates or resumes a temporary hold and hands its opaque identity to your backend. Your backend inspects the held items, calculates the charge, and books through a [server SDK](/server-sdk/) or the [REST API](/server-api/). ## Next steps - [Holds and checkout](/buyer-sdk/holds-and-checkout/) before you connect a production order flow - [Add a seat map to a React app](/buyer-sdk/react-seating-chart/), a step-by-step walkthrough - [3D buyer view](/buyer-sdk/3d-view/) for the venue view and view from a seat - [Native app SDKs](/buyer-sdk/mobile/) if you are building an iOS or Android app # WebMCP agent tools > Let an AI agent in the buyer's browser search, select, and optionally hold seats on the real map through the picker's off-by-default tools. Source: https://docs.seatlayer.io/buyer-sdk/webmcp-agent-tools/ · Markdown: https://docs.seatlayer.io/buyer-sdk/webmcp-agent-tools/index.md WebMCP is a draft browser API that lets a page hand callable tools to an AI agent running in the same browser (`document.modelContext`). With one option the SeatPicker registers a small set of tools, so an assistant asked for "two seats together under $90" drives the real map instead of guessing from pixels: the seats light up in the buyer's basket, availability and price come from the live event, and the buyer completes checkout exactly as before. > **Available since 0.71.5; current browser SDK 0.106.1** > > `webMcp` is available in the public SeatLayer browser SDK. Use the current > compatible release (`0.106.1`, checked 4 October 2026), and keep the option off for > events that should not expose browser-agent seat tools. ```ts title="webmcp.ts" const picker = new SeatPicker({ container: '#picker', event: eventKey, publicKey, webMcp: true, // search + select tools // webMcp: { holds: true } // also allow the agent to place a hold }); await picker.render(); ``` - **Off by default.** Nothing is registered unless `webMcp` is set. - **Feature-detected.** If the browser has no `document.modelContext` (WebViews, older browsers), the option is inert. No code path touches the picker. - **Torn down with the picker.** `destroy()` unregisters every tool. - **Checkout stays human.** There is no checkout or payment tool. - **Organizer text is marked untrusted.** Tool annotations tell compatible agents that event, category and seat labels need untrusted-content handling. ## Tools the picker registers | Tool | Reads or acts | What it returns | | --- | --- | --- | | `seatlayer_describe_event` | reads | event name and time, currency, sales state, ticket types with price and live availability, zones, selection limits, whether holds are allowed | | `seatlayer_find_seats` | reads | up to `limit` candidate groups of `count` seats, adjacent by default, within `max_price`, optionally filtered by ticket type or zone. **Never selects or holds.** | | `seatlayer_select_seats` | acts | puts the given seats in the basket; the buyer sees them on the map; respects `maxSelection` and your `selectionValidators` | | `seatlayer_get_selection` | reads | current seats, count, total, currency and the active hold if any | | `seatlayer_hold_selection` | acts | only with `webMcp: { holds: true }`; the same action as the buyer tapping **Select seats**; `onCheckout` fires as usual | Every tool callback returns a plain object; the browser's `executeTool()` API serializes it to JSON text. Failures come back as `{ ok: false, reason, message }` (`bad_count`, `no_match`, `not_enough_together`, `sold_out`, `event_closed`, `nothing_selected`, `not_allowed`) so callers receive a typed failure result. ## Holds: who decides By default an agent can search and select, and the buyer taps to hold. Set `webMcp: { holds: true }` when you want the agent to place the timed hold itself. The hold uses the ordinary picker TTL, `onHoldChange` and `onCheckout` callbacks, and release rules. Nothing downstream changes. > **Inventory is yours** > > A hold locks seats for other buyers. Keep holds tap-only unless your > organisers want a fully agentic flow on that event. ## Iframe and hosted embeds Tools are exposed to their own document by default. If an in-page agent in a cross-origin host needs the picker's tools, both sides opt in. The picker names the host's exact, trustworthy origin: ```ts title="picker-frame.ts" const picker = new SeatPicker({ container: '#picker', event: eventKey, publicKey, webMcp: { exposedTo: ['https://tickets.desipass.com'], }, }); ``` The host grants the frame the `tools` permission: ```html ``` An in-page agent in the host requests tools from the frame origin: ```js const tools = await document.modelContext.getTools({ fromOrigins: ['https://picker.example.com'], }); ``` Do not build `exposedTo` from a query string or request header. Same-origin agents do not need it, and browser-provided agents use their browser's own tool discovery path. ## Integrator pattern: DesiPass agent-assisted booking Keep seat work and commerce as two explicit trust boundaries: 1. SeatLayer's browser tools describe the event, find seats and select them. 2. With `holds: true`, the agent may place the same temporary hold as the buyer's **Select seats** action, after confirming the choice with the buyer. 3. `onCheckout` hands the opaque hold ID to DesiPass. The DesiPass server inspects that hold, calculates the authoritative total, takes payment and books once with an idempotent booking reference. ```ts title="desipass-event-page.ts" const picker = new SeatPicker({ container: '#picker', event: eventKey, publicKey, webMcp: { holds: true }, onCheckout: async (_hold, _seats, handoff) => { // Send identity, not browser-posted prices, tiers or seat labels. const response = await fetch('/api/seat-checkout', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ holdId: handoff.holdId }), }); const checkout = await response.json(); openDesiPassCheckout(checkout.id); }, }); ``` On the server, follow the ordinary [hold inspection and checkout flow](/buyer-sdk/holds-and-checkout/): authenticate the buyer, inspect the hold with a server credential, calculate from the returned items, and handle expiry or a `409` booking conflict. An agent-facing checkout tool must accept only a server-issued checkout or confirmation ID, never price, currency, seat IDs or payment-card data, and must keep the buyer's confirmation UI in the loop. A remote DesiPass MCP client, such as a WhatsApp assistant, cannot call tools that exist only inside a browser page. For a seated event it should return the event deep link and a structured `booking_mode: "browser_handoff"`. Once the buyer opens that page, the browser agent can use the SeatLayer tools; DesiPass then resumes its normal checkout and booking-status flow. A host can expose `desipass_open_checkout`, which opens a server-created checkout session and reports `requires_buyer_confirmation: true` rather than attempting to book from chat-supplied seat or price data. ## Walkthrough: prove the browser renderer and agent handoff together This is a local browser proof, not a replacement checkout. Mount one current `SeatPicker`, opt into its browser-local tools, ask the browser for the tools, and confirm that an agent-selected seat appears in the visible picker before a buyer chooses whether to hold it. The exact tool calls below are available only in a compatible browser with WebMCP enabled. ```ts title="agent-proof.ts" import { SeatPicker } from '@seatlayer/js'; async function beginCheckout({ holdId }: { holdId: string }) { await fetch('/api/seat-checkout', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ holdId }), }); } const picker = new SeatPicker({ container: '#picker', event: '', publicKey: '', webMcp: true, onCheckout: (_hold, _seats, handoff) => { // Your trusted server receives only this opaque identity. beginCheckout({ holdId: handoff.holdId }); }, }); await picker.render(); const tools = await document.modelContext.getTools(); const find = tools.find(({ name }) => name === 'seatlayer_find_seats'); const select = tools.find(({ name }) => name === 'seatlayer_select_seats'); if (!find || !select) throw new Error('WebMCP is unavailable in this browser.'); const candidates = JSON.parse(await document.modelContext.executeTool(find, { count: 2, max_price: 90, })); if (candidates.ok && candidates.groups[0]) { await document.modelContext.executeTool(select, { seat_ids: candidates.groups[0].seatIds, }); } ``` `seatlayer_find_seats` only returns candidates. `seatlayer_select_seats` changes the same visible renderer that the buyer controls, and it still applies the Event's selection validators. Use `webMcp: { holds: true }` only after this proof when the event owner wants the optional normal hold handoff. The [holds and checkout guide](/buyer-sdk/holds-and-checkout/) remains the server implementation for trusted price, payment, and booking. ## Trying it WebMCP ships behind a flag in current Chrome (`chrome://flags/#enable-webmcp-testing`). With it on, open your page and in DevTools: ```js const tools = await document.modelContext.getTools(); tools.map(t => t.name); // ['seatlayer_describe_event', 'seatlayer_find_seats', …] const find = tools.find(t => t.name === 'seatlayer_find_seats'); JSON.parse(await document.modelContext.executeTool(find, { count: 2, max_price: 90 })); ``` ## Related - [Best available seats](/buyer-sdk/best-available/): the same search from your own UI, with a hold. - [Holds and checkout](/buyer-sdk/holds-and-checkout/): what happens after `seatlayer_hold_selection`. - [Agents](/agents/overview/): the public knowledge MCP and the Designer MCP. # Changelog > Track SeatLayer API, Buyer SDK, Designer, webhook, and platform releases, behavior changes, fixes, and migration notes. Source: https://docs.seatlayer.io/changelog/ · Markdown: https://docs.seatlayer.io/changelog/index.md Notable changes to the SeatLayer API, embed SDK, and webhooks, newest first. ## 2026-10-08 - **Server API: inventory calls answer `503`, not `500`, when the event is briefly unavailable.** Hold, extend, release, book, and the best-available calls now answer `503` with `retryable: true`, `Retry-After: 1`, and `code: event_temporarily_unavailable` when the event's inventory restarts or does not answer in time. Release, book, and the hold check inside book retry once on their own first. A hold, block, or extend that answered this `503` may still have been made, so read the hold or the seats before repeating it. See [which errors to retry](/server-api/errors/#which-errors-to-retry). ## 2026-10-04 - **Browser SDK 0.106.1 (`@seatlayer/js`, React, Vue and Angular).** Upgrade from 0.106.0; nothing to change in your code. - **Fixed:** the Control room shows the drag-a-box hint only when the room has a tool that takes seats by box. A watch-only room, such as a season seat map, no longer shows it. - **Fixed:** pages built from npm (React, Vue, Angular, `@seatlayer/js`) now report their SDK version with each chart load, as CDN pages already did. - **Added:** `seatDisplayParts` in `@seatlayer/core/core/sections`. Seat labels are unchanged. ## 2026-10-02 - **Browser SDK 0.106.0 (`@seatlayer/js`, React, Vue and Angular): the full Control room, in Vue and Angular too.** Upgrade from 0.105.1; every new option is additive. - **Vue and Angular:** `SeatManager` is now on `@seatlayer/vue/manager` and `@seatlayer/angular/manager`, with the same board and options as React and `@seatlayer/js/manager`. - **`onOpenDesigner`** (Angular: the `(openDesigner)` output). When it is set, Sections on a chart that has no sections offers "Open the designer". Leave it out and the empty state keeps its words only. - **Venue view captions:** a sold-out section turns solid grey and reads SOLD OUT. A section that is all blocked or all reserved is hatched and reads BLOCKED or RESERVED. A closed one reads CLOSED, with its opening time when it has one. - **Seat history** shows how long a seat sat in a cart. Saved selections show the date the chart changed, when the API sends it. - **Changed:** the Rows and Ticket types pickers add free seats only. A table with chairs in a cart is no longer locked; "All …" leaves it out and says so. A reservation that ended on its own stays in the Reserve list, greyed, until the room reloads. - **Fixed:** a free ticket type reads "Free" in the buyer's price list and phone price rail, not "$0". Channels counted sold seats as free. The sold-out alert fired on blocked sections. Seat history closed on live changes. The seat card asked for a seat's history several times while the room opened. ## 2026-10-01 - **React, Vue and Angular 0.105.1: the React `SeatManager` passes the new options through.** In 0.105.0 the React component accepted the Control room options listed below but did not hand them to the room, so they had no effect. 0.105.1 passes every one of them on. `@seatlayer/js` 0.105.1 is the same as 0.105.0; upgrade the React package to use the options from React. - **Browser SDK 0.105.0: the embedded Control room.** `SeatManager` gains optional options, and every one of them is additive: leave them out and the room behaves as it did on 0.104.1. - **Showing the room:** `kpis: false` hides the room's own number strip when your page shows the numbers itself. `timeZone` shows times in the event's zone. `mapInsets` keeps the map clear of your own chrome. - **Opening at a place:** `focusSection`, `focusSeat`, `focusSeats` and `focusCategory` open the room framed on seats. - **Telling the room about the sale:** `sale` (the host's words, tone, when sales opened and the buyers' page) and `organizationName`. - **Following the room:** `onRoomStateChange` reports the room's link state, such as paused or offline, so your header can say it too. `onOpenOrder` lets the room's search open a booking in your own orders page. See [Embedded Control room](/platform/embedded-control-room/). - **Browser SDK 0.105.0: the buyer picker.** On a phone, the fitted venue keeps clear of the level stack. A ticket type whose name is too long to sit beside the quantity stepper moves to its own row instead of being cut. The picker's chrome follows the buyer's language once it loads. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.105.0 pin this release. - **Doors time on events.** Events gain an optional `doorsAt` (epoch ms, read in the event's `timezone`). Set it with `PATCH /v1/events/{key}`, or send `null` to clear it. A doors time after the event's start is refused with `422 doors_after_start`. Events without one return `doorsAt: null`. ## 2026-09-30 - **API: the event log names its seats.** `GET /v1/events/:key/log` pages now carry `displayLabels`, the name each label reads as where that differs from the label, such as a general admission area's places. `labels` are unchanged. See [Audit log](/server-api/reports/#audit-log). - **Webhooks: `seat.released` says when single tickets were refunded.** When some tickets of a SeatLayer order are refunded or cancelled from the dashboard, the `seat.released` for exactly those seats carries `source: "ticket_refund"` and the order's `bookingRef`, so you can match the release to the order; the rest of the order stays booked. Every other release sends the same payload as before. See [seat.released](/webhooks/events/#seatreleased). ## 2026-09-29 - **Browser SDK 0.104.1: the buyer's language on `@0` embeds.** A page that loads the SDK from the major channel (`seatlayer-js@0/…`) showed every buyer the English map, because the language file was requested under the channel path, where it does not exist. The SDK now loads its language file, the 3D view, the seat view and the checkout card from its own release directory, so they always match the running bundle and no longer take an extra redirect. If that request fails (for example a Content Security Policy that allows only the `@0/` path), it falls back to the old address, as before. Pinned, self-hosted and npm installs behave as before. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.104.1 pin this release. ## 2026-09-28 - **Browser SDK 0.104.0: level buttons, taps that keep the seat in view, updates behind proxies.** A multi-level chart now shows one stack of level buttons above the zoom buttons instead of the "Levels:" menu, and a wide map keeps room for it on the right. "All levels" stays a vertical stack unless the chart is set to "As drawn" in the Designer. The mini-map steps aside when the whole venue is in view. On a phone, the first tap on a table chair keeps that chair on screen while the map zooms in. When a buyer's network blocks the live connection (some proxies, VPNs and antivirus tools do), the map checks for seat changes every 15 seconds while the page is visible, and goes back to live updates as soon as the connection works. A section name size set in the Designer is now used in the whole-venue view. Removed: the retired isometric and 2.5D projections. `setViewMode()` and `initialView` still accept those values and show the flat map, and `@seatlayer/core` no longer exports `createPerspectiveCamera` and its projection helpers. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.104.0 pin this release. - **Browser SDK 0.103.0: venue signs and names on the buyer map, sessions that renew.** The buyer map now draws what the Designer places for wayfinding: venue icons, gate signs, map badges and the step-free route. Named areas and landmarks such as a dance floor, a bar or an entrance keep a readable name when the whole venue is in view, and drop it only when the shape is too small to hold it. `SeasonPicker` and `PerformanceGroupPicker` renew their 30-minute buyer session before it ends, and a session that has expired is asked for once instead of on every refresh. The idle card now reads "Your session expired" with a "Show latest seats" button, in all 43 languages. A performance group's seat list is revalidated with its ETag. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.103.0 pin this release. ## 2026-09-27 - **Browser SDK 0.102.0: aisle seats, standing areas sold as one box, booth details.** Aisle seats are marked on the seat card and the hover card, and best available takes an opt-in `preferAisle`. A standing area of up to 100 guests can be sold as one box: the buyer books the whole area or picks a party size, and chooses the ticket type. Standing areas can hold wheelchair places, and under the wheelchair filter an area with none free says "No wheelchair space". Buyer cards show a booth's type, the entrance and chair numbers. `SeasonPicker` takes a `header` option, and its map now fills its box. `PerformanceGroupPicker` supports hosted checkout. On a 404, `onError` now receives the API's explanation, and developers see it in a `console.warn`; the buyer's message is unchanged. A refused public bootstrap is asked once, not retried. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.102.0 pin this release. ## 2026-09-25 - **Browser SDK 0.101.0: 3D that moves like a venue.** At the overview a drag turns the venue, and the near side follows the pointer. Zoomed in, a drag moves you; right-drag, Shift-drag or a two-finger twist turns. The seat card opens on the view from that seat. After a pick, a figure sits in the chosen seat under a spotlight. Section tags carry the category colour and a free wheelchair-space count, and gates are signed with an arrow on the ground. When an app sends its own prices, the seat card shows them, the same as the price list. No breaking changes. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.101.0 pin this release, and the Flutter package `seatlayer` 0.12.0 loads it. - **Browser SDK 0.100.0: 3D on phones, seat types in 3D, arena floors.** On a phone, the first tap in 3D zooms to the seats and a second tap picks one. Wheelchair, companion, access, limited-view and premium seats show their icon in 3D, as in 2D. On desktop, hovering a seat in 3D shows the same seat card as the 2D map. Picking a seat no longer moves the camera, and aisles run straight through a section. In estimated arenas, floors stay level, straight banks stand as one tier, and floor seats face the stage. No breaking changes. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.100.0 pin this release, and the Flutter package `seatlayer` 0.11.0 loads it. ## 2026-09-24 - **Browser SDK 0.99.0: host prices in mobile apps, season prices for managers, steadier 3D.** Apps that show the map through the mobile bridge can pass their own prices with the `pricing` option or the `picker.setPricing` command, so the map, seat card and cart show the price the app charges. The server still prices every hold. `SeatManager` takes `categoryPrices` (and `setCategoryPrices()` on the handle) to show a different price on hover, or `null` to hide it. In 3D, the view keeps responding to drag, pinch and wheel while a seat card is open, and only a tap outside the card closes it. The Find count now sits inside the Find box. Stage captions take a direction (horizontal, vertical or automatic), and estimated theatre tiers sit at more realistic heights. No breaking changes: `FLOOR_STACK_MIN_SURFACE_PX` stays exported but is no longer used. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.99.0 pin this release. - **Seasons from your own server.** Secret-key REST routes now list season holders, refund, cancel, cancel one date, resend tickets, manage hosted renewal links and reminders, and read season reports and CSVs. New webhooks `season.order.paid` and `season.order.refunded` tell your system when a season sale is paid or refunded. See [Run the year from your server](/integrations/seasons/#8-run-the-year-from-your-server). ## 2026-09-23 - **Browser SDK 0.98.0: easier 3D navigation and indoor halls.** Wheel and pinch zoom toward the point under the pointer, tapping a stand frames the whole stand, and tapping a seat when zoomed in picks it without moving the camera. "View from here" lands behind the seat, the picked seat stays easy to spot, taken seats share one muted grey, and stadium overviews show seats. Drawn walls now form a room at any size, and a zone and section with the same name are labelled once. New for managers: `SeatManager` takes `unavailableObjects` and `unavailableObjectsReason` to show extra seats as unavailable, with `setUnavailableObjects()` on the handle. In Select mode a row-label tap now follows the same selection rules as a seat tap. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.98.0 pin this release. - **Season ticket sales are reported on the season.** Event totals, the Orders list and orders.csv no longer include season ticket orders; see the season's Reports tab. - **Browser SDK 0.97.0: 3D for every chart, clearer closed states, six more languages.** Charts drawn without 3D details now open in 3D with estimated tiers and stands, and five new playing surfaces (pitch, track, diamond, court, ring) draw on both the 2D map and in 3D. When an event exists but isn't selling, the map, card and button all say the same thing: "Sales haven't opened yet", "Sales are paused" or "Sales are closed". An unknown or removed event shows "This event isn't available" in place of the map, with no retry button. Buyer text now also ships in Vietnamese, Thai, Indonesian, Malay, Korean and Hindi. No breaking changes. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.97.0 pin this release. ## 2026-09-22 - **Hosted Ticketing: refund or cancel any order from one place.** Each order has one **Refund or cancel…** action. Refund in full or in part through the gateway the buyer paid with; a full refund puts the seats back on sale, a partial one leaves them sold. **Cancel without refund** covers money handled outside SeatLayer, such as a refund in the gateway dashboard, a lost dispute or cash returned at the counter; it releases the seats and voids the tickets. Refunds made in the Stripe or Razorpay dashboard sync back to the order. See [Refunds and cancellations](/sell/refunds-and-cancellations/). - **Box office: printing, order lookup and a staff role.** The counter prints tickets on an 80 mm roll or A4, chosen once per device. **Find order** searches by name, email, phone, order number or ticket code, and reprints and resends are logged on the order. A new **Box office** team role can sell, find orders, print, email, change seats and check people in, and the server refuses anything else, including refunds. See [Box office](/sell/box-office/) and [Staff roles](/sell/staff-roles/). - **Browser SDK 0.96.0: a clearer checkout card.** The hosted checkout card now lists every ticket with its section, row and seat, its category and price tier, and the order total in a footer that stays in view with one button to continue to payment. It asks for one email and an optional name, marks test mode in one line, and becomes a bottom sheet on phones, in light and dark. Checkout follows a payment redirect only when it is a secure `https` URL. New optional fields: `CheckoutLineItem.categoryLabel` and `tierLabel`, `CheckoutOrderSummary.lines`, and `CheckoutMount.event`. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.96.0 pin this release. - **Browser SDK 0.95.1: the whole venue fits the map.** The overview fit now measures section blocks, standing areas and shapes by their outline, so a chart whose blocks pad beyond their seats opens fully in frame instead of cutting the outer blocks at the edge; curved sections count only the arc they draw. With `chrome: 'minimal'`, `SeatManager` keeps the fitted venue clear of its zoom buttons when there is room. Row letters at the screen edge no longer cover a seat number. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.95.1 pin this release. - **Browser SDK 0.95.0: sold-out ticket types say so, a lost hold no longer ends the session, and the control room has one colour language.** For buyers, a ticket type with nothing left reads "Sold out" and is not offered for best seats (and a best-seats call for it answers `sold_out` without a request); a saved hold that can no longer be resumed after a reload shows a short notice and the picker carries on; and chart-load failures report why they failed. In the control room, a legend that stays on at every zoom carries a **Colour by: How full · Ticket type** switch that colours the section blocks, the seats and the minimap together (it opens on How full); the minimap follows sales as they happen; Full screen is a proper toolbar button; and a table sold as one unit shows all its chairs as sold. `SeatManager` gains `chrome: 'minimal'` (just the map, zoom and Venue), `onAreaClick` for standing areas in Select mode, `focusSection(label)` and `colourBy`. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.95.0 pin this release. ## 2026-09-21 - **A buyer who reloads mid-hold keeps their seats.** Minting a buyer access session again with the same `clientRequestId` now moves the earlier session's live holds to the new session. Before, the holds stayed with the revoked session and the new session could not resume them. **Behaviour change:** a live session that names a hold another session owns now gets the same answer as for a hold that does not exist, instead of `401 buyer_access_invalid`. That is `404 hold_not_found` on resume and checkout, `409 cannot_extend` on extend, and `409 conflict` on a replacement hold. Revoked tokens, and expired sessions resuming another session's hold, still get `401`. - **Browser SDK 0.94.0: the control room gets one way to choose seats, a map that reads at every zoom, and a picker that pauses politely.** Block, Categories, Channels and Select share one seat chooser (tap · drag box · lasso · category · section · rows · seat list · Find), and Categories gains "Reset to chart defaults". The map has a Find box and a View menu (seat labels, row labels, category colours, channel colours, booking momentum, remembered per browser), a hover card that says status · channel · for sale, a crumb, zoom pills and a minimap; section blocks wear an availability band and their state word, and a dense chart opens on its blocks. A control-room session that can no longer be renewed ends with one "Try again". For buyers, the seat picker pauses after thirty idle minutes and refreshes in place, and its chart-load telemetry counts page loads and socket reconnects. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.94.0 pin this release. ## 2026-09-19 - **Publishing a chart no longer updates linked events by itself.** Every event keeps its current map until it is updated explicitly: in the dashboard's post-publish dialog, one checkbox per event, or per event with `POST /v1/events/{key}/update-chart`. Behaviour change for secret-key publishes, which used to move events with no bookings automatically. The publish response gains `linkedEvents` (each event still on an older map, with its booked, held and blocked counts and whether the update is `safe` or `reviewed`); `refreshedEvents` is now always `0`. - **Publish answers sooner.** The dashboard's publish reports live as soon as the publication is saved, and new events can be created immediately after publish. `bundlePending` in the status shows the short window while the buyer map finishes preparing. ## 2026-09-18 - **Browser SDK 0.93.0: the embedded Designer fits short and padded hosts.** `height: 'fill'` no longer imposes a 480 px floor (`minHeight` now defaults to `0` and an explicit value is still honoured) and fills the host's content box, so a padded or bordered panel no longer overflows. The loading placeholder is the same quiet light shell the Designer paints, sized to the embed box. A hosted checkout the organizer's payment provider refuses now tells the buyer so, instead of a generic message. `@seatlayer/react`, `@seatlayer/vue` and `@seatlayer/angular` 0.93.0 pin this release. ## 2026-09-16 - **Browser SDK 0.92.5: the buyer page says why a sale is shut.** An event that is not on sale yet says so ("Tickets for this event aren't on sale yet") instead of "sales have ended"; paused events say paused. An event whose sales really have finished is unchanged. - **Browser SDK 0.92.1: Block mode marquee starts from anywhere inside a seating block.** In Block mode a drag that begins anywhere inside a seating block draws a marquee, and a drag that begins on the floor moves the map. - **Browser SDK 0.92.0: one seat-state language in the organizer control room.** On the manage map a free seat now draws in its category colour, a sold seat in neutral grey, a held seat in gold with a clock once you zoom in far enough to read it, and a blocked seat with a cross. The same four states are used everywhere the organizer sees seats. - **Dragging empty space moves the map, even with seats selected.** A drag that starts on an empty floor, an aisle or the gap between rows now pans the chart. Marquee selection starts only from a press on a seat, so you no longer lose a selection by trying to move the view. Section shapes no longer swallow the wheel or the drag either, so zooming and panning work over a section the same way they work over empty floor. - **A Clear button in the map toolbar** drops the current selection in one click, and the notices that used to stack in different corners now share a single pill lane at the top centre of the map. - **Channels follows your light or dark theme.** The dialogs, the preview banner, the status flags and the staged-changes bar all read correctly in both themes. "+ New channel" stays pinned while the list scrolls, and the buyer link, once revealed, has a Copy button next to it. ## 2026-09-15 - **Browser SDK 0.91.1: multi-floor charts name every seat by its own floor.** On charts with stacked floors, a seat on an upper floor now carries that floor's section name on the confirm card, the tray chip and the 3D caption instead of the ground floor's. Booths and table seats keep a tappable hit target at every zoom level, and the section overview chips show section names rather than ordinals. Charts with a single floor are unchanged. - **Hidden and closed sections are consistent on multi-floor charts.** Hiding or closing a section now removes the same seats from the map and from sale on every floor. Events materialized before this release keep their stored section labels in reports and section tools until the chart is republished. - **Events carry their after-payment destination.** `checkoutReturn` (`seatlayer` | `page`) is stored on the event and set from Setup → Checkout inside the payment-gateway card; a return to your own page still requires the website origin to be declared for the organization. - **Embed origins are observed, not typed.** The installation check records the origin of a published page that loads the widget, so the organizer can declare it with one click instead of pasting it. - **Door list and orders export.** The door list has one row per guest with the attendee name, phone and booking-form answers; `orders.csv` heads each booking-form question as its own `Q:` column (capped at 20). ## 2026-09-13 - **Browser SDK 0.88.1: the picker names a maintenance pause and how long it should last.** When the API answers `503 maintenance_mode`, the buyer picker now says "Booking is paused for maintenance. Please try again in about N minutes." (or shows the operator's own notice) instead of reporting that the seats were just taken. `ApiError` carries the new `retryAfterS` and `notice` fields on a 503, the mobile bridge forwards them, and the message is translated in all 37 locales. ## 2026-09-12 - **Publishing a chart returns before its events are rebuilt.** `POST /v1/charts/{id}/publish` now answers as soon as the new version is live, and the events pinned to that chart are moved onto it in the background. Two additive fields carry that: - `pendingEvents`: events handed to the background update. - `publishedSha`: the publication those updates are about. `refreshedEvents` still reports events refreshed **inside** the request, so it is `0` while `pendingEvents` is non-zero. `manualUpdates` and `eventsNeedingManualUpdate` keep their meaning: events a background update may not touch (a bound Event configuration, for example). An event that turns out to need a reviewed manual update, such as one holding bookings or holds, is reported by the status endpoint rather than in the publish response, along with its pricing review, so the publish response stays fast. - **New: `GET /v1/charts/{id}/publications/{sha}/status`.** How far one publication has travelled onto that chart's events: ```json { "sha": "…", "complete": true, "events": [ { "key": "ev_…", "name": "Opening night", "state": "done", "outcome": "refreshed", "at": 1757671234567 } ] } ``` `state` is `pending`, `done`, `skipped`, `failed`, or `unknown` (this publication never touched that event). `complete` is false only while something is still `pending`, so a failure is a finished state that needs attention rather than more waiting. It is always answerable: a publication with nothing pending returns an empty list with `complete: true`, so a client can poll it unconditionally. Charts with more than 200 events answer `truncated`. The endpoint is reachable with a secret key, but it is deliberately not part of the generated server SDKs yet: the per-event state vocabulary may still change while the propagation work continues. Nothing was removed, and no existing field changed shape. A client that ignores the new fields behaves exactly as before. ## 2026-09-07 - **Buyer SDK `0.86.2` (`@seatlayer/core`, `@seatlayer/js`, CDN, React, Vue, and Angular).** Row numbers keep to the seats they belong to. Rows outside the focused section wait for the seat numbers before they are drawn, a focused stand keeps its numbers at the row-band rung, and a focused section framed barely past the venue fit, a whole floor, waits for the seat numbers too. - **Buyer SDK `0.86.1` (`@seatlayer/core`, `@seatlayer/js`, CDN, React, Vue, and Angular).** Section names are drawn in one grey and set smaller, 6px on a phone and 8px on a desktop, and they grow by at most 1.15x as the map is zoomed. A vertical stage caption reads top to bottom. The phone fit keeps a 10px margin to the edge of the screen, row numbers appear only alongside the seat numbers, and a focused section can always be zoomed into. The minus button walks back out to the whole venue when the current fit already shows seats. - **Buyer SDK `0.86.0` (`@seatlayer/core`, `@seatlayer/js`, CDN, React, Vue, and Angular).** Section names are centred inside their section and drawn in the category hue, and sold-out sections read as pale blocks. Row-band lines are available on desktop and phone, with the hand-over to seats kept under the zoom ceiling, and the phone map keeps clearance above the zoom column. Picking the first ticket no longer moves the camera, while the beacon and count badge dive to the buyer's own seats. Closed and sold-out blocks are inert to taps, a held seat offers a Remove card, the seat card drops its distance line, and best-available skips seats that are not for sale. - **Buyer SDK `0.85.2` (`@seatlayer/core`, `@seatlayer/js`, CDN, React, Vue, and Angular).** The phone zoom column keeps its + and − controls in fixed slots, and a disc that reaches its limit fades out in place instead of shifting the column. The map camera now stays over the venue: a venue smaller than the screen never leaves it, a larger one leaves at most a third of the screen blank, and the leash is a hard stop rather than a snap-back. - **Buyer SDK `0.85.1` (`@seatlayer/core`, `@seatlayer/js`, CDN, React, Vue, and Angular).** Focused category rows remain visible after narrow-screen map settling and panning, and tapping a selected-ticket card keeps the mobile cart sheet open so buyers can inspect several chosen seats in sequence. - **Buyer SDK `0.85.0`.** Section selection can reveal category-coloured row bands before individual seats, with neighbouring sections remaining visible as the map moves. Chart authors can choose row detail or direct seat detail. Authored section label positions are respected and overview fills are darker. - **Mobile cart actions.** Opening a seat confirmation card no longer replaces the footer action with “Confirm or cancel this seat”. Adding or cancelling a candidate restores the normal cart action and selected-ticket total. - **Chart editing and transfer.** Section review tools support a repeatable review workflow. Portable chart exports preserve editable chart data while excluding private reference images and review metadata. ## 2026-09-06 - **Buyer SDK `0.84.1` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.84.1`; wrappers at the same version).** The phone round's second pass, again validated on a real iPhone. *Phone sheet.* The sheet stacks above the loading skeleton, so the handle is whole while the chart loads, and the handle steps aside while a confirm card is up. Adding a seat now reads as one moment: the button sweeps, the seat's chip flies to the cart total, the count and total swell as it lands, and only then does the map settle back. Tapping a ticket card takes the map to that seat and puts the sheet away. *Map controls.* The phone's control column grows upward from the corner: at the whole-venue view only the accessibility and zoom-in discs show, and the back-out discs appear above them, so the disc under the thumb never moves. The tapped-section card is gone on every layout. *Panels.* The accessibility menu's info button sits with its label and the free count stands clear of its switch; the find-seats-together card's info button follows its title. Light-theme map discs are tinted rather than white. - **Buyer SDK `0.84.0` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.84.0`; wrappers at the same version).** The phone round, validated on a real iPhone. *Phone sheet.* The bottom sheet has three resting heights (the footer alone, the cart, the whole panel), and its handle is a single disc straddling the one line the sheet draws, which is now the sheet's top edge: the map runs up to it and nothing is painted above it. Adding a seat always leaves the sheet at the footer, so the chart is never pushed up mid-pick; a tap anywhere on the footer brings the cart back, and the count and total swell when a ticket lands. Cart lines are shortened by the fact rather than clipped mid-word the first time the cart is shown. *Map controls.* The four discs in the phone's control column are one size, the back-out discs leave the column at the whole-venue view, and the zoom-in disc leaves it once seats are big enough to tap. Light-theme discs are tinted rather than white. *Seat numbers.* A seat that is tapped and then cancelled, or deselected from the cart, gets its number back at once instead of when the camera next moves. - **Buyer SDK `0.83.0` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.83.0`; wrappers at the same version).** A buyer QA round across the picker's map, panel and 3D view. *Map.* A seat nobody can take is inert: sold and other buyers' held seats are one flat grey disc that no longer answers a press, shows a hover card, or wears an accommodation glyph or ring, and a press that lands on empty canvas does nothing at all (the zoom-in cursor now appears only over the chart). The selection tick is drawn inside the seat's disc rather than over its markings, the focus ring comes down when the buyer steps the zoom back, and the hover card is larger and carries every per-seat attribute. *Seat attributes and notes.* Seat types ship a shared pictogram set, and a seat's notes are drawn as full-bleed bands on every buyer popup; a cart line states its attributes once, in words. Thirteen new strings across all 37 locales. *Phone.* The bottom sheet is now one surface with the desktop panel: the same footer block, the same cards, a pill handle on the edge, a capped list that scrolls instead of clipping. The hold clock lives in the header only. The map gets three zoom controls (+, −, fit) with an honest disabled state at the whole-venue fit, kept clear of the open sheet, and the price rail runs out past its last chip instead of slicing it. *Accessibility.* The accessibility control moved into the buyer's map control column, where it stays reachable in 3D; the menu it opens stays inside the screen, scrolls when it must, and gives each accommodation one readable line (icon, short name, count, toggle). Colourblind mode now remaps the accommodation ring ink. The "find seats together" card fits its title on one line. *3D.* Orbit direction is fixed at the source, `−`/`+` dolly symmetrically under a widen-only envelope, and the camera controls are named in the buyer's language. Leaving the booked overlay hands back a working picker. *Native hosts.* The picker snapshot carries `map.atVenueFit` and `map.canZoomIn` (both optional) so a native shell can draw its own zoom chrome truthfully. *Multi-level venues.* The level selector is a compact rail that fits its labels, keeps its own chrome clear of the seating and of the narrow map controls, and holds a selection made on one floor while the buyer reads another. A venue whose floors are shown together lays out as one combined plan, and the minimap tints and frames the focused section from the same source as the map. Entering or leaving fullscreen no longer re-measures a layout that did not change. *Optional section prices.* A section summary may carry no price range at all; the card now states what it has instead of rendering a range built from missing numbers, and section codes survive the trip to the card. ## 2026-09-05 (night) - **Buyer SDK `0.81.0` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.81.0`; wrappers at the same version).** A Season the organizer has made public can now be embedded with no backend. Omit `buyerAccessToken` and `buyerAccessTokenProvider` and the Season picker mints its own anonymous, origin-bound session; that session is public-sale only and can never reach a private channel or a renewal allocation, and an explicit token always takes precedence. `publicKey` is accepted for parity with the Event picker's snippet shape. It is not sent and is not a credential, because a public Season session is authorized by the Season key plus the browser's registered Origin. Three refusals reach `onAccessUnavailable` with their own reasons: `audience_not_public`, `sales_not_open` (retryable) and `origin_not_allowed`; the picker also states each one in plain language, so a host that handles none of them still shows the buyer something true. When the Season publishes per-category package prices, the server price wins: the hero prices each category for the whole package, the confirm card totals the selection at those prices, and host-authored display-only `offer` copy is not shown beside them. With no server prices the previous wording is unchanged. New `checkout` option: `'handoff'` stays the default and downloads no payment code, while `'hosted'` opens the same payment card the Event picker uses once the atomic hold is committed, one order with one ticket per performance, and confirms through `onOrderConfirmed`. `onContinue` still fires either way. A Season priced on the organizer's own backend reports `checkoutMode: 'server'` and stays on `'handoff'` rather than starting a payment SeatLayer cannot complete, and `returnUrl` is honoured only for the organizer's declared embed domains. ## 2026-09-05 (evening) - **Buyer SDK `0.80.3` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.80.3`; wrappers at the same version).** A second tap on a seat already under a hold now asks before releasing it on native shells that opt into `seat.retap`; it used to be released with no question. The overview beacon rings the buyer's seat itself rather than a point lifted toward the section's middle. Nothing changes for web hosts. ## 2026-09-05 (later) - **Buyer SDK `0.80.2` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.80.2`; wrappers at the same version).** Three additive bridge commands for native shells that draw their own chrome over the map, each gated on its presence in the `hello` command table. `picker.setSelectionFocus` names the seat a native card is asking about, so it is painted as the candidate the way the web card paints it: thick double ring, halo, neighbours paled. `picker.setBlockedRegions` takes the rectangles of native chrome laid over the map and swallows any pointer sequence that starts inside one before the map's gesture machine or canvas can see it; on iOS 26 a tap on a native control reaches the web view regardless of the platform-view gesture boundary. `picker.frameSeat` pans and never zooms, so a seat rests at a fraction of the band the viewport insets leave clear, answering with the pan it made and the buyer's own gesture count so a later re-frame can be refused once the buyer has moved the map. Nothing changes for the web picker. The wide confirm card now clears the seat it names when it closes. ## 2026-09-05 - **Buyer SDK `0.80.1` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.80.1`; wrappers at the same version).** A live hold is told once, by the header countdown: the "held tickets restored" notice on returning from checkout and the "N secured · yours for m:ss" strip above the pay button are gone on every layout, and the ✕ on each held line remains the way to release one seat. The phone is no longer offered a hold extension in the final minute; the wide layout keeps the docked control. The seat-session recovery card now says "The seat map timed out", that any seats being held stay held, and that Refresh brings the map back. The map's zoom stack is "+" and "−" on every layout; the 3D view keeps its own Fit view. The price rail no longer narrates other buyers' bookings, because the live "left" counts and the map already carry that. A second tap on a seat already in the order keeps the seat and opens its card, and a native shell can pin whether entering a section dims the rest of the venue. Nine locale keys are removed across all 37 languages; hosts that override `picker.securedCount`, `picker.holdReassurance`, `picker.change`, `picker.heldTicketsRestored`, `picker.fitToScreen`, `picker.liveAvailability` or `picker.seatsJustTakenInCategory` can drop those overrides. ## 2026-09-04 (late) - **Buyer SDK `0.80.0` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.80.0`; wrappers at the same version).** The client's testing round. The count beacon that marks a buyer's seats from the venue overview stands on the buyer's own section rather than the zone's label, and flies there; it and the other controls inside the map answer a finger, not only a mouse. The seat card opens one per seat on click as well as hover, the locate ring travels with its seat, and a host that draws its own seat card can tell the picker to stand its card down. ## 2026-09-04 (night) - **Buyer SDK `0.79.0` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.79.0`; wrappers at the same version).** When a buyer's hold runs out while they are still on the map, the picker now shows one card over the live map that names the seats that were released and offers "Choose seats again", which re-reads availability and returns the map to the venue. The seat-session recovery screen uses the same card: the map stays visible behind a light veil, every reason offers one action, and "Refresh" re-establishes the session in place. An open seat card, table dialog or standing-area prompt closes when either card appears. Opening the picker no longer fetches a seat snapshot the live connection already delivered. The test-mode chip is readable on dark maps, the widget no longer lets iOS resize its type, the full-screen frame follows the visible viewport height, and light mode reaches the picker's rounded corners in every full-screen route. Internally the picker and controller are split into smaller modules; the public API is unchanged. ## 2026-09-04 (evening) - **Buyer SDK `0.78.0` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.78.0`; wrappers at the same version).** A buyer's own hold no longer reads as another buyer taking the seat: the live feed exempts seats whose hold request is still in flight, so the seats stay in the order and the "someone else took a seat" notice does not fire on the device that pressed Continue. On phones the seat card is a fixed bottom sheet; the map lifts a low seat into view above it and drops back when the card leaves, the map behind it blurs with the tapped seat kept clear, and a tap outside the card closes it. Entering a section no longer dims the rest of the venue on phones, the section bar under the map is gone there, and the venue overview paints each block's number beside the zone names on every layout. The count beacon over a buyer's seats is a tap target that flies to those seats. "All prices" restores every section and the previous framing. The full-screen control hides where the picker already fills the phone. Tapping a seat already in the order opens the card with a Remove action instead of dropping it silently. Opening the picker issues its availability and live-feed reads alongside the chart load and no longer fetches the seat snapshot separately. ## 2026-09-04 - **Buyer SDK `0.77.2` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.77.2`; wrappers at the same version).** The seat card's category band no longer prints the remaining count; it is a touch taller with larger type and the price kept clear of the card's edge. On the phone the collapsed cart bar shows the whole of its buttons again, the chevron leaves the collapsed bar, and Find seats and Continue are larger, right-aligned buttons. The count beacon that marks a buyer's seats from the overview now sits over those seats rather than over the section's name, which could land a block away. ## 2026-09-03 (night) - **Buyer SDK `0.77.1` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.77.1`; wrappers at the same version).** On the phone the collapsed cart bar's grab handle sits clear of its buttons. Native hosts get the accessibility experience the web menu already had: turning a filter on through `picker.setAccessibilityFilter` flies to the matching spaces or keeps the overview with a hint, `picker.focusAccessibilityFilter` re-runs that flight, `picker.focusNextAccessibleSection` steps from section to section, and each snapshot section reports its free accessible places under `accessibleFree` (capabilities `accessibility-focus-v1`, `section-access-counts-v1`). With a filter on, opening a section frames its matching spaces instead of the whole stand. `picker.bestAvailable` now lands the map on the seats it held, with their neighbours, on every surface. ## 2026-09-03 (evening) - **Buyer SDK `0.77.0` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.77.0`; wrappers `@seatlayer/react`, `@seatlayer/vue`, `@seatlayer/angular` at the same version).** The phone seat card is louder and shorter: section, row and seat sit in three equal centred cells, the category band is the category colour itself with an ink chosen per colour, and a seat with no photograph drops the empty strip and keeps 3D as a square beside Cancel. Inside the 3D venue the passport, compare and view-from-here controls share one row. A seat tap in 3D steps the camera in before the card opens, and seat numbers print on readable plates. The accessibility filter keeps the sections holding free spaces lit with a count badge, offers a hint when they span the venue, and turns the free count into a way to jump between them. A best-seats pick frames the seats it found with their neighbours, also under reduced motion, and every automatic zoom stops at a thumb-sized seat. The empty cart bar prints the from-price large with a rounded Find seats button. - **Fewer requests on open.** The map opens faster with fewer requests: when nothing changed it makes one map read and one availability read, and live updates paint from the first message instead of reading again. - **Bootstrap response.** `chartDelivery` now carries `bytes` and `cache` only. ## 2026-09-03 - **Buyer SDK `0.76.0` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.76.0`; wrappers `@seatlayer/react`, `@seatlayer/vue`, `@seatlayer/angular` at the same version).** Focusing a seat dims only the seats outside its neighbourhood, so the row and its neighbours stay readable. A price or accessibility filter recedes the seats it excludes instead of hiding them, and sold seats draw hollow with a status cue whose ink is chosen against the surface behind it, so it reads on light and dark canvases alike. Tapping a price band dives to a readable, pickable depth when the band is one block in one section, and frames every block when it spans several; turning the band off leaves the camera where it was. The map fills its box on landing, reuses label nodes across pans, and survives a host that mounts it hidden. Switching theme at seat depth keeps every seat number and status cue. The buyer chrome now fits its own words in every locale: action buttons step down before "Cancel" becomes an icon, cart lines drop whole facts rather than cutting mid-word, and pills shrink before they wrap. - **Selection.** The "leaves a single seat stranded" advisory only appears when the host enforces the `noOrphanSeats` selection validator; without it buyers pick freely with no message. - **Native hosts.** Selection entries gain `seat-view-thumbnail-v1`: a real photo reference (`seatViewThumb`), the sightline distance (`sightlineMetres`) where a stage is authored, and the seat's confidence disclosure (`seatViewConfidence`). The web picker shows "View from here" only for seats with a real uploaded photo. ## 2026-09-02 - **Buyer SDK `0.75.1` (`@seatlayer/core`, `@seatlayer/js`, CDN `seatlayer-js@0.75.1`; wrappers `@seatlayer/react`, `@seatlayer/vue`, `@seatlayer/angular` at the same version).** The seat picker's confirm card shows Section · Row · Seat only where a section is authored (category-only charts show Row · Seat), the ticket-type band is a tint of the category colour with the theme's own ink, the primary action reads "Add seat" and invites the tap, and the cart lists one line per ticket in the price list's language. On phones the card sits just above the tapped seat, every control meets a 44 px touch floor, the sheet respects the home-indicator inset, the overview minimap is gone, the empty bar offers "Find seats", and the best-seats card stacks its controls. Seats reveal in one tap on charts without sections; selected seats keep a breathing halo and, at the section and zone levels, a tappable beacon that flies to them. Sold seats no longer invite a hover. The accessibility filter frames every matching seat, dims other buyers' holds, and carries into 3D, whose section shells now recede when a filter empties them. In 3D the seat-eye view opens from the occupant's eye height, aims at the stage from every block, narrows its lens on portrait phones, and its look-around drag works again; the caption, back control, area chips and fit-view icon no longer overlap. Zone pills are quieter and never wider than their zone. Charts may opt in to a derived restricted-view flag (`derivedRestrictedView`) that marks seats the house geometry obstructs, honouring any authored flag. - **Fixes.** A held-seat conflict during checkout can no longer render the success screen; the success view lists the seats and offers a way back to the map. Section-less charts no longer print the engine's internal "Other seats" label. The renderer repaints only what changed on availability snapshots and focus changes, coalesces wheel zoom per frame, caches the pointer rectangle, and fetches inventory once per boot. - **CDN.** Every script now ships pre-compressed brotli and gzip variants served by content negotiation; non-English pickers no longer wait for the locale before fetching the chart. Native hosts gain additive bridge fields behind `seat-screen-point-v1` (a seat's screen point on selection entries) and `category-availability-v1` (per-category `free` counts). ## 2026-08-31 - **All four native SDKs are aligned at `0.3.4`.** Flutter on pub.dev, React Native on npm, iOS through the `v0.3.4` Swift tag, and Android's aligned core and Compose artifacts on Maven Central now ship the complete native picker. Each supports a ready-made flow, themes/strings/styles/builders, a host-owned layout built from public components, and the preserved raw map. Booking stays on the trusted backend, and the native picker currently targets one Event, not a Performance Group or Fixed Renewable Season. - **A new Fixed Renewable Seasons preview, not yet released.** It permits multiple immutable Plans to remain published, binds each browser session to an explicit `planActivationId`, and requires one complete retained rehearsal journey per published Plan before sales can open. The JavaScript preview adds same-seat Season Best Available plus safe adoption when `buyerAccessTokenProvider` rotates to an exactly matching session. It builds on the released `0.72.1` baseline and remains unpublished. The REST and Node.js preview adds exact-replay return and reclaim for one booked occurrence. It remains unpublished; the other six server SDKs do not yet expose those two operations or webhook names. In the preview, Season booking and renewal also show up on each included Event: one usage unit per newly booked Event label, Event booking history, live seat updates, and standard `seat.booked`/`event.soldout` webhooks. Full Season cancellation produces the matching Event history, live release, and `seat.released` webhook. Exact replay does not repeat these effects, no extra Season usage unit is added, and Event configuration prices are not treated as package revenue. Season Event webhook items omit configured price and currency while trusted Event history keeps them. - **No existing package or deployment is being renamed as that preview.** The released `0.7.0` server-SDK and `0.72.1` browser/framework baselines remain the previous frozen surface. No new package version, CDN artifact, REST deployment, framework wrapper, or native wrapper is claimed here. This work remains inventory-only: hosted ticket selling, package pricing, checkout, payment, commercial orders, ticket/pass issuance, refunds, resale listings, and payouts stay outside SeatLayer's Seasons scope. ## 2026-08-30 - **Fixed Renewable Seasons reached the frozen production SDK baseline.** Node, Python, PHP, Java, Go, Ruby, and .NET `0.7.0` each expose the same 48 released Season operations. JavaScript, React, Vue, and Angular `0.72.1` expose the released browser picker. Later multi-Plan shapes and occurrence return/reclaim are a preview, not yet released, and are not part of those package versions. ## 2026-08-29 - **Historical candidate milestone, superseded by the 2026-08-30 release.** The production-gated Fixed Renewable Seasons candidate could build a catalogue from 2–20 compatible Events, publish an immutable Plan, rehearse the buyer flow, coordinate same-seat holds and booking, import incumbent Seat Rights, issue time-bounded renewal offers, record occurrence amendments, and inspect operational reports. The host platform keeps identity, pricing, checkout, commercial Orders, tickets, refunds, and fulfilment. The public candidate initially covered the REST API, the Node.js server resource, JavaScript and React `SeasonPicker`, and organizer dashboard. The completed 2026-08-30 release expanded the server contract to all seven SDKs and the web picker to Vue and Angular as well. No native `SeasonPicker` is claimed. Start with the [buyer journey](/buyer-sdk/seasons) or [Seasons API](/server-api/seasons/). - **Current AI and agent surfaces use one release vocabulary.** Copilot Ask, Edit, multistep creation, reference input, and chart-scoped Designer MCP are released with review, revision, authorization, and publication boundaries. AI-led 3D improvement remains off. ## 2026-08-23 - **Partner access for sales channels.** An organizer can hand one private channel to an external partner organisation. Each invited person accepts with their own SeatLayer account and sees the channel as a *partner context* in the workspace switcher: Overview, Allocations, Distribution, Reports, and Team. It is not membership of the organizer's workspace. Roles are Viewer, Coordinator, and Partner manager; per-assignment permissions cover buyer links, free-seat returns, and inventory requests; a release date returns still-free seats to Public sale automatically; every action on both sides is audited. See [Partner access](/platform/sales-channels#give-a-partner-its-own-context-partner-access). ## 2026-08-21 - **Performance Groups are available with Buyer SDK 0.64.0.** A Platform integration can present one fixed run of two to eight compatible dated performances with `PerformanceGroupPicker`. Buyers choose the same assigned seats for every included date or an equal-size allocation per date; SeatLayer makes one recoverable all-or-nothing hold and the host retains checkout, payment, commercial order, and fulfilment ownership. This is not a season ticket, subscription, pass, or buyer-selectable date bundle. Start at [Integrate a Performance Group](/integrations/performance-groups). ## 2026-08-16 - **Sales channels can now carry authoritative sparse prices.** Each channel exposes `priceOverrides` and an independent `pricingVersion`; PATCH writes use optimistic concurrency and reject unknown category/tier shapes. Scoped buyers see the channel price throughout SeatPicker, while the server resolves and freezes the same value on the hold. Existing holds and booked lines do not change after a price edit. - **Pricing precedence is explicit.** A channel override wins over an ordinary active ticket release and does not consume its quota. A Box Office operator's deliberate named-release choice wins over the channel override and keeps the release attribution. - **Control Room remains lightweight.** Channel details now contain one compact price editor; blank fields inherit event pricing, and a concurrent save refreshes the latest values for review instead of silently overwriting them. ## 2026-08-13 - **Manage sessions now default to least privilege.** Omitting `capabilities` when minting an `mse_…` token is still accepted, but the effective grant is now exactly `event:view`. Blocking, cancellation, reporting, channels, and Hosted Ticketing authority must be requested explicitly. Empty, malformed, and unknown capability lists remain invalid. - **Manage-session revocation is exact-event scoped.** A session ID can be revoked only under the event that minted it. Cross-event IDs are non-disclosing `404` responses, and live/test key mismatch returns the documented `403 mode_mismatch` without revoking the session. - **`event:boxoffice` browser grants are effective.** A correctly scoped manage-session token can now use the Managed counter read and sale routes; the previous auth dispatcher incorrectly treated it as a secret key. ## 2026-08-10 - **The Platform/SDK booking boundary is inventory-only.** A platform owns its event catalogue, buyer, checkout/payment, commercial Order, ticket/QR/PDF creation and delivery, email, refunds, scanning, and support. SeatLayer owns seating inventory, holds, idempotent inventory booking, audit/reporting, and webhooks. `bookingRef` links the systems; it is not a SeatLayer commerce Order. The short-lived behavior announced on 2026-08-06 created an internal `external` Order, tickets, PDF/email, attendance, and Door artifacts after `POST /book`. That behavior has been removed. Existing historical artifacts are retained for migration/audit, but new Platform bookings create inventory records only. SeatLayer-managed checkout and box-office fulfilment are unaffected. - **Inventory cancellation and direct booking now enforce the same product boundary.** Generic `POST /v1/events/:key/unbook` and legacy `POST /v1/events/:key/box-book` are available only for explicit Platform/SDK events. Hosted Ticketing cancellation stays coordinated through its Order/refund workflow, and Managed counter sales use `/box-office/sale`, so neither path can leave a live Order or ticket detached from its inventory. Organizer lifecycle, Control Room, object, report, and realtime-ticket routes also reject a live/test secret key used against the opposite event mode before reading or changing the event. ## 2026-08-06 - **The confirmation email and ticket PDF now wear the organizer's brand.** The logo and accent an organizer sets for their event pages now appear on the buyer's confirmation email, with the logo in the header and accent on the card. The accent also colours the printed ticket's rules. Sending stays under SeatLayer's authenticated domain with the organizer's name and reply-to, so deliverability is unaffected. Organizers who have set no branding see no change. - **Historical implementation note, superseded: a server booking also created hosted fulfilment artifacts.** For a short period, `POST /v1/events/:eventKey/book` with a `holdId` created a first-class order and issued real tickets: one QR per seat, the buyer's ticket page, a printable PDF, and (when you pass the new optional `buyer.email`) the confirmation email. The response gains an `order` object carrying the order id, the human reference, and one scannable URL per seat so you can render your own tickets instead. The sale appears on the organizer's Orders screen, the timeline, attendance, and the door list, exactly like a hosted-checkout sale. Retries with the same `bookingRef` return the original order and never email the buyer twice. Direct label bookings (no hold) get the same sale. They are priced through an internal hold. In the rare case the sale cannot be priced, the seats still book and the response states `orderUnavailable` instead of silently issuing nothing. SeatLayer still never touches your money on this route. Refunds stay in your own payment account, and the organizer's refund button on such an order says so. See [booking](/server-api/booking). - **Every order now has a human reference: `#7284 7528`.** Eight digits with a check digit that rejects a misheard or transposed digit rather than matching someone else's order. It appears on the buyer's confirmation, the tickets and PDF, the confirmation email, the organizer's Orders screen and the door list, and the Orders search accepts `#7284 7528`, `72847528`, and `7284-7528`, alongside buyer name, email, and pasted order ids. It authorises nothing: ticket pages and PDFs are still addressed only by the long `ord_…` id. Orders that predate the change gain numbers automatically. ## 2026-08-01 - **Behavior change: a bare secret key can no longer touch private inventory.** This affects you only if your server calls SeatLayer with an `sk_…` key to book seats *and* you start using sales channels on that event. An event with no channels behaves exactly as it did before. Once a seat is assigned to a private channel, a **direct label booking** without a `holdId` fails on that seat with `403 channel_access_denied` unless the request says which channel it is acting for. A secret key proves which organization you are, not which allocation you may spend; without this rule the key that runs your public checkout could quietly drain a sponsor's reserved block, and nothing but the audit log would say who did it. Three ways forward: send `channelIds: ["chn_…"]` and the sale is scoped and attributed to that channel; do nothing if the seats you book are all Public sale; or send `ignoreChannelRestrictions: true` with a `reason` for genuine back-office work, which reaches everything and writes every private seat it reaches to the channel audit log. **Booking a hold is unaffected.** A hold already captured its authorization when the seats were taken. See [booking](/server-api/booking). - **Sales channels.** Split one event's inventory into named allocations for partners, presales, sponsors, and box office, and decide who may select each one, without splitting the event, the chart, or the seat count. Assignment never changes whether a seat is free, held, booked or blocked; it only changes who may pick it before it sells. Every event you already have is entirely Public sale and stays that way; there is no migration. Start at [sales channels](/platform/sales-channels). - **New API: channels and buyer access sessions.** Channel create, list, rename, versioned assignment, allocation paging, access preview, pause/unpause and archive-with-destination are on [`/server-api/channels`](/server-api/channels). Buyer access sessions are short-lived, opaque, origin-bound `bse_…` bearers your backend mints for one buyer. They are documented at [`/server-api/buyer-access-sessions`](/server-api/buyer-access-sessions). Assignment is versioned and non-destructive: seats in someone's checkout and seats already sold are never moved. New in the generated OpenAPI spec and the server SDKs. - **Channel reporting separates allocation from attribution.** `GET /v1/events/:key/channels/report` reports what each channel holds *now* beside what it *sold*, which are two different columns that move independently. Sell-through is attributed-sold over current-allocated, and the response says so in a `methodology` field rather than leaving you to guess. Revenue requires `event:reports` on top of `event:channels:view`; a caller without it sees every operational figure with `revenue: null`. See [reports](/server-api/reports). - **`seat.booked` webhooks carry sale attribution.** Booked line items gain `channelId` (`null` = Public sale), `channelExternalRef`, `accessSource`, and `partnerRef` where the buyer's grant carried one. Attribution is per item, not per delivery, and it is recorded at booking time and never rewritten, so it is safe to reconcile against. Payloads never contain a bearer token, session id, link capability, or secret key. See [webhook events](/webhooks/events). - **Two new least-privilege capabilities** for event manage tokens: `event:channels:view` (names, allocations, counts) and `event:channels:manage` (create, assign, pause, archive). Neither implies the other, and `event:block` gains nothing. Tokens minted before this release keep exactly the capabilities they had. Channel access is opt-in by name. ## 2026-07-28 - **Template capacities changed for 17 of the 50 catalog templates.** The published catalog had been serving a snapshot taken on 2026-07-17, and this is the first full reprint since, so the change carries eleven days of accumulated geometry work rather than a single fix. **If you size events, price tiers or reconcile inventory against a template's published seat count, re-read it.** Existing charts you have already created are untouched. This affects the template you instantiate *from*, not anything you have saved. The largest movements, measured against the versions they replace: | Template | Before | After | Change | | --- | --- | --- | --- | | `esports-arena` | 864 | 3,666 | +2,802 | | `amphitheatre` | 864 | 3,605 | +2,741 | | `arena-round` | 2,364 | 3,684 | +1,320 | | `west-end` | 1,584 | 922 | −662 | | `small-theatre` | 198 | 788 | +590 | | `concert-hall` | 663 | 1,119 | +456 | | `uber-arena` | 14,142 | 13,784 | −358 | Increases are mostly sections that the archetype compiler had already learned to build but that had never been printed. Decreases are corrections: rows that were being counted where the geometry could not actually seat them. A separate fix to arc-row placement accounts for the `uber-arena` reduction and several smaller ones. Rows had been deriving their radius from a rounded seat count and could land up to 30 chart units off their own circle. - **Venue relief now renders in 3D across 34 of 50 templates.** Sections carry real front-edge heights and rake, so raked stalls, stacked terraces and balcony tiers read as physical structure instead of flat plates at stepped heights. `arena-section` previously authored no sections at all and now builds three terraces rising to 4.92 m. The remaining 16 templates are flat because the rooms are flat, including banquet floors, booth halls, GA lawns, conference rooms, runways, and black-box studios. They are deliberately not badged 3D. - **Web SDK 0.32.0.** Seats render as chairs with backs at close range rather than dots, and label emphasis is reduced at seat level. The 3D renderer is still loaded only when a buyer opens 3D. `@seatlayer/core`, `@seatlayer/js` and `@seatlayer/react` are on npm; the CDN `seatlayer-js@0` alias resolves to the new version. - **`GET /v1/charts/:id` returns `categorySeats`.** A map of category key to the number of seats that category actually sells, derived from the document on every read and never stored. It resolves per-seat category overrides, so it agrees with what the map sells rather than with what the row objects declare. Categories that sell nothing are present with `0`, so "sells nothing" stays distinguishable from "does not exist". ## 2026-07-26 - **Native Android SDK 0.1.0 first public release.** The official Kotlin SDK provides `SeatLayerView`, coroutine commands, typed state and event flows, structured errors, a secure origin-restricted AndroidX WebKit bridge, and a runnable sample app. Install the exact JitPack tag or inspect source at [seatlayer/seatlayer-android](https://github.com/seatlayer/seatlayer-android). - **React Native SDK 0.1.0 first public release.** The official `@seatlayer/react-native` package provides an Expo-compatible view, typed controller, versioned bridge, structured errors, and an independently vendored buyer runtime for iOS and Android. Source and the runnable example are available at [seatlayer/seatlayer-react-native](https://github.com/seatlayer/seatlayer-react-native). - **Web SDK 0.30.0.** The public SDK now includes the current 3D venue structure, section rake, geometry, labels, and buyer-view renderer. The npm package pages also gain complete package-level guides, exact documentation routes, live demos, mobile SDKs, agent resources, and registry metadata. - **Web SDK 0.29.0 published.** `@seatlayer/js` and `@seatlayer/react` `0.29.0` are available from npm. The release includes the reconciled buyer widget and versioned mobile bridge behavior described below. - **Flutter SDK 0.1.0 first public release.** The official [`seatlayer`](https://pub.dev/packages/seatlayer) package is available from pub.dev, with source in [seatlayer/seatlayer-flutter](https://github.com/seatlayer/seatlayer-flutter). - **iOS SDK source is public.** The Swift package is available at [seatlayer/seatlayer-ios](https://github.com/seatlayer/seatlayer-ios) for branch-pinned evaluation while its first semantic release is qualified. ## 2026-07-23 - **Native SDK release candidates.** The iOS and Flutter `0.1.0` packages now share the versioned bridge protocol and pin their vendored buyer bundles independently. Their typed APIs cover holds, GA quantities, tiers, floors, accessibility color, best-available zone/premium/TTL controls, and flat/isometric/Perspective 2.5D view modes. They remain unpublished until the immutable web artifact, hosted/device smoke tests, and public-package checks pass. - **Web SDK 0.29.0 release candidate.** The app and public SDK widget sources are reconciled. The bridge adds initial and runtime view-mode control while retaining the hold-TTL and correlated command-error behavior. Existing public installation guidance remains pinned to the currently published `0.28.4` packages until the release gate completes. ## 2026-07-22 - **Reference and vector import boundaries.** The Designer can use PNG/JPEG/WebP as a private trace reference or rasterize one selected page from a PDF source up to 15 MB and 100 pages. It can also sanitize a bounded SVG/DXF subset into ordinary editable Shape objects. PDF export, native PDF/vector fidelity, arbitrary SVG/full CAD support, and SVG export remain outside the contract. Private `referenceImage` bytes and provenance are removed from buyer documents; the optional URL-only `backgroundImage` is a separate buyer presentation layer. - **Eight accessibility accommodations.** CART live-caption view is now first-class alongside wheelchair, companion, semi-ambulatory, hearing, sign-language, plus-size, and lift-up armrest. Wheelchair inventory distinguishes an accessible physical seat from an empty selectable bay across the Designer, picker, event capacity, and JSON reporting. - **Per-zone focal points.** Venue zones can face distinct stages or field centres. Designer authors can place the point on the map or copy the active floor focal; generated seat views and best-available ranking consume the owning zone's point. The buyer's best-seats panel offers an accessible zone choice when applicable, and `bestAvailable` accepts an optional stable `zoneId`. Legacy charts still preview with floor/chart fallback, but used zones must be explicit before publication. ## 2026-07-21 - **SDK 0.23.0: "Best seats" premium quick-pick.** In charts with premium-flagged seats, the picker's find-best-seats panel gains a ★ toggle that biases the search to an all-premium block, with an honest fallback note when no premium block of the requested size exists. Charts without premium seats are unchanged, and the default best-available ordering is identical when the toggle is off. Localized in English, Spanish, German, and French. - **Designer loading skeleton.** Opening a chart now shows a ghost of the designer chrome and canvas instead of a text placeholder while the document loads. ## 2026-07-20 - **SDK 0.22.0: the embedded Designer fills the viewport.** `EmbeddedDesigner` defaults to `height: 'fill'`: the iframe always reaches the bottom of the browser window (recomputed on resize and scroll, floor of `minHeight: 480`), instead of the old content-height auto-grow that could leave the editor short inside a host page. Numeric `height` plus `autoResize` remains for hosts that manage sizing themselves, and the Designer's new full-screen button asks the host to pin the iframe over the page via `seatlayer.designer.fullscreen`. See [Embed the chart Designer](/platform/embedded-designer/). - **SDK 0.22.0: buyers see seat flags.** Seats marked in the Designer as restricted view, obstructed, or premium now surface in the picker: a caution note (with the organizer's own wording) on the seat details card and hover tooltip, markers on cart chips, and a **Hide limited-view seats** filter that dims flagged seats, localized in English, Spanish, German, and French. - **White-label enforcement.** `theme.hideBadge` ("Hide Powered by SeatLayer") is now honored only for accounts with the white-label add-on; the flag is ignored at buyer-serve time otherwise. Existing charts are unaffected in the dashboard. Contact support to enable the add-on. ## 2026-07-19 - **SDK 0.21.0: denser buyer panel and cross-floor category focus.** Ticket-type rows now filter and frame their category, including categories on another floor; long section names wrap cleanly; low-value availability chrome stays quiet until there is real news; and all four sightline translations use the correct distance copy. - **SDK 0.20.0: complete React option forwarding.** `` now forwards every typed core option, including `pricing`, `hideBadge`, and `transport`, instead of silently dropping newer options. React hosts can also import `attachPickerFrame` directly from `@seatlayer/react`. - **SDK 0.19.0: embedded control room.** `@seatlayer/js` now exports `SeatManager` and `@seatlayer/react` exports ``, a live, origin-scoped seat-management board for organizers: realtime seat repaint, KPI + gross-revenue tallies, an activity feed, and bulk block/unblock, all inside your own admin UI. See [Embed the control room](/platform/embedded-control-room/). - **Manage sessions: browser-safe, capability-gated tokens.** A secret key or a dashboard session can mint a short-lived `mse_…` token via `POST /v1/events/:key/manage-sessions`, scoped to one event, one exact browser origin, and a chosen subset of capabilities (`event:view`, `event:block`, `event:cancel`, `event:reports`). Tokens are shown once, never retrievable, revocable (`DELETE /v1/events/:key/manage-sessions/:id`), and the account secret never ships to a browser. Each route is gated by the capability it needs. - **Sections availability: the `closed` state.** Availability rules (`POST /v1/events/:key/availability`) add a `closed` mode alongside `hidden`, `timed`, and `threshold`: a closed section stays **visible** to buyers but greyed and off sale, where a hidden section vanishes. Operators drive open / closed / hidden / timed / auto-reveal from the control room's Sections tool. See [Inventory control](/server-api/inventory/#availability-windows-timed--auto-reveal). - **Iframe embed docs.** The one-iframe `/e/:key` buyer embed, including responsive sizing, the `seatlayer:height` / `seatlayer:fullscreen` postMessage contract, and the `attachPickerFrame` host helper, is now documented in [Iframe embedding & sizing](/integrations/iframe/). - **Embedded Designer host options.** [Embed the chart Designer](/platform/embedded-designer/) now documents the wrapper's `autoResize`, `showLoadingState`, `loadingTimeoutMs`, `onRequestRelaunch`, and host-managed fullscreen behavior. - **Usage-based pricing only.** Monthly subscription plans were retired in favor of a single usage-based [credit wallet](/billing/credits): one credit per sold seat, a free grant of 100 sold seats/month, and prepaid packs from 8¢ down to a 5¢ floor. Every feature is included for every workspace. See [Pricing](/billing/pricing). - **`hold.extended` webhook.** The extended-hold event is now subscribable, bringing the [webhook catalog](/webhooks/events) to eight events. - **Docs restructured around personas.** These docs are now organized by task: embed the picker, wire up booking, build venues in a CMS, run the control room, customize, or run a platform. The API reference covers the Management, Server, and Designer Embed surfaces. The [manage-session token](/platform/embed-sessions), control-room, availability, event-lifecycle, template-instantiate, and webhook-config operations are now documented. ## 2026-07-15 - **Build with AI: Designer MCP first public release.** An external agent (Claude, ChatGPT, or your own bot) can connect to SeatLayer's remote, chart-scoped MCP over OAuth 2.1 + PKCE and follow a deterministic reference → design → validate → preview → review loop. SeatLayer hosts no model; your agent supplies vision and semantics while SeatLayer supplies secure reference assets, geometry, validation, and publication gates. Charts stay unpublished drafts until authoritative seat data and human review exist. This release established the chart-scoped workflow and human-review gate. See [Build with AI](/agents/overview). ## 2026-07-10 - **Platform API 0.4.0: embedded chart Designer.** Platform backends can mint short-lived `dse_…` sessions scoped to one active workspace, one chart, one exact parent origin, expiry, and optional publish permission. Organizers can draw new venue geometry, edit, autosave, upload assets and references, and publish inside the platform CMS without a SeatLayer login or account key in the browser. Sessions are shown once, never retrievable, revocable, and disabled automatically when their workspace is disabled. See [Embed the chart Designer](/platform/embedded-designer/). - **SDK 0.3.0: native Designer hosts.** `@seatlayer/js` now exports `EmbeddedDesigner`, and `@seatlayer/react` exports ``. Both wrappers mount the scoped Designer iframe, validate message origin/source and optional chart/workspace IDs, and recreate the iframe when a new session URL is supplied. - **Platform API 0.3.0: organizer workspaces.** One SeatLayer account can create an isolated workspace per organizer through `/v1/workspaces` while retaining account-level billing, keys, and a single webhook subscription. Charts have an immutable `workspaceId`; events inherit it from the chart; list routes filter by it; and webhook payloads include it. Existing integrations continue in an automatically-created default workspace. `externalRef` remains optional routing metadata, not authorization. - **SDK 0.2.0.** Atomic cart/tier hold replacement, GA quantity APIs and pricing, per-hold TTL, authoritative hold items, expiry reconciliation, and React parity. - **Fully key-drivable platform flow.** `POST /v1/charts/:id/publish` and `GET /v1/events` (sold counts, `externalRef` filter, `mode` on every item) now accept secret-key auth: duplicate → publish → create event → embed without a dashboard visit. - **`externalRef` tagging for platforms.** Charts and events accept an optional `externalRef` (your own organiser/tenant id, up to 128 chars) on create and on `POST /v1/charts/:id/duplicate` (override wins, explicit `null` clears, absent inherits the source's tag). It's returned in chart/event meta, updatable via `PUT /v1/charts/:id` and `PATCH /v1/events/:key`, filterable on the list routes (`?externalRef=…`), and rides every webhook payload alongside `livemode` (additive: only present when set). - **Chart provisioning with secret keys.** The chart routes (list, create, get, update, duplicate, archive) now accept `Authorization: Bearer sk_…` in addition to a dashboard session, so a platform backend can provision per-organiser charts programmatically. Charts carry no live/test mode (a chart is a design; mode lives on events), so test keys can provision charts too. Delete, unarchive, thumbnail, and asset routes support the same scoped secret-key workflow. - **New guide: [Platform integration](/integrations/platforms/).** The end-to-end recipe for running SeatLayer under a multi-organiser ticketing platform: roles mapping, per-organiser provisioning, webhook routing by `externalRef`, and how billing works for platforms. - **Test-mode keys + sandbox events.** API keys can now be created in **test** mode (`pk_test_…`/`sk_test_…`; rotation keeps the mode). An event created with a test key is a sandbox event: bookings are free, with no credits or usage reports, while holds, best-available, and realtime behave exactly like live. Buyers see an amber TEST MODE ribbon on the chart (localized), and every webhook payload now carries `livemode: true|false` so receivers can route sandbox traffic. Test and live are strictly isolated. A key used on an event of the other mode gets `403 mode_mismatch`. See [Test mode](/start/authentication/#test-mode) and the updated [Quickstart](/start/quickstart). - **SDK 0.1.4.** New floor API for multi-floor charts: `getFloors()` and `setFloor(floorId)` let embedders build their own floor switcher (see [Multi-floor venues](/designer/multi-floor-venues)); new colorblind-safe mode: the `colorblindSafe` option (and `setColorblindSafe()`) switches categories to an Okabe-Ito palette and renders booked seats as hollow rings, with a matching toggle on the hosted buyer page; new `onHint` callback delivers the non-blocking, localized orphan-seat hint (see [Holding seats](/buyer-sdk/holds-and-checkout/#the-orphan-seat-hint)). `@seatlayer/react` exposes the new methods on its imperative handle. - **Production launch on seatlayer.io.** The API (`api.seatlayer.io`), hosted SDK (`cdn.seatlayer.io/sdk/v1/seatmap.js`), and dashboard are live on the production domain. - **Per-event checkout window.** The default hold TTL is now **15 minutes** (was 5), configurable per event via `GET`/`POST /v1/events/:key/hold-ttl` or the dashboard's "Checkout window" field. Precedence: explicit `ttlMs` on the hold → event setting → default, all clamped to 60 minutes. See [Handling hold expiry](/buyer-sdk/hold-expiry/). - **SDK 0.1.3.** `HoldResult` and `BestAvailableResult` gain `seats` (the held seats with the buyer's chosen ticket tier per seat); new `setSeatTier()` method and `tiers`/`tierId` on `SelectedSeat`; new `onDeckTap` callback for multi-floor charts. - **Best available respects hidden inventory.** `bestAvailable()` never auto-picks a seat inside a hidden or not-yet-revealed section. ## 2026-07-09 - **Inventory control + auto-reveal.** Set section/zone availability windows that hide inventory from buyers or reveal it on a schedule or automatically once the rest of the house sells past a threshold (`GET`/`POST /v1/events/:key/availability`). Cancel bookings with `POST /v1/events/:key/unbook`; schedule blocked seats to auto-release with `releaseAt` on `block`. Reveal changes reach embedded pickers live through the SDK. See [Inventory control](/server-api/inventory/). - **Ticket tiers.** Categories can offer named price tiers (Adult / Child / Senior…); buyers choose per seat in the picker, and the chosen tier rides through the hold to your booking flow. See [Ticket tiers](/buyer-sdk/ticket-tiers). - **Multi-floor venues.** Charts can carry multiple floors; the designer authors them per floor, and buyers get a floor switcher plus a stacked 3D deck view. See [Multi-floor venues](/designer/multi-floor-venues). - **Template library.** 28 templates: 11 capacity-adjustable venue families (with a continuous capacity slider) plus 17 curated flagships, and an insertable seating-blocks library in the designer. See [Templates](/designer/templates/). - **Big-venue rendering.** Section-block overview with level-of-detail: far zoom shows section blocks with availability tint, zooming in melts blocks into individual seats. - **Buyer i18n + dynamic currency.** The picker and embed SDK ship in en/es/de/fr (`locale`/`messages` options), and prices render in the organizer's workspace currency. See [Localization](/customization/localization). - **Subscription plans.** _Superseded 2026-07-19._ Monthly paid tiers (Studio, Pro, Scale) shipped here were retired in favor of pure usage-based [credits](/billing/credits): one credit per sold seat, no subscriptions. See [Pricing](/billing/pricing). ## 2026-07-08 **1.0: Public API + embed SDK.** Book, hold, best-available, and block seats; pull inventory reports and audit logs; embed a live seat map with the SDK; receive webhooks (`seat.booked`, `seat.released`, `seat.blocked`, `hold.created`, `hold.expired`, `event.created`, `event.soldout`); and keep the embedded picker in sync with live seat updates through the SDK. # Copilot overview > Understand what Copilot can do in the Venue Designer today, how reviewable creation and editing work, and what remains off. Source: https://docs.seatlayer.io/copilot/overview/ · Markdown: https://docs.seatlayer.io/copilot/overview/index.md SeatLayer Copilot Ask is released for understanding the chart already open in the Venue Designer. It can answer chart-aware questions without changing the chart. > **Current boundary** > > Ask itself is read-only. Editing an existing chart, creating one from written > requirements, and taking a plan or reference image as input are separate > released workflows: they change the open chart only as a reviewable > revision that you publish. AI-led 3D improvement is off, and external-agent > access is the released [Designer MCP](/agents/designer-mcp), scoped to one > chart or one workspace. The > released chart-derived 3D buyer view is a separate capability. ## What Ask can help with - summarize the open chart's structure; - explain capacity, sections, rows, tables, labels, or floors that are present in the authorized chart context; - identify accessibility or review topics the user should inspect; - explain relevant Designer concepts and suggest normal manual controls; and - start a fresh conversation when a long chat is no longer useful. Ask output is guidance. The canonical chart, current revision, validation results, and publish state come from SeatLayer, not from a model assertion. ## What the other Copilot workflows do Edit, create-from-requirements, and reference input are released workflows that do change the open chart. Each one lands as a reviewable revision with conflict handling and full-scope rollback, and publishing stays a separate step a person takes. A plan or reference image is an input to the chart Copilot is working on, not an authoritative capture of the room: uncertainty is disclosed and your venue data remains authoritative. SeatLayer makes no claim of universal photo understanding, vector round-tripping, or automatic venue truth. The current public state of every capability is published as [AI seating chart capabilities and roadmap](https://seatlayer.io/ai-seating-chart/). ## What Ask does not do - modify chart geometry or metadata (that is the Edit workflow, not Ask); - create a complete chart from a prompt (that is the create-from-requirements workflow, and its result is a draft you review); - infer authoritative inventory from a photograph or floor plan; - add height or improve 3D automatically; - approve, publish, or roll back a revision; or - authorize its own access, confirmation, or billing state. ## Start a new conversation Use **New conversation** when the current thread is long, has changed topics, or contains assumptions you no longer want carried forward. Starting fresh clears the conversation transcript used for subsequent answers. It does not delete or change the chart. Before starting over, copy any useful answer you need to retain. Product and audit records remain governed by their own retention rules; **New conversation** is not an account-data deletion control. ## Example prompt ```text Summarize this chart's floors, sections, capacity, accessibility information, and the three most important items I should review before publishing. Do not assume missing information and do not change the chart. ``` ## Evidence and privacy Public marketing demonstrations are deterministic recorded examples. They make no model call and contain no customer chart data. In the product, Copilot receives only the authorized context needed for the current capability. SeatLayer records authoritative action, revision, validation, cost, and terminal state from canonical product records. A model cannot create those records merely by saying that work succeeded. ## Related paths - [Designer MCP](/agents/designer-mcp): external-agent access to one chart or one workspace. - [Agent capability matrix](/agents/capabilities): authority and release states. - [Reference plan import](/designer/reference-plan-import): current non-AI source workflow. - [Publishing and versioning](/designer/publishing-and-versioning): human review and revision rules. # Buyer experience theming > Brand SeatPicker chrome, understand chart-versus-host precedence, and keep the buyer experience accessible. Source: https://docs.seatlayer.io/customization/buyer-experience/ · Markdown: https://docs.seatlayer.io/customization/buyer-experience/index.md `SeatPicker` combines the chart's published theme with optional host overrides. The chart continues to paint the seating canvas; the host theme restyles the surrounding buyer interface. ## Add a host theme ```js title="buyer/theme.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", theme: { accent: "#e5484d", accentInk: "#ffffff", background: "#0b0f1a", surface: "#161d2e", text: "#eef1f8", muted: "#9ba3b5", line: "rgba(255,255,255,.14)", fontFamily: "Inter, sans-serif", radius: 12, logoUrl: "https://cdn.example.com/tickets-logo.svg", brandName: "Example Tickets", }, }); await picker.render(); ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. All theme fields are optional. | Host field | Controls | Chart fallback | |---|---|---| | `accent` | Buttons, links, active controls, hold status | `accent` | | `accentInk` | Text and icons on the accent | `accentInk` | | `background` | Widget shell | `background` | | `surface` | Cards and panels | None | | `text` | Primary chrome text | `textColor` | | `muted` | Secondary chrome text | None | | `line` | Dividers and borders | None | | `fontFamily` | Chrome typography | `fontFamily` | | `radius` | Chrome corner radius in pixels | None | | `logoUrl` | Header logo | `logoUrl` | | `brandName` | Brand label or monogram source | `brandName` | Host values resolve to the widget's `--sl-*` CSS tokens. Do not target internal picker class names; those are implementation details and may change. ## Configure the canvas in Designer These controls belong to the chart theme and apply everywhere the chart is rendered: | Designer control | Chart field | Notes | |---|---|---| | Canvas background | `background` | Canvas only; also seeds host background | | Row labels | `rowLabelColor` | Falls back to general text color | | Seat numbers | `seatLabelColor` | Checked for readable contrast | | Selection | `selectionColor` | Hover and selected treatment | | Décor | `decorFill` | Stages and shape blocks | | Other chart text | `textColor` | Text objects | | Brand accent | `accent` | Also seeds buyer chrome | | Label font | `fontFamily` | Canvas labels | | Seat size | `seatScale` | Clamped from `0.7` to `1.6` | `SeatPicker.theme` does not recolor individual seats or categories. Category colors, accessible-seat treatment, and the colorblind-safe palette stay part of the chart and picker rendering contract. ## Logo, brand name, and attribution The host logo wins over the chart logo. Without a logo, the picker can derive a monogram from the host brand name, chart brand name, or event name. The "Powered by SeatLayer" badge follows one switch: your account's white label. It is not sold separately. Buying any credit pack turns it on for your organization, at no extra charge and for good. From then on the badge is hidden on every chart and every event, immediately: there is nothing to tick per chart and nothing to republish. Until then the badge is shown, and no client-side option can remove it, because the server applies the account setting to the chart document it serves. The `hideBadge` option is deprecated and has no effect since 0.81. It is still accepted so existing embeds keep compiling. > **Check the real buyer surface** > > Do not treat a client-side flag as proof the badge is gone. Confirm it in the > published buyer surface after your first credit-pack purchase. ## Accessible branding checklist - [ ] Accent text and icons retain sufficient contrast. - [ ] Category colors remain distinguishable in the colorblind-safe view. - [ ] Focus rings are visible on every themed surface. - [ ] Logos include enough clear space and do not carry essential instructions. - [ ] Layout works at 320 CSS pixels and 200% browser zoom. - [ ] Light and dark host pages do not make the embedded boundary disappear. - [ ] Meaning is never communicated by color alone. ## When to use each surface ### SeatPicker Use host theming for a complete buyer flow with cart, timer, states, 3D, and checkout handoff. ### SeatingChart Build your own surrounding controls. The map still uses the published chart theme and does not accept a `SeatPickerTheme`. ### Hosted iframe Use the published chart theme. The parent cannot pass host theme overrides into the framed page. See [Customization overview](/customization/overview), [localization](/customization/localization), and the [SeatPicker reference](/buyer-sdk/seat-picker). # Currency and pricing > Resolve event currency, present host prices safely, and keep buyer checkout aligned with the intended server authority. Source: https://docs.seatlayer.io/customization/currency-and-pricing/ · Markdown: https://docs.seatlayer.io/customization/currency-and-pricing/index.md Currency, buyer presentation, and SeatLayer's hold record are related but separate decisions. Define the authority your checkout uses before adding a custom price book. ## Currency precedence | Source | Purpose | |---|---| | Event or organization currency | Normal order currency; travels with event data | | SDK `currency` | Formatting fallback when event data has no currency | | `USD` | Built-in fallback | One hold or booking uses one ISO 4217 currency. SeatLayer does not convert amounts between currencies, and a locale change affects formatting rather than the amount or currency. ```js title="buyer/currency.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", currency: "EUR", // event/org data wins when present }); ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. ## Default: charge the inspected hold For the simplest and safest checkout, inspect the hold from your backend and calculate the payment from its `unitPrice`, `currency`, and `quantity`. ```ts title="server/order-total.ts" const hold = await inspectHold(eventKey, holdId); const total = hold.items.reduce( (sum, item) => sum + item.unitPrice * item.quantity, 0, ); assertOneCurrency(hold.items); await createPayment({ amount: total, currency: hold.items[0].currency }); ``` This makes the event's locked price snapshot, SeatLayer reports, and your charged amount agree. ## Host price books `SeatPicker.pricing` can make your application price-authoritative for the buyer presentation and SDK checkout handoff: ```js title="buyer/member-pricing.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", pricing: { prices: { vip: 150, standard: { base: 60, tiers: { early: 45, door: 75, }, }, }, formatter: (amount, currency) => `${amount} ${currency}`, }, onCheckout: (_hold, _seats, handoff) => { startCheckout({ holdId: handoff.holdId, displayedTotal: handoff.total, }); }, }); ``` `prices` accepts a flat category amount or a base amount plus tier overrides. `formatter` changes rendered money text. The picker applies the override to displayed totals and the `CheckoutHandoff` it gives your callback. > **The hold retains the event price** > > A host override does not rewrite the `unitPrice` stored in SeatLayer's hold. > A fresh server inspection still returns the event-authoritative snapshot. > Treat the browser handoff as context, never as trusted payment input. ## Choose one server policy ### Chart-priced checkout Ignore browser-posted totals. Inspect the hold and charge its recorded line items. This keeps SeatLayer's amount reporting aligned with payment. ### Host-priced checkout Resolve the same category and tier ids against your own server-side price book. Validate eligibility, currency, discounts, and tax there. Record both your charged amount and SeatLayer's inspected amount because SeatLayer reports will continue to reflect the event's locked snapshot. Never trust a custom amount posted from the browser, even if it matches the `CheckoutHandoff`. Send the opaque `holdId` plus your own authenticated cart or price-context identifier. Recompute the intended amount on the server. ## Prevent mismatches - Keep category keys and tier ids stable across the chart and host price book. - Version promotional and membership rules so checkout can reproduce them. - Reject mixed currencies before creating a payment. - Store the hold id, booking reference, price-book version, and charged amount. - Decide how refunds and reports reconcile when host and chart prices differ. - Test resumed and extended holds; a client refresh must not change eligibility. - Display taxes and fees separately unless they are intentionally part of unit price. ## Money units SeatLayer chart and hold examples use major units, such as `45` for 45.00. Payment providers often require minor units. Convert once on your server using a currency-aware library; do not assume every currency has two decimal places. Continue to [holds and checkout](/buyer-sdk/holds-and-checkout), [ticket tiers](/buyer-sdk/ticket-tiers), or the [complete checkout example](/examples/complete-checkout). # Localization > Choose a buyer locale, override product copy, and understand language fallback and money formatting. Source: https://docs.seatlayer.io/customization/localization/ · Markdown: https://docs.seatlayer.io/customization/localization/index.md Buyer surfaces ship in 43 languages. Pass a locale when your application already knows the buyer's language, or leave it out and let the SDK resolve it from the buyer's browser. ```js title="buyer/localization.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", locale: "de-DE", messages: { "map.fromPrice": "ab {price}", "picker.holdSeatsAndCheckout": "Weiter zur Kasse", }, }); await picker.render(); ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. ## Locale resolution SeatLayer resolves language in this order: 1. explicit `locale`; 2. `navigator.languages`, in the buyer's own priority order; 3. English. Tags match most-specific-first. `es-MX` uses Spanish and `fr-CA` uses French, and script subtags survive: `zh-Hant-TW` gets Traditional Chinese, not Simplified. Unsupported languages fall back to English. The 43 languages, by tag: `ar`, `be`, `bg`, `ca`, `cs`, `cy`, `da`, `de`, `el`, `en`, `es`, `et`, `fa`, `fi`, `fr`, `he`, `hi`, `hr`, `hu`, `id`, `it`, `ja`, `ko`, `ku`, `lt`, `lv`, `ms`, `nl`, `no`, `pl`, `pt`, `ro`, `ru`, `sk`, `sl`, `sr`, `sv`, `th`, `tr`, `uk`, `vi`, `zh-Hans` and `zh-Hant`. Arabic, Hebrew and Persian are written right to left. Kurdish (`ku`) is Kurmanji, in Latin script, and reads left to right. English is compiled into the SDK. The other 42 load on demand, one small file only for the language actually used, and `render()` resolves after it is ready. Adding languages does not grow the bundle every buyer downloads. > **Language is fixed at render** > > `locale` is read once, when `render()` runs. There is no way to change the > language of a live chart today; you would have to `destroy()` and re-create > it, which loses the buyer's selection. ## Override individual messages `messages` is a sparse map layered over the active locale. Use it for white-label product language, not for rendering untrusted user input. ```js title="buyer/copy.js" const messages = { "picker.continueToCheckout": "Confirm tickets", "picker.holdExpired": "Your reservation expired. Choose your seats again.", "picker.seatCount.one": "{count} ticket", "picker.seatCount.other": "{count} tickets", }; ``` Placeholders such as `{count}`, `{price}`, and `{label}` are interpolated by the SDK. If a key is absent, SeatLayer falls back to the selected locale, then English, then the key itself. Plural keys carry one entry per CLDR category **for the language you are overriding**, which is not always `.one` and `.other`. English and German need two; Polish needs `.one`, `.few`, `.many`, `.other`; Arabic and Welsh need all six. `Intl.PluralRules(locale).resolvedOptions().pluralCategories` gives the exact set. A category you leave out falls back to `.other`, which parses fine and reads wrong. > **Keys are matched exactly** > > An override for a key the SDK never asks for does nothing, silently. Check a > key against the shipped English dictionary before relying on it. > **Keep overrides small** > > Override the language your product owns and rely on the shipped dictionary > for standard seating behavior. A small override set is easier to review when > the SDK adds a new state. ## Common keys | Key | English purpose | |---|---| | `map.aria` | Seating-map screen-reader instructions | | `map.seatsLeft` | Remaining inventory label | | `map.fromPrice` | Starting-price label | | `picker.holdSeatsAndCheckout` | Main hold and checkout action | | `picker.continueToCheckout` | Direct-booking action | | `picker.addMinutes` | One-tap hold extension | | `picker.holdExpired` | Expired-hold recovery | | `picker.seatTaken` | Concurrent-selection conflict | | `picker.ticketPrices` | Ticket-type filter heading | | `picker.hideLimitedView` | Limited-view accessibility filter | | `picker.toggleColorblindColors` | Colorblind-safe palette control | | `picker.yourSeats` | Selection summary | | `picker.total` | Buyer total | | `picker.seatCount.one` / `.other` | Quantity-aware seat count | Use the exported SDK types and current dictionary as the exact reference when maintaining a comprehensive custom translation. ## Locale and currency Locale changes number formatting, separator placement, dates, and plural rules. It does not convert prices or choose the order currency. Currency resolves independently from event/org data, the SDK fallback, and USD. ```js title="buyer/french-euros.js" new seatlayer.SeatingChart({ container: "#chart", event: "", publicKey: "", locale: "fr", currency: "EUR", // fallback only when the event carries no currency }); ``` ## Localization review - [ ] Every explicit locale has a supported fallback. - [ ] Overrides preserve required placeholders. - [ ] Singular and plural actions read naturally. - [ ] Every plural category the language requires is present, not just two. - [ ] Long German and Finnish labels fit on a narrow screen. - [ ] Arabic, Hebrew and Persian render right-to-left inside your page. - [ ] Screen-reader instructions match the actual controls. - [ ] Price formatting is tested separately from the price source. Continue to [currency and pricing](/customization/currency-and-pricing) or the [SeatingChart reference](/buyer-sdk/seating-chart). # Customization overview > Understand which SeatLayer surface controls chart styling, buyer chrome, language, pricing presentation, and operator branding. Source: https://docs.seatlayer.io/customization/overview/ · Markdown: https://docs.seatlayer.io/customization/overview/index.md SeatLayer can feel native inside your product without forking the seating engine. Customize it in three layers: design the published chart, configure the buyer embed, and style the operator experience. ## Choose the right layer | What you want to change | Configure it in | Applies to | |---|---|---| | Seat, row, canvas, décor, font, and seat size | Designer chart theme | Every renderer of that published chart | | Picker shell, buttons, panels, logo, and brand name | `SeatPicker.theme` | One buyer embed | | Language and product copy | `locale` and `messages` | One buyer embed | | Buyer-facing price presentation | `SeatPicker.pricing` | Picker display and checkout handoff | | Operator shell | `SeatManager.theme` | One embedded control room | > **Start with the chart** > > Put the shared visual identity in the Designer. Add SDK overrides only when a > host product or tenant needs a different buyer experience. ## Precedence at a glance Unset values fall through to the next available source. | Setting | Precedence | |---|---| | Buyer accent, background, text, font, logo, brand name | Host `theme` → chart theme → built-in default | | Canvas colors, labels, décor, and seat size | Chart theme → built-in default | | Badge visibility | Account white label only: included with any credit pack, not per chart, not a host option | | Language | Explicit `locale` → stored buyer preference → browser language → English | | Message text | Host `messages` → active locale → English → message key | | Currency | Event/org data → SDK `currency` fallback → USD | | Price shown and handed to host checkout | Host `pricing` override → event price | | SeatLayer hold inspection and reports | Event price locked when the hold is created | The last two rows intentionally describe different records. A host price book can control what your application shows and charges, but it does not mutate the price stored with SeatLayer's hold. ## Canvas and chrome are different The **canvas** contains the seats, labels, sections, selection treatment, and décor. Its appearance comes from the published chart. The **chrome** contains the picker header, side panel, buttons, cards, hold timer, and checkout action. `SeatPicker.theme` can override this layer for one mount. ```js title="buyer/picker.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", theme: { accent: "#6d5dfc", accentInk: "#ffffff", surface: "#ffffff", text: "#17151f", fontFamily: "Inter, sans-serif", radius: 12, logoUrl: "https://cdn.example.com/logo.svg", brandName: "Example Tickets", }, locale: "en", messages: { "picker.holdSeats": "Continue to checkout", }, }); await picker.render(); ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. ## Hosted iframe limits The hosted `/e/:eventKey` page uses the published chart theme. A parent page cannot pass a `SeatPicker.theme` object across the iframe boundary. Use the JavaScript SDK when you need per-mount branding, custom checkout, or analytics callbacks. ## Recommended setup 1. Set reusable canvas and brand defaults in the Designer. 2. Publish and test the chart in light, dark, narrow, and zoomed layouts. 3. Add only the host overrides your product actually owns. 4. Set locale and copy at the same boundary as the rest of your user preferences. 5. Decide whether checkout charges the inspected event price or an intentional host price book, then enforce that decision on the server. 6. Test color contrast, keyboard operation, reduced motion, and 200% zoom. ## Continue - [Buyer experience theming](/customization/buyer-experience) - [Localization](/customization/localization) - [Currency and pricing](/customization/currency-and-pricing) - [SeatPicker reference](/buyer-sdk/seat-picker) - [Embed choices](/start/choose-an-integration) # Accessibility and pricing > Author semantic accommodations, wheelchair spaces, categories, and tiers that survive into buyer and reporting flows. Source: https://docs.seatlayer.io/designer/accessibility-and-pricing/ · Markdown: https://docs.seatlayer.io/designer/accessibility-and-pricing/index.md Accessible inventory needs semantic data, not only a colour or icon. SeatLayer uses a fixed accommodation taxonomy that the buyer picker can expose and filter. ## Accommodation taxonomy | Key | Buyer meaning | |---|---| | `wheelchair` | Wheelchair space | | `companion` | Companion seat | | `semi-ambulatory` | Limited-mobility seating | | `hearing` | Assistive listening | | `cart` | CART live-caption view | | `sign-language` | Sign-language view | | `plus-size` | Plus-size seat | | `lift-armrest` | Lift-up armrest | A seat can carry several values. Use only accommodations that the venue can actually provide and operational staff can honor. ## Wheelchair space versus removed inventory For a wheelchair accommodation, record the physical provision: | Value | Meaning | Inventory exists? | |---|---|---:| | `seat-present` | Accessible physical chair remains | Yes | | `no-seat` | Empty, sellable wheelchair bay | Yes | | Skipped position | Numbering gap/no inventory at that position | No | > **Do not model a wheelchair bay as skipped** > > A skipped position disappears from capacity. A `no-seat` wheelchair bay is > still one selectable and bookable inventory place. Older chart data may contain `accessible: true`; it is interpreted as wheelchair accessibility. New authoring writes the full semantic list. ## Author accessible seats 1. **Select exact positions** Choose one seat or a range within a row/section. Confirm the visible labels before applying a batch edit. 2. **Apply accommodations** Select every supported accommodation. For wheelchair inventory, choose the physical space type. 3. **Inspect buyer behavior** Preview badges, filters, confirmation copy, keyboard focus, and a colorblind-safe rendering. 4. **Validate operations** Confirm box-office and reporting surfaces preserve the same semantics. ## Price through categories and tiers Price is assigned to the category or its tiers, not to arbitrary browser input: 1. define stable category keys and buyer labels; 2. set a base price or named tier prices; 3. assign each bookable object to the intended category; 4. publish the chart and create/update the event intentionally; and 5. inspect the server hold before charging. Prices use major currency units: `45` means 45.00 in the event currency. > **Display overrides are not ledger overrides** > > An SDK host can show a fee-inclusive or promotional amount through `pricing`, > but SeatLayer's authoritative held and booked amount remains the published > category/tier price. ## Accessibility and price together An accommodation does not automatically imply a price or discount. Use a category or ticket tier for commercial policy, and preserve the accessibility metadata independently. This keeps the model explicit: - accessibility says what the place physically supports; - category says how inventory is grouped and styled; - tier says which buyer price/policy was selected; and - the inspected hold freezes the trusted combination. ## Review checklist - [ ] Each accessible place uses the correct taxonomy. - [ ] Empty wheelchair bays are `no-seat`, not skipped. - [ ] Multiple accommodations remain selectable and understandable. - [ ] Category/tier keys are stable and prices use major units. - [ ] Discount eligibility is explained in the host checkout. - [ ] Buyer UI works with keyboard, screen reader, and colorblind-safe mode. - [ ] Server payment uses inspected `unitPrice`, currency, and quantity. - [ ] Venue staff have verified the authored accommodations. Continue with [charts and objects](/designer/charts-and-objects) or [ticket tiers](/buyer-sdk/ticket-tiers). # Charts and objects > Build reusable venue geometry from rows, tables, booths, GA areas, sections, zones, shapes, and text. Source: https://docs.seatlayer.io/designer/charts-and-objects/ · Markdown: https://docs.seatlayer.io/designer/charts-and-objects/index.md A chart is reusable venue geometry plus categories, buyer presentation, and authoring metadata. It has no live availability until a published snapshot is used to create an event. ## Object types - **Row** — Parametric reserved seating defined by origin, count, spacing, direction, curve, and optional seat-level overrides. - **Table** — Round or rectangular seating that can book individual chairs, an atomic whole table, or a flexible party size within authored bounds. - **Booth** — One bookable block such as a trade-show stand, VIP box, or private suite. - **GA area** — A polygon with quantity-based capacity instead of individually presented seat positions. - **Section** — A named region that owns geometry, far-zoom navigation, and optional generated seat-plan behavior. - **Shape** — Non-bookable context such as a stage, field, wall, aisle, or decorative polygon. - **Text** — Wayfinding or descriptive copy placed independently on a floor. - **Zone** — A higher-level grouping for sections, navigation, focal points, reporting, and zone-scoped best-available selection. ## Parametric geometry Rows and sections store compact design intent rather than an arbitrary list of rendered dots. The renderer deterministically expands that intent into inventory positions. > **Edit the rule, not every seat** > > Changing row spacing, curvature, count, or a section path reflows the derived > seats. Seat-level overrides retain intentional exceptions such as labels, > accessibility, pricing, and skipped positions. This model enables consistent labels, scalable large venues, repeatable agent operations, and validation before publication. ## Custom shape outlines Plans often have shapes no rectangle can hold: a curved bar, a ring bar with an open middle, a stage with a rounded front. Draw them as a custom outline (or turn a rectangle into one with **Custom outline**), then choose **Edit outline**: - **Curved sides.** Pick a side on the map or in the list and set its **Curve** from -100 to 100. Positive bows to the left of the side's direction, the same rule sections use. **Straighten this side** and **Straighten all sides** undo it; the points you placed never move. - **Cut-outs.** **Add cut-out** draws a rectangle or round hole inside the shape: a pillar, an island, the middle of a ring bar. Cut-outs stay inside the outline and never overlap, and a curve that would cross one is refused. Curves and cut-outs show on the Designer canvas, the buyer map, thumbnails and in 3D, where a cut-out goes right through the solid. Agents can set the curves with `update_shapes` `edgeCurvature` (one value per side); cut-outs are drawn in the Designer only, because the tools never accept coordinates. ## Tables and inventory identity | Mode | Buyer selects | Inventory behavior | |---|---|---| | Individual | One or more chairs | Each chair has its own identity | | Whole table | The table | All capacity is one atomic unit | | Flexible occupancy | A party size | Table remains exclusive to one buyer within min/max bounds | New events preserve the table mode and capacity from the chart snapshot they were created from. Changing the chart later does not reinterpret an existing event's inventory. ### Standing areas sold as one box A standing area normally sells per person: each buyer picks how many places. Its **Selling** choice can instead sell it as one box, like a table: **Whole area** (one party takes all of its capacity) or **Party size** (one party picks a number between the fewest and most guests you set). A box is one booking whose label is the area's technical label, so that label must be unique. An area sold to one party holds at most 100 guests. Buyers are also limited by the event's "tickets per order" setting, which applies to whole tables too. Events created before this existed keep selling the area per person, and an area made with Join Areas always sells per person. **Wheelchair places** marks that many of the area's places for wheelchair users. They are part of the capacity; buyers who filter for wheelchair access can pick them, and the map shows the wheelchair symbol next to the capacity. ### Chair numbers A table's **Labels** line sets how buyers read its chairs: the first number (0–999), the direction (clockwise, or anticlockwise from the last chair), and the format — `T1-1, T1-2 …`, `1, 2, 3 …` or `A, B, C …`. A gap keeps its number, as a row's seats do. Numbering is display only: every chair keeps its booking label (`T1-1`, `T1-2` …), so changing it never moves a ticket, hold or booking. A chair's own label, set while editing chairs, still wins. ## Buyer details on tables, booths and standing areas | Setting | Tables | Booths | Standing areas | |---|---|---|---| | Name on map (show or hide, size 8–24 pt) | ✓ | ✓ | ✓ (a hidden name keeps the capacity line) | | Entrance, up to 40 characters | ✓ | ✓ | ✓ | | Booth type: inline, corner, peninsula, island (1–4 open sides) | | ✓ | | | Buyer note, up to 160 characters | per chair | ✓ | | Without its own entrance, an object uses the entrance of the section it sits in. The same settings are available to agents through `update_tables`, `update_booths` and `update_ga_areas` (`entrance`, `nameOnMap`, `nameSize`, `chairNumbering`, `boothType`, `note`, and for areas `bookingMode`, `minOccupancy`, `maxOccupancy`, `wheelchairPlaces`). ## Aisle seats Buyers see **Aisle seat** on the card of a seat that sits on an aisle. Rows mark them for you: the first and last seat of every row, and the seats on both sides of a removed seat. In a segmented row, a junction set to **continuous** is not an aisle; a **break** is. Tables, booths and standing areas have no aisle seats. A row's automatic ends can be replaced, for example when one end of the row meets a wall. In the Designer, a row's **Layout** has **Aisle seats**: **Start**, **End**, **Both** or **None**, with a note naming the row's aisle seats; **Use automatic** goes back to the rule. Several selected rows show **Mixed** when they differ, and one choice sets them all. Agents use `update_rows` `aisleSeats` (`start`, `end`, `both` or `none`); `null` goes back to automatic. Seats beside a removed seat stay aisle seats either way. `POST /v1/events/:eventKey/best-available` takes `preferAisle: true` to prefer a group that includes an aisle seat. This is separate from the **Aisle transfer** accessibility type, which marks a seat for moving across from a wheelchair. ## Categories, tiers, and price Every bookable object has a category: ```ts interface Category { key: string; label: string; color: string; price?: number; tiers?: Array<{ id: string; name: string; price: number; }>; } ``` - `key` is the stable integration identity. - `label` is buyer-facing copy. - `color` drives the map legend and seat styling. - `price` and tier prices use major currency units. > **Authoritative for holds and reporting** > > SeatLayer does not process payment, but category/tier price is stamped into > held items and booked records. Charge from the server-inspected hold. A > browser `pricing` override changes display only. See [ticket tiers](/buyer-sdk/ticket-tiers) for the buyer-to-server flow. ## Sections, zones, and focal points Sections group rows and other objects for navigation and venue structure. Zones group sections one level higher, for example Lower Bowl, Upper Bowl, and Floor. Each used zone should have an explicit focal point aimed at its stage, field, or performance area. That focal influences zone-scoped best-available ranking and generated view-from-seat perspective; it does not change inventory identity. Small venues can omit zones and work directly with sections or bare objects. ## Three image jobs | Asset | Purpose | Buyer receives it? | |---|---|---:| | Private trace reference (`referenceImage`) | Authoring guide for PNG/JPEG/WebP or a selected PDF page | No | | Imported SVG/DXF primitives | Sanitized shapes converted into editable chart objects | As resulting shapes | | Buyer background (`backgroundImage`) | Optional presentation layer behind the buyer map | Yes | Private reference bytes and provenance are stripped from the buyer document. SVG/DXF import supports a bounded primitive subset, not arbitrary scripts, external resources, or full CAD fidelity. ## Panorama manifest jobs Premium Panorama Manifests customers can start a private, revision-pinned CSV or JSON ingest from a trusted server with `createPanoramaManifestJob`, then poll the source-redacted result with `retrievePanoramaManifestJob`. Each row targets a stable venue, floor, section, row, or seat identity; URL inputs are fetched and transformed into generated preview and display tiers without exposing the original source URL in status responses. Job creation never changes the chart and never publishes media. A signed-in human must review a fully ready job in the dashboard and explicitly apply it to the still-matching draft revision. Listing jobs, retrying, cancelling, applying, and publishing are deliberately absent from server-key and MCP surfaces. ## Authoring checklist - [ ] Every bookable identity and visible label is unique where required. - [ ] Categories use stable keys and intended major-unit prices. - [ ] Rows/sections use geometry rules rather than manual seat duplication. - [ ] Table booking modes match how the venue sells them. - [ ] GA capacity and flexible table bounds are realistic. - [ ] Sections and zones have readable names and explicit focal points. - [ ] Private trace references are not confused with buyer backgrounds. - [ ] Validation passes before publication. Continue with [accessibility and pricing](/designer/accessibility-and-pricing), [templates](/designer/templates), or [publishing and versioning](/designer/publishing-and-versioning). # Multi-floor venues > Author one chart and event across floors, keep labels unique, and synchronize custom 2D/3D floor controls. Source: https://docs.seatlayer.io/designer/multi-floor-venues/ · Markdown: https://docs.seatlayer.io/designer/multi-floor-venues/index.md One chart can contain several floors, for example Stalls, Dress Circle, and Upper Circle. They publish together and share one event inventory and checkout flow. ## Author floors 1. **Add and name each floor** Use the Designer floor rail. Give levels buyer-readable names and stable ids. 2. **Author independent geometry** Each floor has its own objects, focal point, optional base height, private trace reference, and optional buyer background. 3. **Keep inventory identity global** Seat and grouped-object labels must remain unique across the entire chart, not only within a floor. 4. **Review together** Test floor switching, far-zoom navigation, 3D venue view, realtime state, and one hold containing inventory from more than one level. 5. **Publish once** The chart snapshot and resulting event cover every floor. > **Logical floors and physical height are separate** > > Floors isolate authoring and buyer navigation. Physical 3D elevation comes > from authored floor base heights and section geometry; do not assume the floor > list alone creates correct architectural stacking. ## Buyer behavior `SeatPicker` provides floor controls for multi-floor charts and the 3D venue view can present the levels together. Live status is reapplied whenever the buyer switches floor or view. With a custom `SeatingChart` UI, use: ```js title="browser/floor-switcher.js" const chart = new seatlayer.SeatingChart({ container: "#chart", event: "", publicKey: "", onDeckTap: (floorId) => { setActiveFloorTab(floorId); }, }); await chart.render(); for (const floor of chart.getFloors()) { addFloorTab({ label: floor.name, onSelect: () => chart.setFloor(floor.id), }); } ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. - `getFloors()` returns `{ id, name }[]` after `render()`. - `setFloor(floorId)` changes the 2D floor. - `onDeckTap(floorId)` synchronizes host UI after a 3D deck interaction. - Calling `setFloor()` on a single-floor chart warns and has no effect. ## Inventory and checkout A buyer may select across floors if your product allows it. The hold, inspected items, payment, and atomic booking remain one transaction. Show floor/section context in the cart so identical-looking row or seat labels are understandable, even though validation should still keep booking identities unique. ## 3D authoring quality - Set realistic `baseHeightM` or section elevations where physical stacking is intended. - Give every floor/zone a focal point toward the relevant stage or field. - Check stairs, overhangs, and sightline expectations in buyer preview. - Treat 3D as orientation evidence, not an accessibility-route certification. - Verify acceptable behavior when WebGL2 is unavailable; the 2D floor switcher remains essential. ## Verification checklist - [ ] Labels are unique across all floors. - [ ] Every floor has a meaningful name and focal point. - [ ] Physical elevation is authored, not inferred from order alone. - [ ] Custom tabs and 3D deck taps stay synchronized. - [ ] Realtime state remains correct after floor changes. - [ ] A cross-floor hold books atomically in test mode. - [ ] Mobile and keyboard floor navigation pass. - [ ] The 2D experience is complete without WebGL2. Continue with the [3D buyer view](/buyer-sdk/3d-view), [charts and objects](/designer/charts-and-objects), or [publishing and versioning](/designer/publishing-and-versioning). # Publishing and versioning > Understand draft autosave, published snapshots, unpublished changes, and event-pinned inventory. Source: https://docs.seatlayer.io/designer/publishing-and-versioning/ · Markdown: https://docs.seatlayer.io/designer/publishing-and-versioning/index.md Publishing creates a stable chart snapshot that can be used for events. Draft editing and live event inventory remain deliberately separate. ## Lifecycle | Status | Meaning | Can create an event? | |---|---|---:| | `draft` | Autosaved working document, never published | No | | `published` | Draft and latest published snapshot agree | Yes | | `published_draft` | A published snapshot exists, with newer draft edits | Yes, from the published snapshot | ## Events pin a snapshot > **A chart is not a live pointer** > > Event creation materializes inventory from the chart's published snapshot at > that moment. Later chart edits or republishing do not silently move seats in > an event that is already selling. This protects seat identity, capacity, booking mode, labels, and buyer trust. Changing an existing event's chart is a separate inventory migration decision with its own safety rules. ## Publish safely 1. **Validate the draft** Resolve duplicate labels, malformed geometry, missing categories/focals, and accessibility issues. 2. **Review buyer evidence** Preview 2D, 3D where enabled, mobile, tiers, GA, tables, and accessible inventory. 3. **Publish** Create the immutable snapshot. Embedded Designer sessions require `canPublish: true`; otherwise publication returns `403`. 4. **Create or update events intentionally** New events use the new snapshot. Existing events remain pinned until an explicit supported refresh/migration is chosen. ## Republish versus duplicate - **Republish** updates the source chart's published snapshot for future use. - **Duplicate** creates an independent draft chart, even when the source was published. - **Create event** materializes inventory from the selected published snapshot. - **Edit event inventory** is an operational action, not chart draft editing. ## Platform automation Platforms can follow the same lifecycle with server credentials: ```text create or duplicate chart → update the versioned draft → validate → POST /v1/charts/:id/publish → POST /v1/events ``` Use an `Idempotency-Key` for provisioning mutations so retries do not create duplicate resources. Keep the chart id, published version/evidence, and event key on your tenant records. ## Change-management checklist - [ ] Draft autosave is not treated as publication. - [ ] Validation and buyer preview pass before publishing. - [ ] The publisher has explicit permission. - [ ] Teams understand which existing events remain pinned. - [ ] Automation uses idempotency keys and records resource ids. - [ ] Duplicate charts are reviewed and published independently. - [ ] Any existing-event refresh has a capacity/identity migration plan. Continue with [platform integrations](/integrations/platforms) or [going live](/start/going-live). # Reference plan import > Turn a floor plan image or PDF into a seating chart. Click the plan to add whole blocks of seats, draw rows over it, or trace sections by hand. Source: https://docs.seatlayer.io/designer/reference-plan-import/ · Markdown: https://docs.seatlayer.io/designer/reference-plan-import/index.md Reference plans help match real venue structure, but the source is evidence, not automatic sellable inventory. Seat labels, counts, accessibility, and commercial meaning still require authoritative confirmation. ## Choose the right import path - **Private raster trace** — Upload PNG, JPEG, or WebP as an authoring layer. Position, crop, rotate, scale, calibrate, lock, and hide it while drawing. - **PDF page** — Select one page from a PDF up to 15 MB and 100 pages. SeatLayer rasterizes that page into the private trace layer. - **Editable SVG/DXF** — Import supported sanitized primitives as ordinary editable `Shape` objects in one undoable operation. - **Buyer background** — Add an independent URL-based aesthetic layer when buyers should see venue artwork behind inventory. > **Private trace is never the buyer background** > > Reference bytes, source metadata, and authoring provenance are removed from > the buyer document. Only an explicitly configured `backgroundImage` can reach > the buyer renderer. ## Build on top of your plan Once a reference plan is loaded and shown, the **From image** panel offers three ways to build on top of it, quickest first. The chart must be saved before SeatLayer can read the plan, because reading runs on the server. ### Click the plan Choose **Seats only** or **Sections with seats**, then click one printed seat in each block of the plan. - **Seats only** adds every printed seat in that block, with rows lettered from the front. Use it for a small hall. - **Sections with seats** adds the section outline and every seat inside it. Use it for a large venue, where one section per block is easier to price and sell. - Click a stage, bar or other area with no seats to add it as a shape. The panel counts what you have added. **Undo** removes the last one, **Esc** stops, and **Done clicking** ends the run. On a printed theatre plan with 23 sections and 2,178 seats, clicking each section once added all 23 sections and all 2,178 seats. ### Draw rows over the plan Choose **Click one seat to start** and click one printed seat so SeatLayer learns the seat size. Then drag from the first seat of a row to the last. The seat count shows before you let go, and the row gets exactly the printed seats. Rows drawn earlier keep their spacing. ### Draw a section by hand Choose **Draw corners** and click the corners of a section on the plan. Use this when a click cannot find the section's border, for example when the print has no closed outline. ### More plan tools **More plan tools** holds the slower routes for plans the one-click read cannot handle: **Read the whole plan at once** (find every section and seat, then review before anything is added), **Find all sections**, **Measure seats by hand** (mark neighbouring seats), **Mark a curved row**, **Draw rows without measuring** and **Fill empty sections by capacity**. Whichever route you use, the result is ordinary chart objects. Check counts and labels against the venue's own records before you publish; the source policy below still applies. ## Raster/PDF tracing workflow 1. **Upload and select evidence** For a PDF, choose the page that contains the authoritative floor plan. Keep separate supplemental label or inventory sources distinct. 2. **Calibrate** Mark two known points and enter their real distance so spacing, row gaps, and section capacity can use a physical scale. 3. **Align and lock** Set crop, width, rotation, opacity, and layer placement. Lock the reference before detailed authoring. 4. **Author semantic geometry** Draw floors, sections, aisles, rows, tables, booths, GA, shapes, and labels. Use the reference as visual evidence, not a source of unverified identity. 5. **Compare and review** Toggle the source, render buyer previews, check alignment, and confirm capacity/labels against authoritative venue records. ## Vector import boundary SVG and ASCII DXF pass through a bounded parser. Supported primitives become editable shapes; arbitrary scripts, external URLs/resources, embedded objects, full CAD fidelity, and SVG export are outside the contract. Inspect imported layers for: - unexpected scale or units; - duplicated or microscopic primitives; - fills that hide bookable geometry; - text that should become semantic labels; and - decorative lines that should not create inventory. ## Source-of-truth policy | Fact | Can image alone authorize it? | Required confirmation | |---|---:|---| | Approximate section boundary | Evidence only | Visual/human review | | Seat count | No | Venue inventory or explicit owner confirmation | | Seat label | No | Authoritative labeling plan | | Accessibility | No | Venue/operator confirmation | | Category and price | No | Commercial configuration | | Aisle/exit context | Evidence only | Safety/venue review | ## Agent-assisted workflow The remote Designer MCP can let an authorized external agent inspect private overview/tile evidence, submit semantic regions, invoke deterministic compilers, render diffs, and prepare revision-bound review evidence. It does not provide a hosted vision model, and it cannot turn uncertain pixels into approved inventory by itself. See [Designer MCP](/agents/designer-mcp). ## Verification checklist - [ ] The correct source and PDF page are selected. - [ ] Physical scale is calibrated from a known distance. - [ ] Private evidence is distinct from buyer presentation. - [ ] Every seat count and label has an authoritative source. - [ ] Accessibility and pricing are explicitly confirmed. - [ ] Vector imports contain only intended editable primitives. - [ ] Buyer preview never exposes the private reference. - [ ] Comparison and validation pass on every floor. Continue with [sections and seat plans](/designer/sections-and-seat-plans) or [Designer MCP](/agents/designer-mcp). # Sections and seat plans > Draw venue regions, generate containment-safe rows to an exact capacity, and review what a refill preserves. Source: https://docs.seatlayer.io/designer/sections-and-seat-plans/ · Markdown: https://docs.seatlayer.io/designer/sections-and-seat-plans/index.md A section is both buyer navigation geometry and an authoring surface. After drawing its outline, the Designer can deterministically plan rows inside it for a target capacity. > **Preview never creates inventory** > > The planner first returns derived rows, capacity, and diagnostics. Seats enter > the chart only when you apply the preview, so cancelling cannot leave phantom > inventory. ## Plan one section 1. **Draw and name the section** Trace the physical boundary, assign its stable technical label and buyer display label, then connect it to a zone when appropriate. 2. **Set its focal context** Use the chart/zone focal for curved rows that face the performance area, or choose horizontal orientation for straight plan-style rows. 3. **Enter the target capacity** Choose seat spacing, row gap, edge inset, and numbering direction. Physical measurements use the chart's calibrated scale when available. 4. **Review diagnostics** Confirm planned capacity equals target capacity, inspect row count and curvature, and resolve any containment or maximum-capacity failure. 5. **Apply once** The generated rows become ordinary editable chart objects in one history operation. ## Planner inputs | Input | Purpose | |---|---| | Target capacity | Exact number of generated seats | | Seat spacing | Distance between centers in one row | | Row gap | Distance between row tracks | | Edge inset | Clearance from the section outline | | Orientation `focal-tangent` | Rows follow the focal geometry where possible | | Orientation `horizontal` | Rows use a fixed horizontal axis | | Numbering `ltr` | Each row numbers left to right | | Numbering `rtl` | Each row numbers right to left | | Numbering `serpentine` | Alternates direction by row | Concave or multipart section geometry can produce several safe row segments without drawing seats through an aisle, opening, or outside the section. ## Batch planning Select several sections to plan them together. Each section keeps its own target capacity, and the batch applies only when every preview is valid and the summed planned capacity equals the summed target. Use batch mode for repeated stadium blocks or balcony regions that share spacing and numbering rules. Review each physical section, not only the aggregate. ## Refill an existing section Refill rebuilds the generated rows while attempting to carry intentional seat metadata to the new plan. The preview reports: - how many settings can be carried; - which categories, accessibility values, display names, and seat-view photos will move; - which prior seats cannot be mapped; and - the reason each setting would be dropped. > **Review every dropped setting** > > A geometrically valid refill can still lose business-critical meaning if an > old seat no longer has a safe counterpart. Resolve the drop list before > applying. ## Repeat, mirror and split Select a section and open **Repeat and split** in its panel. There are four actions. Each one opens a preview first: nothing changes until you press Apply, Cancel leaves the chart as it was, and one Undo takes the whole result back. **How repeat and split work** in the same place shows the four side by side. ### Circular copies Use it for stands around a stage or pitch: draw one stand, then copy it round. 1. **Open Circular copies** Choose the total number of sections, including the one you drew, and how far round they go: a full circle, three quarters, half or a quarter. 2. **Set direction, spacing and names** Pick clockwise or counter-clockwise, the minimum clear aisle between copies, and a label prefix and start number for the new sections. 3. **Check the preview, then Apply** The preview lists the new sections and their seat counts. Copies turn around the zone's focal point, otherwise the floor's. Set a focal point first; without one the preview can't be made. Keep **Repeat seats and contents** on to copy the rows, tables, booths and standing areas too. Apply stays off while copies overlap an existing section or each other, or while the aisle between them is narrower than the minimum you set. ### Mirrored copy Use it for the opposite stand: one flipped copy of the section, with everything inside it. 1. **Open Mirrored copy** Choose left ↔ right or top ↔ bottom. 2. **Choose what to mirror around** The zone's focal point if the section has one, otherwise the floor's. You can pick either one directly. 3. **Check where the copy lands, then Apply** The new section gets the next unused name, so it never clashes with an existing one. Like circular copies, a mirror needs a focal point on the zone or floor. ### Divide with aisles Use it to split one large outline into several sections with walkways between them, before any seats go in. 1. **Open Divide with aisles** Choose how to divide: around the focal point, left to right, or top to bottom. 2. **Set the count and the aisle** Choose the number of sections (2 to 60), the clear aisle width in metres, and a label prefix and start number. 3. **Check the preview, then Apply** The original outline is replaced by the new sections. > **When Divide is not available** > > Only an empty section can be divided: once it holds rows, tables, booths or > a standing area, Divide shows "Only before seats are added". Divide first, > then fill each new section. It also needs a single plain outline: convert > an exact, smoothed or traced boundary first, split linked parts apart, and > fix any overlap with another section. ### Curve rows to fit Use it to bend the rows already in a section so they face the focal point or follow the section's curved edge. It shows only when the section has rows. 1. **Open Curve rows to fit** Fit against the floor focal point or the section outline, and set the strength. 2. **Check which rows move** The preview lists each row, how far it moves, and any row that is kept as drawn. 3. **Apply** Seat names, categories and seat settings are kept. Rows you drew as a custom path are not changed. ## Published safe mode Published booking identities are protected. Safe mode blocks destructive changes such as deleting published seats, changing technical labels, shrinking GA identity, or changing a published booking mode unless the host grants an explicit supported exception. Additive draft geometry is safer than replacing published identities. If a live event needs a new chart revision, use the supported event refresh flow and verify that held/booked labels remain present. ## Review checklist - [ ] Section boundary matches the real sellable region. - [ ] Zone and focal point are explicit where used. - [ ] Target capacity is physically plausible. - [ ] Preview capacity exactly matches target. - [ ] No row crosses an aisle, opening, or section edge. - [ ] Row/seat numbering is readable and globally unique. - [ ] Batch mode passes per-section as well as aggregate review. - [ ] Refill carry-over and every dropped setting are approved. - [ ] Published identity protections remain intact. - [ ] Buyer 2D/3D preview and accessibility semantics pass. Continue with [reference plan import](/designer/reference-plan-import), [charts and objects](/designer/charts-and-objects), or [publishing and versioning](/designer/publishing-and-versioning). # Templates > Start from capacity-adjustable venue families, curated flagships, or insertable blocks, then own the resulting draft. Source: https://docs.seatlayer.io/designer/templates/ · Markdown: https://docs.seatlayer.io/designer/templates/index.md Templates accelerate authoring without creating a permanent dependency on the catalog. Instantiating one creates a normal editable draft in your workspace. The public [seating chart templates](https://seatlayer.io/templates/) hub previews the same families before you sign in. ## Catalog model - **Venue families** — Eleven archetypes such as curved theatre, banquet rounds, classroom, grandstand, festival GA, and expo hall. A capacity control regenerates coherent geometry between authored bounds. - **Curated flagships** — Eighteen fixed-geometry examples for arenas, theatres, stadiums, opera, cinema, exhibitions, worship, and other complex venues. - **Insertable blocks** — Reusable balcony, row, GA, table, and box groups that can be added to an existing chart as one undoable operation. The create-chart gallery may expose a smaller published subset while catalog entries are curated. ## Catalog discovery and revision identity Discover currently published templates without a credential through [`GET /pub/templates`](https://api.seatlayer.io/pub/templates). Its `id` is the catalog identifier to pass as `templateId` when instantiating; do not derive that value from the human-readable `slug`. Use [`GET /pub/templates/:slug`](https://api.seatlayer.io/pub/templates) for the selected entry's active `version.number` and content `version.sha256`. Catalog discovery exposes only published templates and their active revision; it does not provide a historical-version listing. A known published revision's chart artifact remains addressable at `/pub/templates/:slug/versions/:version/chart`. For reproducible provisioning, persist the catalog `id` together with the returned version and SHA-256, then submit both pins to instantiation. The new chart records those same source values and is independent of later catalog activation changes. ## Capacity-adjustable families Family geometry is rebuilt, not visually stretched. The compiler adjusts rows, tables, aisles, sections, and labels together. S/M/L/XL chips are anchors on a continuous supported range. Use this when the venue shape matches an archetype but the capacity varies between customers or events. ## Flagships as working references Flagships demonstrate combinations that are hard to infer from an empty canvas: - sections and zones with focal points; - multi-floor theatres and stadium decks; - category and tier pricing; - GA, booths, tables, and mixed inventory; - far-zoom navigation and 3D structure; and - large-chart performance patterns. Duplicate and simplify a flagship when it is closer to the real venue than a family. ## Instantiate and publish 1. **Choose a starting point** Match venue semantics first; a similar capacity with the wrong booking model creates more rework. 2. **Instantiate** SeatLayer copies the catalog revision into a new workspace draft. 3. **Make it authoritative** Rename sections, verify labels, set categories/prices, confirm accessibility, and adjust capacity/geometry. 4. **Validate and publish** Review buyer evidence and create a published snapshot. 5. **Create the event** Materialize live/test inventory from that snapshot. ## Platform instantiation **POST /v1/templates/:templateId/instantiate**. Authentication: Secret key ```bash title="instantiate-template.sh" curl -X POST \ "https://api.seatlayer.io/v1/templates/tpl_example/instantiate" \ -H "Authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "Idempotency-Key: tenant-42-mainstage" \ -H "Content-Type: application/json" \ -d '{ "name": "Main Stage", "workspaceId": "ws_tenant_42" }' ``` Pin `version` and `sha256` when reproducibility matters. An optional `editedDoc` can instantiate a validated customized variant. Record the returned chart id, then publish and create the event through the normal lifecycle. ## Insertable block safety Inserted blocks continue row-letter sequences and advance table/booth labels to avoid collisions. Still run full chart validation: surrounding objects, custom labels, sections, and accessibility overrides can introduce conflicts the block cannot predict. ## Template checklist - [ ] The starting template matches the selling model. - [ ] Generated capacity is within the real venue's safe limit. - [ ] Labels, aisles, exits, accessibility, and sightlines are reviewed. - [ ] Category/tier prices are replaced with real values. - [ ] The chart has no assumed ongoing link to the catalog. - [ ] Provisioning uses a stable idempotency key and optional pinned revision. - [ ] Buyer preview and validation pass before publication. Continue with [charts and objects](/designer/charts-and-objects) or [publishing and versioning](/designer/publishing-and-versioning). # Complete checkout > A framework-neutral browser and Node server example covering hold inspection, payment, booking, retries, and conflicts. Source: https://docs.seatlayer.io/examples/complete-checkout/ · Markdown: https://docs.seatlayer.io/examples/complete-checkout/index.md This example shows the smallest production-shaped Platform/SDK loop. Adapt the routing and payment calls to your framework; keep the trust boundary and state transitions intact. Your application owns the commercial Order and creates, delivers, recovers, and scans its own tickets. ## Browser The browser renders inventory, creates a hold, and sends only the hold id to your backend. ```html title="checkout.html"
``` Register this page's exact origin as an Embed domain. The public key starts one direct Public-sale bootstrap; SeatLayer returns the chart and compact inventory status together, and the SDK keeps the resulting bearer in memory. For a login, presale, partner, or channel audience, replace `publicKey` with a scoped `buyerAccessTokenProvider` from your authenticated backend. The buyer never posts a price, category, or trusted seat allocation. Those values are resolved from the hold by the server. ## Server > **This server helper is private-audience only** > > Do not mint a buyer-access token for the ordinary Public sale shown in the > browser example above. That page passes `publicKey` and starts directly with > SeatLayer. Add the following helper only when an authenticated buyer must see > login-, presale-, partner-, or channel-scoped inventory. ```js title="server/seatlayer.js" const SEATLAYER_API = "https://api.seatlayer.io"; async function seatlayer(path, init = {}) { const response = await fetch(`${SEATLAYER_API}${path}`, { ...init, headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", ...init.headers, }, }); return response; } export async function mintPrivateBuyerAccess(eventKey, allowedOrigin) { const response = await seatlayer( `/v1/events/${encodeURIComponent(eventKey)}/buyer-access-sessions`, { method: "POST", body: JSON.stringify({ // A private agency allocation. Both fields are deliberate: SeatLayer // has no default for includePublic and an empty scope is rejected. channelIds: ["chn_agency_private"], includePublic: false, // One exact HTTPS origin: the origin your checkout page is served // from. SeatLayer checks it on every request the token makes. allowedOrigin, }), }, ); if (!response.ok) { throw new Error(`SeatLayer buyer access mint failed: ${response.status}`); } const session = await response.json(); // Keep sessionId for audit/revocation; hand only these two to the browser. return { token: session.token, expiresAt: session.expiresAt }; } export async function inspectHold(eventKey, holdId) { const response = await seatlayer( `/v1/events/${encodeURIComponent(eventKey)}/holds/${encodeURIComponent(holdId)}`, ); if (response.status === 404 || response.status === 409) return null; if (!response.ok) { throw new Error(`SeatLayer hold inspection failed: ${response.status}`); } return response.json(); } export async function bookHold(eventKey, holdId, bookingRef) { return seatlayer( `/v1/events/${encodeURIComponent(eventKey)}/book`, { method: "POST", body: JSON.stringify({ holdId, bookingRef }), }, ); } ``` ```js title="server/buyer-access-route.js" // Private-audience alternative to the publicKey bootstrap above. Gate this // route according to who may see the login, presale, partner, or channel // inventory. The token cannot book; booking stays with your server. app.post("/api/events/:eventId/seat-access", async (req, res) => { const event = await db.events.findById(req.params.eventId); if (!event?.seatlayerEventKey) { return res.status(404).json({ error: "event_not_found" }); } // Bind the token to the page's origin. Never trust a body-supplied origin; // derive it from the request (Origin header) or your configured site URL. const allowedOrigin = req.get("origin") ?? process.env.SHOP_ORIGIN; const session = await mintPrivateBuyerAccess( event.seatlayerEventKey, allowedOrigin, ); res.set("cache-control", "no-store"); res.json(session); }); ``` ```js title="server/checkout-route.js" app.post("/api/events/:eventId/checkout/seats", async (req, res) => { const holdId = typeof req.body?.holdId === "string" ? req.body.holdId : ""; if (!holdId) return res.status(400).json({ error: "holdId_required" }); const event = await loadBusinessEvent(req.params.eventId); const hold = await inspectHold(event.seatlayerKey, holdId); if (!hold || hold.status !== "active") { return res.status(409).json({ error: "hold_not_active" }); } // Create from trusted server-returned items. const order = await orders.create({ customerId: req.user.id, eventId: event.id, items: hold.items, currency: hold.items[0]?.currency, amount: hold.items.reduce( (total, item) => total + item.unitPrice * item.quantity, 0, ), }); // Prefer authorization before booking when your provider supports it. const payment = await payments.authorize({ orderId: order.id, amount: order.amount, currency: order.currency, }); const booking = await bookHold( event.seatlayerKey, holdId, order.id, // stable idempotency reference ); if (booking.status === 409) { await payments.void(payment.id); await orders.markInventoryConflict(order.id); return res.status(409).json({ error: "inventory_conflict" }); } if (!booking.ok) { // The booking result may be unknown after a network/server failure. // Queue a retry with the SAME order.id; do not issue a new bookingRef. await retries.enqueueSeatLayerBooking({ eventKey: event.seatlayerKey, holdId, bookingRef: order.id, paymentId: payment.id, }); return res.status(202).json({ redirectUrl: `/orders/${order.id}/processing`, }); } await payments.capture(payment.id); await orders.markBooked(order.id, await booking.json()); await tickets.issueAndDeliver(order.id); return res.json({ redirectUrl: `/orders/${order.id}/confirmation`, }); }); ``` > **Your route needs its normal application security** > > Authenticate the customer, authorize access to the business event, validate request size/type, rate-limit abuse, and apply CSRF protection when your session model requires it. ## Retry worker A timeout is an unknown result, not a failed result. Retry with the same order id: ```js title="server/retry-booking.js" async function retrySeatLayerBooking(job) { const response = await bookHold( job.eventKey, job.holdId, job.bookingRef, ); if (response.ok) { await payments.capture(job.paymentId); await orders.markBooked(job.bookingRef, await response.json()); return; } if (response.status === 409) { await payments.void(job.paymentId); await orders.markInventoryConflict(job.bookingRef); return; } throw new Error(`Retryable SeatLayer error: ${response.status}`); } ``` ## What to adapt - Resolve the SeatLayer event key from your own trusted event record, not from arbitrary browser input. - Expose only the matching publishable key to the browser, register the exact Embed origin, and never expose `SEATLAYER_SECRET_KEY`. - Use your actual authentication and authorization middleware. - Use minor currency units if your payment provider requires them. - Keep order creation idempotent if the browser repeats the checkout POST. - Follow your provider's authorization, capture, void, and refund semantics. - Create and deliver tickets only after the inventory booking is confirmed. - Keep ticket recovery, scanning, and attendance in your platform. - Add a signed webhook receiver for reconciliation. ## Tests worth keeping | Test | Expected result | |---|---| | Valid active hold | One order, one booking, payment captured | | Repeated checkout POST | Existing order/reference reused | | Hold expired before inspection | `409`, no payment attempted | | Booking conflict after authorization | Payment voided, order recoverable | | Booking response times out | Processing state, same reference retried | | Retry after successful first booking | Idempotent success | | Browser posts fake price | Ignored; hold items determine amount | | Missing secret key | Safe server error, no browser secret | Read [integration best practices](/integrations/best-practices) before adapting this to production. # Integration best practices > Security, inventory, payments, retries, accessibility, observability, and testing practices for production SeatLayer integrations. Source: https://docs.seatlayer.io/integrations/best-practices/ · Markdown: https://docs.seatlayer.io/integrations/best-practices/index.md Good reserved-seating UX depends on more than rendering a chart. The integration must preserve a clear trust boundary, behave well while inventory changes, and give buyers a useful recovery path when a hold or payment fails. ## Keep one trust boundary | Browser may | Server must | |---|---| | Render public event inventory | Store `SEATLAYER_SECRET_KEY` | | Let the buyer select and hold | Inspect the hold and its authoritative items | | Send your backend an opaque `holdId` | Calculate the order total | | Display availability and pricing | Take payment and confirm the booking | > **Never trust checkout data from the browser** > > Labels and prices in the browser are display data. Resolve the hold on your server and build the charge from the returned items. ## Use an explicit checkout state machine A practical checkout has these states: 1. `selecting`: no hold exists. 2. `held`: inventory is protected until `expiresAt`. 3. `paying`: payment is in flight; keep the same hold and order. 4. `booked`: SeatLayer confirmed the sale. 5. `recoverable_error`: hold expired, inventory conflicted, or payment failed. Do not create a new order or `bookingRef` merely because a network request timed out. Retry the original operation with the original reference. ## Make booking idempotent Use your immutable order or payment identifier as `bookingRef`. ```js title="server/book-with-retry.js" async function bookHeldSeats({ eventKey, holdId, orderId }) { const response = await fetch( `https://api.seatlayer.io/v1/events/${eventKey}/book`, { method: "POST", headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ holdId, bookingRef: orderId }), }, ); if (response.ok) return response.json(); if (response.status === 409) return { conflict: true }; throw new Error(`SeatLayer booking failed: ${response.status}`); } ``` The same `bookingRef` is safe to replay. A different reference for the same sold seats is a real conflict. ## Decide the payment sequence Most integrations inspect the hold, authorize or capture payment, then book: ```text hold → inspect trusted items → authorize payment → book → capture/settle ``` If your payment provider captures before booking, define an automatic void/refund path for a booking conflict. If it supports authorization and capture, authorizing before booking reduces refund noise. > **Do not let a request timeout decide the order state** > > Re-inspect your order and retry booking with the same `bookingRef`. A timeout does not prove that the first booking call failed. ## Treat `409` as a product path Inventory conflicts are expected under load. When booking returns `409`: - do not partially confirm the order; - void or refund payment according to your payment sequence; - explain that the reservation expired or changed; - reopen the picker with the previous intent where possible; and - let the buyer choose again without restarting the whole event journey. ## Preserve active holds across navigation The buyer SDK restores its current hold from `sessionStorage` by default. If your application owns navigation or cart persistence, set `restoreHold: false`, store the `holdId`, and pass `initialHoldId` when the picker mounts again. ```js title="checkout-picker.js" const picker = new SeatPicker({ container: "#picker", event: eventKey, publicKey: "", restoreHold: false, initialHoldId: cart.seatlayerHoldId, onHoldChange: (hold) => { saveCart({ seatlayerHoldId: hold?.holdId ?? null }); }, }); ``` Use a public key whose mode matches `eventKey` and register this page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. ## Design for every buyer - Give the picker container an explicit height and test it at its actual embed width. - Keep keyboard focus visible and do not trap focus around the embed. - Preserve accessible-seat metadata and companion-seat rules from the venue. - Test high zoom, narrow screens, screen-reader labels, and colorblind-safe mode. - Treat 3D and 360° seat views as progressive enhancement. The 2D map remains the reliable fallback. - Localize surrounding checkout copy as well as the picker. ## Add useful observability Record your own correlation fields together: - SeatLayer `eventKey`; - `holdId`; - your `orderId` / `bookingRef`; - payment reference; - HTTP status and SeatLayer error code; and - webhook delivery/event identifier. Never log secret keys or full authorization headers. Avoid logging buyer personal data in inventory diagnostics. ## Use webhooks for reconciliation The synchronous booking response drives the buyer journey. Signed webhooks provide asynchronous confirmation and repair. - Verify HMAC against the **raw request body** before parsing or trusting it. - Deduplicate deliveries. - Return a fast `2xx`, then run slow business work asynchronously. - Route platform traffic by your stored event mapping and verify `workspaceId`. - Keep test and live traffic separate using `livemode`. ## Test the system, not only the component Before live credentials: - two browsers compete for the same seat; - a hold expires during checkout; - payment succeeds but booking returns `409`; - booking succeeds but the HTTP response times out; - the same booking request is delivered twice; - the page refreshes with an active hold; - realtime disconnects and reconnects; - the event sells out while the picker is open; - a narrow mobile container completes checkout; and - missing or mode-mismatched credentials fail safely. > **Use sandbox events** > > Test-mode events exercise the same hold, booking, realtime, and webhook flow without spending booking credits. ## Production checklist - [ ] Secret and public configuration are separated. - [ ] CSP allows only the SeatLayer origins your embed needs. - [ ] Server hold inspection is the source of price and quantity. - [ ] `bookingRef` comes from an immutable order identifier. - [ ] Payment conflict compensation is automated. - [ ] Webhook signatures and duplicate deliveries are tested. - [ ] Mobile, keyboard, accessibility, and 2D fallback are verified. - [ ] Logs connect event, hold, order, and payment without leaking secrets. - [ ] Test and live credentials cannot be mixed. Use the [complete checkout](/examples/complete-checkout) as a reference implementation. # Custom applications > Integrate SeatLayer into any frontend, backend, checkout, payment provider, or analytics stack without giving up product control. Source: https://docs.seatlayer.io/integrations/custom-applications/ · Markdown: https://docs.seatlayer.io/integrations/custom-applications/index.md SeatLayer is designed to sit inside an existing application rather than replace it. Use the browser SDK for interactive inventory and the HTTP API from any trusted server runtime. > **Custom to custom is the normal path** > > Your framework, database, cart, payment provider, analytics, and deployment > platform do not need a SeatLayer-specific adapter. Keep a small integration > boundary around the documented browser and HTTP contracts. ## Reference architecture Only the browser SDK needs DOM access. The server integration is standard HTTPS, so it works in Node, Bun, Deno, PHP, Ruby, Python, Go, Java, .NET, Workers, and other environments that can send authenticated requests. ## Keep the integration boundary small Create one server module responsible for: - loading and validating the SeatLayer secret key; - building the API base URL for the current environment; - inspecting a hold; - booking with a stable `bookingRef`; - cancelling or changing server-managed inventory; - normalizing errors into your application's domain errors; and - attaching request ids to logs without recording secrets. ```ts title="server/seatlayer.ts" const apiBase = "https://api.seatlayer.io"; async function seatLayerRequest(path: string, init: RequestInit = {}) { const response = await fetch(`${apiBase}${path}`, { ...init, headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", ...init.headers, }, }); const body = await response.json().catch(() => null); return { ok: response.ok, status: response.status, body }; } export function inspectHold(eventKey: string, holdId: string) { return seatLayerRequest( `/v1/events/${encodeURIComponent(eventKey)}/holds/${encodeURIComponent(holdId)}`, ); } export function bookHold( eventKey: string, input: { holdId: string; bookingRef: string }, ) { return seatLayerRequest( `/v1/events/${encodeURIComponent(eventKey)}/book`, { method: "POST", body: JSON.stringify(input) }, ); } ``` Wrap this module in your existing service, dependency-injection, tracing, and test conventions. Do not spread direct SeatLayer calls across route handlers. ## Define the browser-to-server contract Your checkout route needs an opaque hold id and your own application context: ```ts title="shared/checkout-contract.ts" type SeatCheckoutRequest = { eventKey: string; holdId: string; cartId: string; }; ``` `eventKey` may instead come from a trusted product record or route parameter. Never accept buyer-supplied price, currency, category, or labels as the booking authority. ## Connect any frontend ### Vanilla JavaScript Mount `SeatPicker` from the hosted script or ESM build and call your checkout endpoint from `onCheckout`. ### React Use `@seatlayer/react` and keep the event key and callbacks in component state. Hold state remains server-authoritative. ### Other frameworks Mount the vanilla SDK in the framework's client-only lifecycle hook and call `destroy()` during cleanup. Avoid rendering it during server-side rendering. ### Mobile webview Load the buyer surface after the webview is ready, allow WebGL only if using 3D, and bridge the checkout handoff into the native application. For an ordinary Platform Public sale, configure the event key plus `publicKey`. SeatLayer validates the exact registered origin and returns a Public-only in-memory session with the chart and compact inventory status together. Login, presale, partner, and channel inventory use `buyerAccessTokenProvider` or `buyerAccessToken` from your authenticated backend instead. The explicit scoped credential always takes precedence. ## Send analytics anywhere `SeatPicker.onAnalytics` is a callback, not a SeatLayer analytics destination. Forward supported events to the system your application already uses: ```js title="browser/analytics.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", onAnalytics: (event, properties) => { // PostHog posthog.capture(`seatlayer_${event}`, properties); // Or Google Analytics: // gtag("event", `seatlayer_${event}`, properties); // Or your own endpoint: // navigator.sendBeacon("/analytics", JSON.stringify({ event, properties })); }, }); ``` The currently emitted event names cover the 3D buyer journey and are marked **Preview** in the [analytics reference](/buyer-sdk/analytics). A throwing sink does not break the picker, but your adapter should still be defensive and avoid sending personal data accidentally. ## Adapt errors into product behavior | SeatLayer result | Domain behavior | |---|---| | `404 hold_not_found` | Cart is stale; return to selection | | Active hold with expired timestamp | Return to selection and clear persisted hold | | `403 mode_mismatch` | Configuration fault; alert operators, not buyers | | Booking `409` | Recoverable inventory conflict | | Timeout or `5xx` after booking request | Unknown state; retry with the same `bookingRef` | | Webhook signature failure | Reject and log security event | Keep raw provider errors in structured server logs and return stable, buyer-appropriate application errors to the client. ## Test without real dependencies Inject the HTTP transport or mock your server adapter at its boundary. Cover: 1. successful hold inspection and booking; 2. expired or missing hold; 3. payment rejection before booking; 4. atomic booking conflict; 5. booking timeout followed by same-reference retry; 6. webhook duplicate delivery; 7. missing or wrong-mode credentials; and 8. browser cleanup during navigation. An end-to-end test in SeatLayer test mode should complement, not replace, these fast contract tests. ## Production checklist - [ ] SDK code runs client-side and is destroyed on unmount. - [ ] A single server adapter owns API calls and secret loading. - [ ] Public Platform mounts use a matching `publicKey` from an exact registered origin; scoped private mounts use a server-backed `buyerAccessTokenProvider`. Any resulting `bse_` stays out of logs, URLs, and storage. - [ ] Browser payloads contain no trusted price or booking authority. - [ ] HTTP errors are mapped to stable product states. - [ ] Checkout retries reuse the existing order id. - [ ] Analytics forwards only intended, non-sensitive properties. - [ ] Unit, contract, and test-mode end-to-end paths pass. - [ ] Framework upgrades cannot silently duplicate picker instances. Continue with [install the Buyer SDK](/buyer-sdk/install), [integrate a ticketing backend](/integrations/ticketing-backend), or [build with agents](/agents/overview). # Groups and general admission > Hold GA quantities and find adjacent reserved seats without changing the server booking flow. Source: https://docs.seatlayer.io/integrations/groups-and-ga/ · Markdown: https://docs.seatlayer.io/integrations/groups-and-ga/index.md General-admission areas and reserved-seat groups use different selection UX, but they produce the same server boundary: an authoritative hold that your backend inspects and books atomically. GA is supported only as capacity authored inside a reserved-seat Chart. This guide does not create or support a pure-GA event without a seating chart. > **SeatPicker includes both flows** > > `SeatPicker` renders GA quantity controls and best-available group selection > in its buyer UI. Use the headless `SeatingChart` methods below when your > application owns those controls. ## General-admission inventory A `gaArea` is a chart object with capacity instead of individually presented seats. A chart can mix reserved rows, tables, booths, and GA areas in one event. With `SeatingChart`, respond to `onGAClick` and hold the buyer's requested quantity: ```js title="browser/ga-selection.js" const chart = new seatlayer.SeatingChart({ container: "#chart", event: "", publicKey: "", onGAClick: async (area) => { const quantity = await askForQuantity({ label: area.label, remaining: area.available, }); if (quantity > 0) { await chart.holdGA(area.id, quantity); } }, onHold: ({ holdId, expiresAt, items }) => { saveCheckoutHandoff({ holdId, expiresAt, items }); }, }); await chart.render(); ``` For a public Platform event, use a publishable key that matches the event mode (`pk_test_…` for test or `pk_live_…` for live) and register the page's exact Embed origin. The SDK obtains Public-only access directly and keeps the grant in memory, so your server does not mint a buyer token when the chart loads. For a login, presale, partner, or channel audience, replace `publicKey` with an async `buyerAccessTokenProvider` backed by your authenticated server; an explicit provider or token takes precedence. SeatLayer materializes internal GA units for inventory correctness. Treat their labels as opaque. For buyer-facing totals, aggregate hold items by `objectId` and sum `quantity ?? 1`. ```js title="server/group-ga-lines.js" function summarizeHold(items) { return Object.values( items.reduce((groups, item) => { const key = `${item.objectId}:${item.tierId ?? "base"}`; groups[key] ??= { objectId: item.objectId, categoryKey: item.categoryKey, tierId: item.tierId, unitPrice: item.unitPrice, currency: item.currency, quantity: 0, }; groups[key].quantity += item.quantity ?? 1; return groups; }, {}), ); } ``` Do not generate GA unit labels in your application or accept them from the buyer as trusted booking input. ## Adjacent reserved seats `bestAvailable(quantity, categoryKey?, options?)` asks SeatLayer to find and hold a suitable adjacent group in one operation. ```js title="browser/best-available.js" const result = await chart.bestAvailable(4); if (!result) { showNoGroupMessage("We could not find four seats together."); } else { console.log(result.holdId, result.labels); } ``` Restrict the search to a category: ```js const result = await chart.bestAvailable(2, "vip"); ``` A successful result updates the chart selection and triggers `onHold` like a manual hold. Continue through the same server inspection, payment, and booking flow. ## Use intentional fallback rules 1. **Ask for the desired group** Start with the buyer's quantity and optional category or zone preference. 2. **Explain an exact failure** If no adjacent block exists, say that clearly; do not report the whole event as sold out when scattered seats may remain. 3. **Offer a buyer-controlled alternative** Let the buyer reduce quantity, choose a different category, or switch to manual selection. Do not silently split the group. 4. **Book the hold atomically** Send only its `holdId` to your server. A conflict fails the whole booking, so a four-person order cannot accidentally confirm three seats. ## Server booking is unchanged ```js title="server/book-group.js" const response = await fetch( `https://api.seatlayer.io/v1/events/${eventKey}/book`, { method: "POST", headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ holdId, bookingRef: order.id, }), }, ); if (response.status === 409) { return sendBuyerBackToGroupSelection(); } ``` Whether the hold contains clicked seats, an adjacent group, a GA quantity, or a mixture, booking is all-or-nothing. ## Accessibility and product details - Label GA controls with the area name, remaining quantity, and chosen count. - Provide increment/decrement buttons with keyboard and screen-reader labels. - Announce the best-available result and hold expiry without moving focus unexpectedly. - Preserve accessible-seat metadata when offering group alternatives. - Show the group as one order line when that matches buyer expectations, while retaining item-level data in the order record. - Never use colour alone to distinguish available, held, and booked inventory. ## Verification checklist - [ ] GA capacity is configured on the chart object. - [ ] Quantity validation prevents zero, negative, and over-capacity requests. - [ ] Buyer totals come from inspected `unitPrice`, currency, and quantity. - [ ] Best-available failure does not silently split the group. - [ ] Manual fallback preserves the requested category/accessibility needs. - [ ] A mixed reserved + GA hold completes in test mode. - [ ] Expiry and `409` return the entire order to a useful state. - [ ] Keyboard and screen-reader behavior is tested for quantity controls. Continue with [best available](/buyer-sdk/best-available), [ticket tiers](/buyer-sdk/ticket-tiers), or [integrate your backend](/integrations/ticketing-backend). # Hosted iframe > Embed SeatLayer's hosted buyer page, and understand what it can complete on its own and what it never tells your host page. Source: https://docs.seatlayer.io/integrations/iframe/ · Markdown: https://docs.seatlayer.io/integrations/iframe/index.md The hosted iframe is the lowest-code way to place a responsive SeatLayer picker on a page. It loads the published event at `https://app.seatlayer.io/e/:eventKey` and needs no browser credential. It is a self-contained buyer page, not a component. That single fact decides both of the things worth knowing about it. ## What it can complete The framed page runs the same `SeatPicker` the SDK ships, so the buyer picks and holds seats for real, and those seats are blocked for everyone else while the hold lasts. Whether the buyer can also **pay** depends on the organizer's account: - **Payments enabled, and a gateway connected to the event**: the buyer pays by card inside the frame, the money goes to the organizer's own Stripe or Razorpay account, and the order is confirmed on the SeatLayer buyer page. The frame is served from `app.seatlayer.io`, so that is where the gateway returns the buyer. No host code is involved at any point. - **Otherwise**: at checkout the buyer sees SeatLayer's "finish in the ticketing checkout" card. The seats stay held; nothing is charged. ## What it never does > **The frame tells your page nothing** > > The public `postMessage` contract is resize and fullscreen only. There is no > checkout, selection, hold, or booking-result message, so a host page cannot > learn that a hold was made or an order was paid, and cannot run its own payment > step against a hold created in the frame. Use the iframe when the frame completing the sale (or completing nothing) is acceptable: a marketing page, a venue page, a preview, a layout review. Use [`SeatPicker`](/buyer-sdk/install) when your page needs to know. It works without a bundler through the hosted script, and gives you either the server checkout handoff (`onCheckout`) or, with `checkout: "hosted"`, the picker's own payment plus `onOrderConfirmed` in your own page. ## Site builders Most website builders accept an embed, but running a custom ` ``` Replace `` with a published event key. `aspect-ratio` reserves stable space before the first measurement arrives; the height message then tracks content changes. ## Message contract | Message | Payload | Host action | |---|---|---| | `seatlayer:height` | `{ type: "seatlayer:height", px: number }` | Set the frame height | | `seatlayer:fullscreen` | `{ type: "seatlayer:fullscreen", on: boolean }` | Pin or restore the frame | The picker sends these messages because a framed document cannot resize its own iframe or escape it for fullscreen. There is no public checkout, selection, hold, or booking-result message. A sale completed inside the frame is invisible to the host page by design, because the frame runs on SeatLayer's origin and must not be able to assert anything to yours. ## Use the frame helper `@seatlayer/js` exports `attachPickerFrame()` for the same protocol. It validates the frame source, locks and restores document scrolling, preserves prior styles, handles height changes received during fullscreen, and exits fullscreen on Escape. ```sh npm install @seatlayer/js pnpm add @seatlayer/js yarn add @seatlayer/js bun add @seatlayer/js ``` ```ts title="host-frame.ts" import { attachPickerFrame } from "@seatlayer/js"; const iframe = document.querySelector("#seatlayer"); if (!iframe) throw new Error("SeatLayer iframe was not found"); const detach = attachPickerFrame(iframe); // Call when the iframe is removed or the host view unmounts. detach(); ``` By default the helper accepts the origin parsed from `iframe.src`. To pin it explicitly: ```ts const detach = attachPickerFrame(iframe, { origin: "https://app.seatlayer.io", }); ``` ## Security requirements > **Verify both origin and source** > > A listener that trusts `event.data` alone lets any framed or opened page ask > your host to resize or cover the viewport. - Compare `event.origin` with the iframe URL's origin. - Compare `event.source` with `iframe.contentWindow`. - Accept only known message types and expected primitive fields. - Never put a SeatLayer secret key in the iframe URL or host JavaScript. - Give the iframe an accessible `title`. ## Iframe versus SDK | Requirement | Iframe | `SeatPicker` | |---|---:|---:| | No bundler | Yes | Yes, through hosted script | | Sells with no backend | Yes, when payments are on | Yes, with `checkout: "hosted"` | | Buyer finishes on your domain | No, always on `app.seatlayer.io` | In-page gateways yes; a card redirect returns to `app.seatlayer.io` | | Your payment/order step before booking | No | Yes | | `onCheckout`, `onOrderConfirmed`, hold callbacks | No | Yes | | Host pricing overrides | No | Yes | | Host theme overrides | No | Yes | | Responsive/fullscreen helper | Required in host | Built into same-document widget | ## Verification checklist - [ ] The host page does not need to know about holds or orders. - [ ] The event is published in the expected mode. - [ ] The listener verifies both origin and window source. - [ ] The frame has a useful title and keyboard focus is visible. - [ ] Height changes do not create horizontal page overflow. - [ ] Fullscreen locks scrolling and restores all prior styles on exit. - [ ] Mobile Safari and an Android browser have been tested. - [ ] Anything that must react to a hold or an order uses an SDK surface instead. Continue with [choose an integration](/start/choose-an-integration) or [install `SeatPicker`](/buyer-sdk/install). # Migrate from seats.io > Import a public seats.io event as a SeatLayer draft chart, then map charts, events, categories, holds, labels, and webhooks to SeatLayer. Source: https://docs.seatlayer.io/integrations/migrate-from-seatsio/ · Markdown: https://docs.seatlayer.io/integrations/migrate-from-seatsio/index.md SeatLayer can read the seating layout of a public seats.io event and turn it into a draft SeatLayer chart. You review the draft in the Designer, publish it, and then create SeatLayer events from it. This page covers what the import copies, the steps in the dashboard, and how seats.io concepts map to the SeatLayer API. ## What the import copies The import reads the published drawing behind one seats.io event, using only the public workspace key and event key. No seats.io secret or admin key is needed. | Copied | Details | |---|---| | Sections | Outline, label, label visibility, and rotation | | Rows and seats | Exact seat positions and row label position. Seat labels become `Section-Row-Seat`. Rows placed outside any section go into an "Unsectioned" group | | Seat attributes | Wheelchair (with space type), hearing, sign language, cart, lift-up armrests, semi-ambulatory, and plus-size flags; restricted view and limited legroom become notes | | Tables | Round and rectangular tables with per-seat positions, book-as-whole, and variable occupancy | | General admission areas | Polygon outline, capacity, and category | | Booths | Position and size | | Text and shapes | Text labels, rectangles, ellipses, polygons, lines, and polylines | | Categories | Label and color | | Floors | Each floor becomes a SeatLayer floor | What it does not copy: - **Bookings and holds.** Seat statuses are not read. Every seat in the draft is free. - **Ticket prices.** Prices are not read. Set prices on categories in the Designer after import. - **Images and icons.** They are not imported as visuals. Image positions are only used to place wheelchair spaces and to detect a pitch or court. - **The visual theme.** The draft uses a SeatLayer theme. - **Anything the converter could not read.** A table, booth, GA area, or shape with missing geometry is left out and listed in the import review. The focal point is set to the center of the drawing. A pitch or court is added only when the drawing or venue name says it is one, for example football, basketball, or court. ## Import in the dashboard 1. Open **Charts** and choose **Import a public Seats.io event**. In the Designer, the same flow is under the new-chart menu as **Import Seats.io event…**. 2. Enter the **Public workspace key** (from Workspace Settings in seats.io) and the **Event key** (from the event in seats.io). 3. Choose the **Region** the seats.io workspace lives in: Europe, North America, South America, or Oceania. 4. Optionally enter a **Draft name**. Without one, SeatLayer uses the seats.io chart name. 5. Choose **Import to a draft**. It usually takes 20 to 30 seconds. You can close the dialog; a message offers **Open draft** when it is ready. 6. The draft opens in the Designer with an import review. Each finding is marked fixed, needs input, unsupported, or informational. Answer the ones that need input, for example ticket prices or accessibility mapping. 7. Set category prices, check labels and sections, then publish the chart. If the import fails, check the public workspace key, event key, and region. > **Import limits** > > An account can start 10 imports per hour. An import that does not finish in > 20 minutes is marked failed. Import is a dashboard feature: it needs a > signed-in Designer or admin, and there is no secret-key route for it in the > Server API. ## Map seats.io concepts to SeatLayer | seats.io | SeatLayer | Where | |---|---|---| | Chart | Chart. Edited as a draft, then published | [Designer](/designer/charts-and-objects/) | | Event | Event, created from a published chart with its own inventory | [`POST /v1/events`](/server-api/events/) | | Category (key, label, color) | Chart category. Its key appears as `categoryKey` on every line item | [Pricing and offers](/platform/ticket-pricing-and-offers/) | | Object label | Object label, such as `A-12`, used in every hold, booking, and webhook | [Inventory models](/start/inventory-models/) | | Hold token | Hold with a `holdId`, created when the buyer selects seats and expiring after 15 minutes by default | [How seat holds work](/server-api/how-seat-holds-work/) | | Booking objects | `POST /v1/events/:key/book` with `holdId` and your `bookingRef` | [Booking](/server-api/booking/) | | Releasing booked objects | `POST /v1/events/:key/unbook` with the `bookingRef` that owns them | [Cancellations and box office](/server-api/cancellations-and-box-office/) | | Objects taken off sale | Blocks, optionally released at a set time | [Blocking](/server-api/blocking/) | | Best available | `POST /v1/events/:key/best-available` | [Holds](/server-api/holds/#let-us-pick-the-seats) | | Channels | Sales channels on one event | [Sales channels](/platform/sales-channels/) | | Seasons | Fixed Renewable Seasons | [Seasons](/integrations/seasons/) | | Resale | Resale listings on booked seats | [Resale](/server-api/inventory/#resale) | | Public workspace key | Publishable key `pk_test_…` or `pk_live_…` | [Authentication](/start/authentication/) | | Secret key | Secret key `sk_test_…` or `sk_live_…` | [Authentication](/start/authentication/) | Imported seat labels follow `Section-Row-Seat`. If your order records store seats.io labels, compare them with the labels in the published SeatLayer chart before you move live orders. ## Replace your webhook handlers Point the handlers that react to seat changes at these SeatLayer events: | Your system needs to know | Subscribe to | |---|---| | Seats were booked | `seat.booked`, with `bookingRef` and priced `items` | | Seats became free again | `seat.released` | | A buyer's hold ran out | `hold.expired` | | The event sold out | `event.soldout` | | An operator blocked seats | `seat.blocked` | | A resale seat changed hands | `object.resale.sold` | Every delivery is signed with HMAC SHA-256 and carries an `occurrenceId` to deduplicate retries. See [webhook events](/webhooks/events/) and [signature verification](/webhooks/signatures/). ## Swap the buyer SDK Replace the seats.io chart renderer with the SeatLayer `SeatingChart`: ```js title="browser/chart.js" import { SeatingChart } from "@seatlayer/js"; const chart = new SeatingChart({ container: "#chart", event: "", publicKey: "", onSelectionChange: renderCart, onHold: (hold) => saveHoldId(hold), }); await chart.render(); ``` The chart holds seats as the buyer selects them. Send the `holdId` to your server, check the price there, take payment, and book with your secret key. React, Vue, Angular, and the mobile SDKs follow the same pattern; see [choose an integration](/start/choose-an-integration/) and the [SeatingChart reference](/buyer-sdk/seating-chart/). ## Cut over one event at a time 1. Import the chart, review it, set prices, and publish it. 2. Create a test-mode event with a `sk_test_…` key and run a full buy: select, hold, pay, book, and receive `seat.booked`. 3. Create the live event with a `sk_live_…` key. 4. For seats already sold elsewhere, book them in SeatLayer with their original order references using [box office booking](/server-api/cancellations-and-box-office/#book-inventory-directly), so they are not sold twice. Each seat booked this way uses a [credit](/billing/credits/), like any sale. 5. Switch the buyer page to the SeatLayer chart. # Partner guide: sell an allocation > Operate inventory assigned by an organizer through a hosted buyer link, scoped reports, or an organizer-brokered SDK integration. Source: https://docs.seatlayer.io/integrations/partner-selling/ · Markdown: https://docs.seatlayer.io/integrations/partner-selling/index.md You have Partner access because an organizer assigned your organization part of one event. You do not receive a copy of the chart or a separate stock of seats. Your buyers use the organizer's existing event and chart, restricted to the inventory assigned to you. > **This is the partner-side guide** > > Start with [Sales channels and allocations](/platform/sales-channels/) for the > shared feature model. If you are the organizer or the developer who controls > the trusted booking backend, use the > [organizer private-sales guide](/integrations/private-and-partner-sales/) > instead. When one of your seats is held or sold, availability changes everywhere immediately. The organizer remains the owner of the event, checkout rules, payments, bookings, tickets, and customer support unless your commercial agreement says otherwise. ## Choose how you will sell | Your situation | Use | |---|---| | You want a link you can send to buyers now | **SeatLayer Hosted buyer link** | | You want the seat map inside your own website or application | **Organizer-brokered SDK integration** | | You only need to monitor the allocation | **Reports and CSV export** | ## Option 1: sell with a Hosted buyer link This option requires no integration work from your team. It is available only when: - the organizer sells the event through SeatLayer checkout; - the assigned channel uses Hosted link access; and - your Partner access permission includes **Create buyer links**. In your Partner access context: 1. Open **Distribution**. 2. Select the event allocation. 3. Choose **Create buyer link**. 4. Give the link an internal label that your team will recognize. 5. Set its expiry, maximum redemptions, seats per buyer, and buyer-session duration. 6. Copy the complete URL when it is revealed. It is shown only once. 7. Send that URL through your own approved email, CRM, package page, or support channel. The buyer opens the organizer's existing event and chart with your allocation already applied. Seats outside the permitted scope do not reveal their private state. Completed sales retain your channel attribution in organizer and partner reports. Treat the URL like an invitation. Anyone who receives it can attempt to redeem access within its limits. Revoke it from **Distribution** if it is forwarded to the wrong audience. > **If Create buyer link is unavailable** > > Ask the organizer to confirm that the event uses SeatLayer checkout, the > channel uses Hosted link access, and your assignment permits buyer links. > Partner access by itself does not change the organizer's checkout route. ## Option 2: put the chart in your own system Use this when your buyers should stay on your website or app. This is a coordinated integration between your system and the organizer's backend. Partner access does **not** issue an organizer secret key or direct booking authority. The supported boundary today is: 1. Your website authenticates your buyer. 2. Your backend asks the organizer's backend for scoped SeatLayer buyer access. 3. The organizer validates your contract and buyer reference. 4. The organizer's backend mints a short-lived session for your assigned channel and your exact production origin. 5. Your page mounts the SeatLayer buyer SDK with that short-lived token. 6. The buyer selects seats and creates a hold. 7. Your checkout sends the order and hold reference to the organizer's agreed booking endpoint. 8. The organizer verifies the hold and price, completes the SeatLayer booking, and returns the confirmed booking reference. The organizer's secret key stays on the organizer's server. Never request it, put it in your application, or ask buyers to send a channel id from the browser. A channel id is a label, not authorization. ### Information to exchange before development Ask the organizer for: - the event id and SeatLayer publishable key used by the buyer SDK; - your production and test origins; - the organizer endpoint that issues scoped buyer-access tokens; - the organizer endpoint that accepts your checkout and hold reference; - the buyer and partner reference format used for reconciliation; - maximum seats per buyer and session duration; - who owns payment capture, refunds, tickets, and customer support; - test-event access and a non-production payment path; and - webhook or status-callback behavior after a booking succeeds or fails. Give the organizer: - your test and production domain names; - how your users authenticate; - the stable buyer and order references you will send; - your retry/idempotency behavior; - the person responsible for checkout failures and reconciliation; and - the date you need test and production access. ### Browser integration shape The organizer supplies the event id and publishable key. Your page mounts the SDK with a `buyerAccessTokenProvider` that calls your own backend; your backend obtains the short-lived token through the organizer-approved broker flow. The code is in step 5 of the [organizer private-sales guide](/integrations/private-and-partner-sales/#5-mount-the-map-with-the-token), which also covers the organizer side of the flow. Keep the token in memory only. Do not store it in local storage, put it in a query string, or write it to analytics and exception logs. ## Monitor and reconcile Use **Allocations** to see the exact seats and statuses currently assigned to you. Use **Reports** to review allocation, attributed sales, sell-through, and download the scoped CSV. Your reports do not expose sibling channels, organizer credentials, buyer identity, booking references, or organizer settings. Agree your own reconciliation keys with the organizer before selling. ## Before you go live - [ ] The correct event, channel, quantity, pricing, and access dates are visible. - [ ] You know whether you are using a Hosted link or your own integrated checkout. - [ ] Your test path cannot create a real customer charge. - [ ] Buyer-link limits or SDK session limits match your agreement. - [ ] Payment, booking, ticket, refund, and support ownership are written down. - [ ] A successful test sale appears in both partner and organizer reports. - [ ] An out-of-scope seat cannot be selected. - [ ] Expired/revoked access and sold-out inventory have clear buyer messages. - [ ] Your team knows how to revoke a link, return free inventory, and request more. ## Related - [Organizer guide: integrate private sales](/integrations/private-and-partner-sales/): organizer and developer implementation - [Sales channels and allocations](/platform/sales-channels/): allocation, roles, permissions, and lifecycle - [Buyer SDK setup](/buyer-sdk/install) - [Holds and checkout](/buyer-sdk/holds-and-checkout) # Integrate a Performance Group > Build a fixed or flexible-date multi-performance checkout with one safe seat hold, your existing payment flow, and per-performance fulfilment. Source: https://docs.seatlayer.io/integrations/performance-groups/ · Markdown: https://docs.seatlayer.io/integrations/performance-groups/index.md Use a Performance Group when a buyer purchases one run of two to eight dated performances that share the same seating plan. SeatLayer coordinates the seat inventory; your Platform application keeps ownership of the offer, checkout, payment, order, tickets, and customer support. This page owns the **end-to-end Performance Group workflow**. Compare all three [inventory models](/start/inventory-models/) before committing to it, then use the [Performance Group picker](/buyer-sdk/performance-groups/) for buyer UI and the [Performance Groups API](/server-api/performance-groups/) for exact trusted operations. Open the public [Performance Groups buyer demo](https://app.seatlayer.io/demo/performance-groups) to test the multi-performance selection and hold handoff before following the end-to-end server flow below. > **This is not a season product** > > A Performance Group does not discount prices, accept renewals, or support > exchanges. `flexible_dates` can omit dates where a chosen partial seat is > unavailable; it is not an arbitrary build-your-own subscription or season. > Use the [Fixed Renewable Season integration](/integrations/seasons/) for > immutable Plans, incumbent Seat Rights, and renewal offers. ## The complete transaction There is never a browser loop that asks each Event whether a seat is free. There is never one child checkout or booking call per Event. SeatLayer makes the all-or-nothing inventory decision for the whole group. ## Before you begin Complete [server authentication and environment setup](/start/authentication) first. This guide uses a small server-only JSON helper; it keeps the secret key out of the browser and turns failed SeatLayer responses into checkout errors your application can handle deliberately. ```ts title="server/seatlayer-json.ts" export async function seatlayerJson(path: string, init: Omit & { body?: unknown; } = {}) { const { body, headers, ...request } = init; const response = await fetch(`https://api.seatlayer.io${path}`, { ...request, headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }); if (!response.ok) throw new Error(`SeatLayer request failed: ${response.status}`); return response.status === 204 ? null : response.json(); } ``` Never import this helper into browser code or expose `SEATLAYER_SECRET_KEY` in a public environment variable. The browser receives only the short-lived `bsg_…` token minted in step 3. ## 1. Create compatible performances Create one Platform Event for each date. Before creating the group, confirm all of the following: - the same organization and workspace; - the same live or test mode; - the same venue, timezone, currency, inventory model, and published chart snapshot; - a start time on every performance; - assigned, individually bookable capacity-one seats only; and - Platform/integrator checkout ownership on every Event. The group stores a fixed chronological member list even when its hold policy is `flexible_dates`. Once active, do not add a late show, silently remove a cancelled show, or swap the chart. Create a new offer/run through your operational process instead. ## 2. Create and activate the group Your server uses its secret key to create the run, chooses its inclusion policy, reviews its compatibility result, then activates the exact displayed revision. ```ts title="server/create-performance-group.ts" const group = await seatlayerJson("/v1/performance-groups", { method: "POST", headers: { "Idempotency-Key": `run-${productionId}` }, body: { name: "Opening weekend", externalRef: productionId, eventKeys: [fridayEventKey, saturdayEventKey, sundayEventKey], inclusionMode: "flexible_dates", }, }); const activated = await seatlayerJson( `/v1/performance-groups/${group.performanceGroup.key}/activate`, { method: "POST", body: { expectedRevision: group.performanceGroup.revision }, }, ); ``` An activation can temporarily return a recoverable in-progress result. Follow its lifecycle location and retry interval until the state is `active`; do not create a duplicate group or change the expected revision blindly. Omit `inclusionMode` or pass `"fixed"` when every date is mandatory. Use `"flexible_dates"` only when your offer permits a partial seat to be sold for the dates where it is available. The buyer cannot override this server-owned policy. ## 3. Mint browser access from your backend The browser needs one short-lived group token. Your backend creates it only after it has decided that this buyer may start this run purchase. ```ts title="server/group-access.ts" const session = await seatlayerJson( `/v1/performance-groups/${groupKey}/buyer-access-sessions`, { method: "POST", body: { allowedOrigin: "https://tickets.example.com", includePublic: true, expiresInSeconds: 1800, maxQuantity: 4, buyerRef: `buyer:${buyer.id}`, }, }, ); return { token: session.token, expiresAt: session.expiresAt }; ``` For private allocations, send `channelIdsByEvent` keyed by every member Event. The group token is one credential, but the server derives each Event scope; the browser never chooses a channel. ## 4. Render the buyer picker Use `PerformanceGroupPicker`, not an array of `SeatPicker` instances. For a run that promises one physical seat across all dates, use the default `same_seat` mode. For equal-size allocations that may differ by date, set `selectionMode: "per_performance"` and an exact `numberOfPlacesToSelect`. With `flexible_dates` and `same_seat`, a partial seat shows its date-by-date availability and can be selected for the available dates without opening or loading another chart. ```ts title="browser/checkout-step.ts" const picker = new PerformanceGroupPicker({ container: "#seat-picker", performanceGroup: groupKey, buyerAccessTokenProvider: () => fetch("/api/group-access", { method: "POST" }) .then((response) => response.json()), onCheckout: (handoff) => { void fetch("/api/checkout/start", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ groupKey: handoff.performanceGroup.key, holdId: handoff.holdId, operationId: handoff.operationId, performanceKeys: handoff.performanceKeys, // display/context only }), }); }, }); await picker.render(); ``` ## 5. Inspect before payment When checkout begins, use the secret-key hold-inspection endpoint. Ignore buyer-posted seats, item prices, and totals. The inspection contains the ordered performance allocations, common expiry, configured inventory values, and exact hold status. ```ts title="server/checkout-start.ts" const hold = await seatlayerJson( `/v1/performance-groups/${groupKey}/holds/${operationId}`, ); if (hold.hold.state !== "committed") { return restartSeatSelection(); } const order = await createCommercialOrderFromTrustedAllocations(hold.hold.allocations); await authorizePayment(order); ``` Configured inventory values are not a charge instruction. Your checkout applies its own discounts, fees, tax, and payment policy to the trusted allocations. For a flexible hold, this authoritative allocation list is also the final set of dates to charge and fulfil; do not reconstruct it from the group member list or trust the browser-posted `performanceKeys`. ## 6. Book after your payment decision Use a new stable `bookActionId` for this one order attempt and reuse it on every retry. Keep the same `bookingRef` for the commercial order. A `202` is not a failure; follow the returned booking location until it reaches a terminal state. ```ts title="server/confirm-inventory.ts" const booking = await seatlayerJson( `/v1/performance-groups/${groupKey}/holds/${operationId}/book`, { method: "POST", body: { bookActionId: `pg-book:${order.id}`, bookingRef: order.id, }, }, ); if (booking.booking?.state === "book_pending") { return pollGroupBooking(groupKey, `pg-book:${order.id}`); } if (booking.booking?.state !== "booked") { return escalateOrCompensatePayment(order, booking); } await issueTicketsForEveryPerformance(order, hold.hold.allocations); ``` Never confirm the commercial order or issue tickets until group booking reaches the terminal booked state. A terminal `book_failed` may mean partial child settlement and requires your pre-agreed payment/support recovery path; do not retry it with a different action ID. ## 7. Test before live use - [ ] Create a three-date test group using one published chart. - [ ] Run the same-seat flow and prove a competing buyer cannot take that seat on any included date. - [ ] Run the per-performance flow with a different valid allocation on each date. - [ ] Create a `flexible_dates` group, make one seat unavailable on one date, and prove the partial seat can be held for the remaining dates from the same chart. - [ ] Clear that partial selection and prove the full-date availability view is restored without another chart load. - [ ] Verify a lost browser response recovers the original group operation. - [ ] Verify expiry and explicit release return inventory on every date. - [ ] Verify your backend refuses expired/invalid group holds before payment. - [ ] Verify booking retries reuse the same `bookActionId` and `bookingRef`. - [ ] Verify ticket fulfilment creates the right entitlement for every date. - [ ] Verify no secret key or child Event hold ID reaches browser logs. For request and response details, read the [Performance Groups API](/server-api/performance-groups) and [Performance Group picker reference](/buyer-sdk/performance-groups). # Build SeatLayer into your platform > Add seating to a custom product or multi-organiser platform while keeping your own brand, users, checkout, and business logic. Source: https://docs.seatlayer.io/integrations/platforms/ · Markdown: https://docs.seatlayer.io/integrations/platforms/index.md SeatLayer Platform is a composable reserved-seating and inventory layer. Your product can use only the buyer picker, or combine venue design, event inventory, checkout holds, server booking, operations, reporting, and webhooks behind your own interface. You keep the event catalogue, customer relationship, product UX, accounts, payments, taxes, commercial Orders, ticket/QR/PDF creation and delivery, email, refunds, check-in, support, and business rules. SeatLayer provides the seating-specific geometry, live inventory, concurrency, and inventory booking ledger. ## Choose an integration shape - **Add seating to one product** — Embed the picker in an existing booking or commerce flow. Your current backend receives the hold and confirms the order. - **Run a multi-organiser platform** — Provision one isolated workspace per organiser, store the mapping, and expose SeatLayer through your own CMS. - **Build custom venue operations** — Combine APIs, the embeddable Designer/control room, and webhooks with your own permissions and workflows. The data flow stays the same in every shape: ```text title="platform-boundary.txt" Your CMS ──secret key──> charts, workspaces, events Buyer page ──event + public key/scoped token──> render, select, hold Your checkout ──secret key──> inspect hold, book SeatLayer ──signed webhook──> reconciliation and downstream work ``` ## Map your model to SeatLayer | Your product | SeatLayer | |---|---| | Venue/layout | Reusable chart | | Performance, departure, or timed session | Event with independent inventory | | Tenant, organiser, or customer account | Workspace | | Cart reservation | Hold | | Inventory committed for a paid/confirmed order | Inventory Booking | | Your stable resource id | `externalRef` | `workspaceId` is the enforced isolation boundary. `externalRef` is an opaque reconciliation tag; never use it as authorization. ## Multi-organiser provisioning 1. **Create an isolated workspace** Create the mapping when a customer or organiser is provisioned. ```bash title="create-workspace.sh" curl https://api.seatlayer.io/v1/workspaces \ -X POST \ -H "Authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "Idempotency-Key: workspace-org_123" \ -H "Content-Type: application/json" \ -d '{"name":"Organiser X","externalRef":"org_123"}' ``` Persist the returned workspace id on your organiser record. 2. **Create or duplicate a chart** Maintain reusable master venues, then duplicate a master into the organiser workspace. ```bash title="duplicate-chart.sh" curl https://api.seatlayer.io/v1/charts/c_master_hall/duplicate \ -X POST \ -H "Authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "Idempotency-Key: org_123-main-hall" \ -H "Content-Type: application/json" \ -d '{ "name":"Organiser X (Main Hall)", "workspaceId":"ws_7e2d", "externalRef":"venue_81" }' ``` A duplicate is a draft. Review it, then publish it before creating an event. 3. **Create an event** Events inherit the chart workspace and receive independent inventory. Only `chartId` is required by the core API; `name` may default from the chart and the platform can keep all catalogue content in its own system. ```bash title="create-event.sh" curl https://api.seatlayer.io/v1/events \ -X POST \ -H "Authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "Idempotency-Key: event-show_456" \ -H "Content-Type: application/json" \ -d '{ "chartId":"c_7e2d", "name":"Spring Gala", "externalRef":"show_456" }' ``` Store the returned `meta.key` with your event. That event key addresses the buyer SDK's inventory and the inventory occurrence is operational immediately. It does not publish a Hosted Event Page, Organizer Website listing, or SeatLayer-managed checkout, and it does not need hosted event details or SeatLayer ticket releases. 4. **Embed under your brand** Render the picker in your product. Register the exact buyer-page origin as an Embed domain and give the SDK your publishable key. No account secret is sent to the browser. ```js title="buyer-page.js" const picker = new seatlayer.SeatPicker({ container: "#seat-picker", event: seatlayerEventKey, publicKey: seatlayerPublicKey, theme: { accent: "#6d28d9", accentInk: "#ffffff", fontFamily: "Inter, system-ui, sans-serif", }, onCheckout: (_, __, handoff) => { beginYourCheckout({ holdId: handoff.holdId }); }, }); await picker.render(); ``` For ordinary Public sale, SeatLayer validates the key, event, mode, and origin, then returns an in-memory Public-only session with the chart and compact inventory status in one response. Login, presale, partner, and channel inventory instead use `buyerAccessTokenProvider` or `buyerAccessToken` from your authenticated backend. An explicit token or provider always takes precedence over `publicKey`. 5. **Inspect, pay, and book inventory** Your backend resolves the hold, derives the trusted order amount, runs your payment flow, and commits SeatLayer inventory with your existing order id. ```js title="platform-checkout.js" const hold = await seatlayerRequest( `/v1/events/${event.seatlayerKey}/holds/${holdId}`, ); assertWorkspaceOwnership(event, organiser); const order = await createOrderFromTrustedItems(hold.items); await authorizePayment(order); const booking = await seatlayerRequest( `/v1/events/${event.seatlayerKey}/book`, { method: "POST", body: JSON.stringify({ holdId, bookingRef: order.id, }), }, ); ``` ## One webhook, many organisers Account-level webhook traffic can arrive at one endpoint. Route from the SeatLayer event id/key you stored, then verify its immutable workspace matches your business record. ```js title="server/seatlayer-webhook.js" app.post("/webhooks/seatlayer", rawBody(), async (req, res) => { if (!verifySeatLayerSignature(req.rawBody, req.headers)) { return res.sendStatus(401); } const delivery = JSON.parse(req.rawBody); const businessEvent = await findBySeatLayerEvent(delivery.payload.eventId); if ( !businessEvent || businessEvent.seatlayerWorkspaceId !== delivery.payload.workspaceId ) { return res.sendStatus(400); } await enqueueForOrganiser(businessEvent.organiserId, delivery); return res.sendStatus(200); }); ``` > **Verify before routing** > > Verify the signature against the raw body first. An `externalRef` is useful context, but it is not proof that a payload belongs to a tenant. ## White-label boundaries You can keep your own: - navigation, account hierarchy, roles, and permissions; - checkout, payments, tax, refunds, and order history; - ticket/QR/PDF creation and delivery, email, scanning, and attendance; - visual theme, surrounding content, and analytics; - venue onboarding and approval workflow; and - customer support and operational runbooks. SeatLayer remains responsible for: - chart geometry and semantic seating objects; - selection rules and accessible-seat relationships; - realtime holds and availability; - atomic booking transitions; - booking-reference linkage, inventory history, and sell-through reports; - buyer 2D/3D visualization; and - signed event notifications. ## Platform best practices - Keep one workspace per isolation boundary, not per event. - Reuse a chart for repeat performances; do not duplicate it for each date unless geometry differs. - Store SeatLayer ids alongside your own immutable ids at creation time. - Use deterministic idempotency keys for provisioning retries. - Enforce ownership in your backend before every cross-tenant action. - Keep master charts separate from organiser-owned copies. - Test sandbox and live webhook routing through the same tenant checks. - Expose only the operations your product is ready to support. - Treat configured booked value as an inventory performance figure; your commerce ledger remains authoritative for money. > **No SeatLayer fulfilment in Platform mode** > > `POST /book` commits inventory and emits inventory webhooks. It must not create > a SeatLayer commerce Order, ticket, QR/PDF/email, attendee/door record, or > refund. Your platform owns those records and experiences end to end. ## Custom-to-custom checklist Before shipping a new product shape, write down: 1. which system owns venues, events, prices, inventory, commercial Orders, tickets, refunds, check-in, and customers; 2. how your ids map to `workspaceId`, chart id, event key, hold id, and `bookingRef`; 3. which surface is embedded and which is called server-to-server; 4. how a hold expiry, booking conflict, and payment failure recover; 5. how tenant isolation is enforced and tested; and 6. which webhooks reconcile your downstream systems. Then use [integration best practices](/integrations/best-practices) and the [complete checkout example](/examples/complete-checkout) to implement the buyer-to-booking loop. # Organizer guide: integrate private sales > As an organizer or platform developer, allocate one Event to private audiences, authorize buyers, hold and book seats, and retain channel attribution. Source: https://docs.seatlayer.io/integrations/private-and-partner-sales/ · Markdown: https://docs.seatlayer.io/integrations/private-and-partner-sales/index.md This guide covers one event sold to more than one audience: a promoter with a public on-sale and a travel agency selling a reserved allocation of the same seats. > **This is the organizer and developer guide** > > Start with [Sales channels and allocations](/platform/sales-channels/) for the > concept and operator controls. If an organizer has already assigned inventory > to your organisation, use the > [partner selling guide](/integrations/partner-selling/) instead. By the end you will have allocated inventory to a [sales channel](/platform/sales-channels), minted buyer access from your backend, held seats in the agency's own page, booked them with your own credential, and received a webhook that says which channel made the sale. ## The shape of it One event with 1,000 seats: - 800 on Public sale; - 200 assigned to `Travel Agency A`. Both audiences load the same event and the same chart. Selling `A-12` through the agency makes `A-12` booked on the public page too, immediately, because there is only one inventory. The public page does **not** call the buyer-access API: it mounts the SDK with `event` and `publicKey`. The following token flow exists only for the agency's private allocation (and equally applies to login, presale, invite-only, and partner audiences). > **Who books, in this profile** > > **The promoter's backend books.** The agency never holds a SeatLayer > credential. It asks the promoter for buyer access and asks the promoter to > book. Direct partner booking belongs to a future partner-credential profile. > Use this profile for current integrations. > > [Partner access](/platform/sales-channels#give-a-partner-its-own-context-partner-access) > does not change this booking boundary either. It gives a recurring partner > signed in with its own SeatLayer account only its assigned allocation, report, > and the distribution, free-inventory return, or request actions the organizer > enables. It never grants an organizer secret key or direct booking authority. ## 1. Create the channel ```bash curl -s -X POST "https://api.seatlayer.io/v1/events//channels" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{"name": "Travel Agency A", "externalRef": "travel-agency-a", "accessIntent": "server"}' ``` Set `externalRef` now. It is the key you will reconcile on later, and it survives a rename. ## 2. Allocate the inventory Read the current `assignmentVersion`, then apply against it. ```bash curl -s "https://api.seatlayer.io/v1/events//channels" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` ```bash curl -s -X POST "https://api.seatlayer.io/v1/events//channels/assignments" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "targetChannelId": "chn_9f1c", "labels": ["A-1", "A-2", "A-3"], "assignmentVersion": 0 }' ``` Read the response's buckets rather than assuming it did what you asked. Seats in someone's checkout and seats already sold are never moved, and they are reported separately from labels that are not in the event at all. A stale `assignmentVersion` moves nothing and returns `409`. Full semantics: [apply an allocation](/server-api/channels). ### Optional: give the agency its own price book Read the channel's current `pricingVersion`, then send only the category or tier prices that differ from the event. Blank/omitted entries inherit event pricing. ```bash curl -s -X PATCH "https://api.seatlayer.io/v1/events//channels/chn_9f1c" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "expectedPricingVersion": 0, "priceOverrides": [ { "categoryKey": "standard", "tierId": null, "price": 18 } ] }' ``` A stale version returns `409 channel_pricing_conflict` and changes nothing. The agency buyer sees the scoped price automatically, and the hold freezes the same server-resolved value. Do not send the channel id or its prices from the browser. ## 3. Check what the agency will actually see ```bash curl -s "https://api.seatlayer.io/v1/events//channels/preview?channelIds=chn_9f1c&includePublic=0" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` This runs the same projection a real buyer session gets, so it is a genuine check and not an approximation. Do it before the partner goes live; it is much cheaper than discovering the allocation was wrong from a buyer. ### Give the agency a report or its own Partner access If the agency's operations team only needs to monitor its seats, open the event's **Performance → Channel** view and choose **Share partner report**. The report cannot hold or book inventory, and the URL itself is the credential, so set an expiry and revoke it when the campaign ends (see the [report lifecycle](/server-api/reports#partner-facing-channel-reports)). If the agency is a recurring partner that should issue its own buyer links, return seats, or request more, choose **Partner access** on the channel instead. Neither option changes the booking boundary below: the agency still cannot book directly, and your backend still owns checkout. What each one shows, the roles, permissions, release date, and revocation rules are on the [sales channels page](/platform/sales-channels#give-a-partner-its-own-context-partner-access). ## 4. Mint buyer access, from your backend The agency's backend authenticates its own customer, then asks yours. Yours decides, then mints. The complete private-channel sequence is deliberate: 1. The agency verifies the buyer and package entitlement. 2. Its backend asks the promoter backend for access; the browser never calls SeatLayer's secret-key endpoint. 3. The promoter backend mints a token scoped to `Travel Agency A`, that buyer, and the agency's exact origin. 4. The browser receives only the short-lived token and expiry in memory, then gives them to `buyerAccessTokenProvider`. 5. SeatLayer shows only that allocation; the promoter backend inspects the resulting hold and books it after checkout. An ordinary Public-sale buyer does none of these steps. Their page mounts with `publicKey` and SeatLayer starts the public chart directly. ```ts title="promoter-backend/access.ts" import SeatLayer from "@seatlayer/server"; const seatlayer = new SeatLayer({ secretKey: process.env.SEATLAYER_SECRET_KEY! }); export async function grantAgencyAccess(agencyBuyerId: string) { // Your rule, not SeatLayer's: is this buyer entitled to the agency block? const entitled = await agencyContract.isActiveBuyer(agencyBuyerId); if (!entitled) throw new Error("not entitled"); const session = await seatlayer.channels.createBuyerAccessSession("", { channelIds: ["chn_9f1c"], includePublic: false, allowedOrigin: "https://booking.travel-agency.example", expiresInSeconds: 1800, maxQuantity: 4, buyerRef: agencyBuyerId, partnerRef: "travel-agency-a", clientRequestId: `agency-access-${agencyBuyerId}`, }); await audit.record({ sessionId: session.sessionId, agencyBuyerId }); // Only these two cross the wire to the browser. return { token: session.token, expiresAt: session.expiresAt }; } ``` Set these session fields explicitly: - **`includePublic: false`**: the agency sells its own block, not your public inventory. There is no default; you must say. - **`allowedOrigin`**: the agency's exact production origin, checked on every request. - **`maxQuantity: 4`**: guest-weighted across *all* that buyer's live holds, so a second tab does not double the allowance. - **`partnerRef`**: flows to the report and the booking webhook as the reconciliation key. It is opaque; it is not a credential. `clientRequestId` makes the call safe to retry: a repeat revokes the earlier session and issues a fresh token rather than replaying a stored bearer. ## 5. Mount the map with the token In the agency's own page, give the SDK a **provider**, not a token. The provider is called for the initial token and again on expiry, so a buyer who lingers is refreshed instead of dropped. ```js title="agency-frontend/picker.js" const chart = new seatlayer.SeatingChart({ container: "#seat-map", event: "", buyerAccessTokenProvider: async ({ reason }) => { const res = await fetch("/api/seatlayer-access", { method: "POST", credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify({ reason }), }); if (!res.ok) throw new Error("Could not establish access"); return res.json(); // { token, expiresAt } }, }); await chart.render(); ``` > **Do not pass channels from the browser** > > There is no `channels: [...]` option, and there never will be. A channel id in > browser code is a label the server ignores. Scope comes from the token. > > Hold the token in memory only. Not `localStorage`, not a query string, not > telemetry, not exception text. Ask for a new one after a reload. The buyer now sees the agency's 200 seats. Everything else reads as one flat unavailable state. It does not expose whether inventory is booked or held, or any activity inside public inventory. ## 6. Hold, then book from your backend The browser holds. It never books. ```ts title="promoter-backend/checkout.ts" // The agency sends you its order and the holdId the buyer's browser produced. const hold = await seatlayer.inventory.retrieveHold("", holdId); // Authoritative pricing: build the total from this, never from the browser. const total = hold.items.reduce((sum, i) => sum + i.unitPrice * (i.quantity ?? 1), 0); if (total !== agencyOrder.total) throw new Error("price mismatch"); await payments.capture(agencyOrder); await seatlayer.inventory.book("", { holdId, bookingRef: agencyOrder.id, }); ``` This flow has two security properties: - **The hold is the authority.** `retrieveHold` returns the server's own items, including the channel and access source captured when the seats were taken. Comparing that against what the partner claims is how you catch a forged price or channel. - **Booking a hold needs no channel argument.** The hold already carries its authorization, so this works even if the buyer's session has since expired. It is *direct label booking* (booking without a `holdId`) that must name a channel on private inventory. See [the behavior change](/server-api/booking). ## 7. Reconcile The `seat.booked` webhook names the channel that made the sale: ```json { "event": "seat.booked", "payload": { "eventId": "ev_9f3a", "bookingRef": "agency-order-4412", "items": [ { "label": "A-12", "unitPrice": 75, "currency": "USD", "channelId": "chn_9f1c", "channelExternalRef": "travel-agency-a", "accessSource": "partner", "partnerRef": "travel-agency-a" } ] } } ``` Attribution is recorded at booking time and never rewritten, so it stays correct even after you move or archive the channel. Reconcile on `bookingRef`, your own order id, the partner's order id, `channelExternalRef`, and the delivery's `occurrenceId`. For the running totals, read [`GET /v1/events/:key/channels/report`](/server-api/reports): what each channel holds now, beside what it sold. ## 8. Wind the allocation down When the agency's window closes, archive the channel with a destination: ```bash curl -s -X POST "https://api.seatlayer.io/v1/events//channels/chn_9f1c/archive" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{"destination": "public", "reason": "Agency window closed"}' ``` Unsold seats return to Public sale, the agency's buyer sessions are revoked in the same operation, and the sales it already made stay attributed to it forever. If anyone is mid-checkout, archive is refused with a `retryAfterMs` rather than stranding them. ## Go-live checklist - [ ] The allocation quantity, public-sale access, booking owner, payment owner, and cancellation policy are agreed in writing. - [ ] Preview matches the intended inventory. - [ ] If a partner report is shared, its revenue scope and expiry are intentional, and an owner is responsible for revoking it. - [ ] If Partner access is granted, the role, permissions, release date, and access end match the agreement, and the people invited are the ones who should act for the agency. - [ ] Test and live credentials and scopes are separate. - [ ] The exact production origin is registered on the session. - [ ] Buyer eligibility is authenticated **before** minting, by your rule. - [ ] The token is memory-only, redacted from logs, refreshed on expiry, and revocable. - [ ] The hold is inspected and priced authoritatively before payment. - [ ] Booking uses a stable `bookingRef` and is safe to retry. - [ ] Allocation exhausted, event closed, access expired, seat conflict, and network errors each show distinct buyer guidance. - [ ] Webhook signature and replay handling are tested. - [ ] Public and partner flows are both tested against one live-like event. - [ ] You can pause access and return unused allocation without a chart republish. ## Related - [Partner guide: sell an allocation](/integrations/partner-selling/): the invited partner's hosted-link and own-system guide - [Sales channels and allocations](/platform/sales-channels/) - [Channels API](/server-api/channels) · [Buyer access sessions](/server-api/buyer-access-sessions) - [Hosted access links](/server-api/channels): when you cannot authenticate each buyer - [Ticketing backend integration](/integrations/ticketing-backend) # Integrate a Fixed Renewable Season > Build a same-seat Season end to end, from Plan publication and checkout to renewal, then run holders, refunds, and reports from your server. Source: https://docs.seatlayer.io/integrations/seasons/ · Markdown: https://docs.seatlayer.io/integrations/seasons/index.md Use a Fixed Renewable Season when one buyer should receive the same assigned seats across an immutable programme of 2–20 Events and may keep a protected path to those seats in a successor Plan. This page owns the **end-to-end integration**. Use the [Season picker](/buyer-sdk/seasons/) for buyer UI options and the [Seasons API](/server-api/seasons/) for exact operations, request fields, responses, lifecycle recovery, and support tooling. If your holders live in your own CMS or CRM, [step 8](#8-run-the-year-from-your-server) walks the year from your server: holders, refunds, renewals, reports, and the webhooks that keep the two in step. > **Who takes the money** > > A season is sold one of two ways. With **SeatLayer checkout**, SeatLayer > takes payment on the season page and records box office sales, sends tickets > for every date, and lets you refund or cancel from the Holders tab. With > **your own checkout**, your system owns package pricing, discounts, tax, > payment, orders, passes or tickets, refunds, and customer support. Either > way SeatLayer owns the Plan membership, same-seat availability, holds, > bookings, Contracts, Seat Rights, and renewal inventory. Steps 1–7 below > are the own-checkout integration, with the SeatLayer checkout sale in > [step 5](#with-seatlayer-checkout). > [Step 8](#8-run-the-year-from-your-server) runs the season from your server > and [step 9](#9-run-the-season-from-the-dashboard) from the dashboard. > **Public Season surfaces** > > The released Season contract is available through browser SDKs, REST, and all > seven server SDKs. Native SDKs are not supported. > [Season Best Available](/server-api/seasons/#season-best-available) picks > the same seats across every date for a buyer session. > Organizers can also create and run Seasons from the dashboard's **Seasons** > page. ## Confirm that Season is the right model | Requirement | Use | |---|---| | One dated occurrence | [Event flow](/start/how-it-works/) | | One atomic purchase across a fixed 2–8 Event run, with no retained renewal right | [Performance Group](/integrations/performance-groups/) | | Same assigned seats across an immutable 2–20 Event Plan, with durable rights and renewal | **Fixed Renewable Season** | A Season is not a marketing name for several events. It adds four inventory concepts that a Performance Group deliberately does not have: - a **Plan** fixes the complete Event membership and publication identity; - a **Contract** records the holder's relationship to that Plan; - a **Seat Right** protects the holder's assigned seats; and - a **Renewal Offer** coordinates those rights into a successor Plan. Read the full [inventory model comparison](/start/inventory-models/) before provisioning either multi-event product. ## The complete Season journey ## 1. Prepare compatible Events Create one Event for every dated occurrence. Before creating the Season, confirm that all Events share: - the same organization and workspace; - the same test or live mode; - the same published Chart snapshot and assigned-seat inventory model; - the same venue, timezone, and currency; - a start time. Who takes the money is set on the Season itself, not on each Event. See [Choose who takes the money](#choose-who-takes-the-money). The currency is a compatibility guard. It does not make Event prices the Season package price. Your commerce system calculates the package amount. Season v1 does not inherit private Sales Channel allocation. Use ordinary public Event inventory for the Plan; treat [Sales Channels](/platform/sales-channels/) as a separate per-Event access model, not as a Season membership system. ## 2. Validate, create, activate, and publish Preflight the intended Event set before a write. Validation reports the exact incompatible Event and field so an operator can correct the source rather than discovering the mismatch during publication. ```http title="Validate compatible Season inventory" POST /v1/seasons/validate HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "eventKeys": ["ev_opening", "ev_matinee", "ev_closing"] } ``` Create the draft with a retry-safe idempotency key: ```http title="Create a Season draft" POST /v1/seasons HTTP/1.1 Authorization: Bearer sk_test_•••••••• Idempotency-Key: membership-2027-draft Content-Type: application/json { "name": "Main stage membership", "edition": "2027", "eventKeys": ["ev_opening", "ev_matinee", "ev_closing"] } ``` The draft contains a Plan. Activate the Season structure, then publish that exact Plan revision. These are separate transitions because structural review and buyer-facing publication are separate operational decisions. ```http title="Activate the Season structure" POST /v1/seasons/sea_main_stage/activate HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "expectedRevision": 1 } ``` ```http title="Publish the immutable Plan" POST /v1/seasons/sea_main_stage/plans/spl_2027/publish HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "expectedRevision": 2 } ``` Lifecycle mutations may return `202 Accepted`. Follow the returned `Location`, honour `Retry-After`, and retain the original operation identity until it is terminal. Do not create a replacement Season because one response was lost. Activation does not publish a Plan, and publication does not open sales. ### Or let the draft create its dates and publish in one call A draft can create its own dates instead of attaching Events you already made. Here it makes three weekly dates: ```http title="Add dates to the draft" POST /v1/seasons/sea_main_stage/performances HTTP/1.1 Authorization: Bearer sk_test_•••••••• Idempotency-Key: membership-2027-dates Content-Type: application/json { "titlePattern": "Autumn night {n}", "firstStartsAt": 1792346400000, "timezone": "Europe/Amsterdam", "repeat": "weekly", "count": 3 } ``` A package is a Plan: which dates a holder gets. Add more with `POST /v1/seasons/:key/plans`, set each package's price per seat category with `PUT /v1/seasons/:key/plans/:planKey/prices`, then publish everything in one call: ```http title="Publish the season and its packages" POST /v1/seasons/sea_main_stage/publish HTTP/1.1 Authorization: Bearer sk_test_•••••••• Idempotency-Key: membership-2027-publish ``` Keep each package's `planKey`: every later call and webhook names a package by it (`packageId`). These routes are REST only; the [Seasons API](/server-api/seasons/#rest-operations-beyond-the-48) lists every call no released server SDK has yet. **Webhooks:** `event.created` for each new date, `season.structure.activated`, and `season.plan.published` for each package. ### Choose who takes the money Set the checkout with `PATCH /v1/seasons/:key` and `{ "expectedRevision": …, "checkoutMode": "server" }` for your own checkout, or `"seatlayer"` for SeatLayer checkout. SeatLayer checkout needs a connected payment gateway that settles the Season's currency; without one the call returns `409 gateway_required`. Once the Season has a live booking, the checkout mode is fixed and a change returns `409 checkout_mode_locked`. ## 3. Rehearse before opening sales The sales gate proves that the inventory and recovery path works as one system. In test mode, complete one coherent journey through the real public surfaces: 1. mint a Season buyer session for the test origin; 2. create a same-seat hold with `SeasonPicker`; 3. inspect and book that hold from the trusted backend; 4. cancel the test booking with an explicit Seat Right disposition; and 5. receive successful webhook delivery for the required lifecycle events. Then ask SeatLayer to validate the retained evidence: ```http title="Validate buyer rehearsal" POST /v1/seasons/sea_main_stage/buyer-rehearsals/validate HTTP/1.1 Authorization: Bearer sk_test_•••••••• ``` Once rehearsal succeeds, open sales against the revision you reviewed: ```http title="Open Season sales" POST /v1/seasons/sea_main_stage/sales/open HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "expectedRevision": 3 } ``` **Webhook:** `season.sales.opened`. If the rehearsal is incomplete, fix the failed hold, booking, cancellation, or webhook-delivery boundary. Do not bypass the gate or paste operation ids into an operator screen. ## 4. Mint buyer access and render the picker Authenticate and authorize the buyer in your application first. Your backend then mints one short-lived, exact-origin Season session and returns only the token and expiry to the browser. ```http title="Create Season buyer access" POST /v1/seasons/sea_main_stage/buyer-access-sessions HTTP/1.1 Authorization: Bearer sk_live_•••••••• Content-Type: application/json { "allowedOrigin": "https://tickets.example.com", "includePublic": true, "maxQuantity": 4, "buyerRef": "member_8372" } ``` Keep the secret key on the server and the returned `bss_…` token in browser memory. Never place either credential in a URL, analytics property, exception message, or persistent browser storage. Render `SeasonPicker` with a token provider. The picker shows the complete Plan, finds the availability intersection across every included Event, and creates one all-or-nothing same-seat hold. ```ts title="browser/season-selection.ts" import { SeasonPicker } from "@seatlayer/js"; const picker = new SeasonPicker({ container: "#season-picker", season: "sea_main_stage", buyerAccessTokenProvider: () => fetch("/api/seatlayer/season-access", { method: "POST", credentials: "same-origin", cache: "no-store", }).then((response) => response.json()), onContinue: (handoff) => { startCheckout({ seasonKey: handoff.seasonKey, operationId: handoff.operationId, holdId: handoff.holdId, }); }, }); await picker.render(); ``` Use the picker's `offer` content only to explain the package. It is display copy, not trusted pricing. Do not multiply an Event price in the browser and call the result a Season total. ## 5. Inspect, price, pay, and book Send the opaque operation identity to your backend. Inspect the retained hold there and verify that it is still committed to the expected Season and Plan. The inspection returns trusted Event and seat allocations but deliberately no authoritative commercial total. Your backend should then: 1. calculate the package amount, benefits, tax, and discounts from its own catalogue; 2. create or recover one commercial order; 3. authorize or capture payment according to its recovery policy; and 4. book the same Season operation with one caller-stable action id and the order's immutable `bookingRef`. ```http title="Book the Season hold" POST /v1/seasons/sea_main_stage/holds/sop_01/book HTTP/1.1 Authorization: Bearer sk_live_•••••••• Content-Type: application/json { "bookActionId": "season-book:order_1042", "bookingRef": "order_1042" } ``` If booking returns `202 Accepted`, poll the returned booking location. Reuse the same `bookActionId` and `bookingRef` after a lost response. Never issue the pass or per-occurrence tickets until the booking is terminally successful. **Webhook:** `season.booking.confirmed`. ### With SeatLayer checkout Connect payments and set package prices, then send buyers to the season's page, `https://app.seatlayer.io/s/`. SeatLayer takes payment, books every date, and emails the tickets. Your system does not call anything to make the sale. Your system hears about each sale through `season.order.paid`, with the order, package, seats, amount, and holder. `channel` is `online` for SeatLayer checkout and `box_office` for a counter sale; `method` is the payment provider online, and `cash`, `card_external`, or `complimentary` at the counter. A complimentary sale arrives with `amountMinor: 0`. The payload is on [Webhook events](/webhooks/events/#season-orders). ## 6. Store the commerce-to-inventory links After booking, retain these relationships in your own order system: | Host record | SeatLayer identity | Why retain it | |---|---|---| | Commercial order | `bookingRef` and booking action | Retry, support, and reconciliation | | Member or account | `holderRef` / buyer reference | Find the correct Contract without treating it as payment identity | | Membership edition | Season key and published Plan identity | Explain exactly which occurrence set was sold | | Assigned package | Contract and Seat Right identities | Renewal, cancellation, and support | | Fulfilment | Child Event allocations | Issue the correct passes or tickets for every occurrence | SeatLayer's Contract and Seat Rights prove inventory entitlement. Your order still proves the amount charged, tax, payment status, refund state, benefits, and fulfilment. ## 7. Renew, cancel, and operate deliberately For an incumbent programme, import holders only through the Season import workflow and keep your own stable holder reference. For renewal: 1. create and publish the successor Plan; 2. generate time-bounded Renewal Offers for eligible Contracts; 3. present the commercial renewal terms in your product; 4. inspect the accepted renewal inventory on your server; 5. take payment; and 6. commit the offer with a stable action and order reference. A buyer renewal intent is not a completed renewal and does not authorize a charge. Treat it as the start of your commerce workflow. Cancellation requires an explicit Seat Right disposition: - **preserve** keeps the future right while cancelling the current inventory booking according to your policy; - **release** returns the right as well as the booked allocation. Cancelling through the API never moves money. With your own checkout, pay the buyer back in your own system. A season sold through SeatLayer checkout is refunded from the [Holders tab](#holders-tab) instead. Use Season operations, reports, support lookups, and the retained outbox to reconcile uncertain or partially terminal outcomes; do not retry with new action ids or attempt independent child-Event rollback. ## 8. Run the year from your server Use this step when your season ticket holders live in your own system (a CMS, a CRM, a membership database) and SeatLayer holds the seats. Every call uses a secret key from your server. A test key sees only test seasons and a live key only live ones. The holder, order, renewal-email, and report calls are REST today; no released server SDK has them yet, so call them over HTTP from any language. Exact fields, answers, and errors are on the [Seasons API](/server-api/seasons/#holders-orders-renewal-emails-and-reports). > **The box office stays in the dashboard** > > Selling at the counter, and cancelling a counter sale, happen in the > dashboard only. At the counter, staff take the money by hand and record how > they were paid; SeatLayer moves none of it, so there is nothing for an API > call to do. Your system still hears about every counter sale through > `season.order.paid`, with `channel: "box_office"`. ### List holders One list for everyone who holds the season, however they bought: ```http title="First page of holders" GET /v1/seasons/sea_main_stage/holders?status=active&limit=50 HTTP/1.1 Authorization: Bearer sk_live_•••••••• ``` Follow `nextCursor` until it is `null`. Match rows to your records by `orderId` (SeatLayer sales), `bookingRef`, or `holderRef` (your own bookings). The list is a read: keep it current with the [webhooks below](#keep-your-system-in-step-with-webhooks) and read it again to reconcile. ### Refund, cancel, cancel one date, resend These act on orders SeatLayer took. Refund and both cancels need an `Idempotency-Key`; reuse it when you retry, so a lost answer never becomes a second refund. ```http title="Refund part of an order" POST /v1/seasons/sea_main_stage/orders/ord_ea11/refund HTTP/1.1 Authorization: Bearer sk_live_•••••••• Idempotency-Key: refund-ord_ea11-1 Content-Type: application/json { "amountMinor": 5000 } ``` Leave out `amountMinor` to refund what is left; a full refund also gives every date back. Each refund sends `season.order.refunded`, and a full refund also sends `season.booking.cancelled`. | To | Call | Webhook | |---|---|---| | Cancel with no money moving (a refund made another way) | `POST /orders/:orderId/cancel` with `{ "reason": "refunded_elsewhere", "refundConfirmed": true }` | `season.booking.cancelled` | | Take back one date | `POST /orders/:orderId/dates/:eventKey/cancel` | `season.occurrence.returned` | | Email the tickets again | `POST /orders/:orderId/resend` with optional `{ "email" }` | none yet | A box office order answers `409 box_office_order_use_dashboard` to cancel: staff cancel it at the counter after handing the money back. ### Renewal links and reminders With SeatLayer checkout, holders renew and pay on a SeatLayer page. Send the offers (`POST /v1/seasons/:key/renewal-offers` with the new package and a deadline), then email every holder: ```http title="Email invitations and reminders" POST /v1/seasons/sea_main_stage/renewal-notices HTTP/1.1 Authorization: Bearer sk_live_•••••••• Content-Type: application/json {} ``` SeatLayer emails each holder now, then 7 days and 1 day before the deadline. Fill in a missing address with `POST /renewal-offers/:offerId/contact`, and read where every offer stands with `GET /renewal-hosted`. To put a link in your own email, call `POST /renewal-offers/:offerId/link`: the URL is shown once, and each call makes a new link and turns the old one off. `POST /renewal-offers/:offerId/resend` sends one holder a fresh invitation. **Webhooks:** `season.renewal.offered`, `season.renewal.intent_received`, `season.renewal.renewed`, `season.renewal.declined`, `season.renewal.lapsed`, and `season.order.paid` when a renewal is paid through SeatLayer. ### Import last season's holders Run the import as a dry run first: ```http title="Dry run" POST /v1/seasons/sea_main_stage/imports/from-season HTTP/1.1 Authorization: Bearer sk_live_•••••••• Idempotency-Key: bring-2026-check Content-Type: application/json { "sourceSeasonId": "sea_2026", "successorPlanActivationId": "spa_2027", "dryRun": true } ``` Send the same body with `"dryRun": false` and a new key to bring them across (`201`). Running it twice is safe. Each holder then has a renewal right and shows in the holder list as `kind: "imported"`. There is no webhook yet; check back with `GET /v1/seasons/:key/imports/:importId`. ### Give back one date A holder who cannot come to one date can give it back, and the seats go on sale for that date only. For a booking your own system made: ```http title="Return one date" POST /v1/seasons/sea_main_stage/occurrences/ev_matinee/returns HTTP/1.1 Authorization: Bearer sk_live_•••••••• Content-Type: application/json { "actionId": "sor_order_1042_matinee", "bookingRef": "order_1042", "planActivationId": "spa_2027", "labels": ["A-1"], "source": { "bookingActionId": "season-book:order_1042" } } ``` While those seats are still unsold, `POST /occurrences/:eventKey/reclaims` gives them back to the holder. For a SeatLayer order, use cancel one date instead; the return route answers `409 hosted_order_use_date_cancel`. **Webhooks:** `season.occurrence.returned` and `season.occurrence.reclaimed`. ### Reports and CSVs `GET /v1/seasons/:key/reports/sales` has money per package, channel, and currency, seats per package and per date, holder counts, and the renewal rate: totals only, no buyer. `GET /reports/seats` counts free seats per package and per date, and `GET /seat-counts` gives seats sold. For a spreadsheet, `GET /reports/holders.csv` and `GET /reports/orders.csv` answer `text/csv`, the same files as the dashboard. Past 50,000 holders the holder file is `413 download_too_large`; filter with `?status=`. A season on your own checkout has no orders file (`409 season_sold_elsewhere`). ### Keep your system in step with webhooks Subscribe once per key. A subscription made with a live key only receives live seasons, and a test key only test ones: ```http title="Subscribe" POST /v1/webhooks HTTP/1.1 Authorization: Bearer sk_live_•••••••• Content-Type: application/json { "url": "https://cms.example.com/seatlayer/webhooks", "events": [ "season.order.paid", "season.order.refunded", "season.booking.confirmed", "season.booking.cancelled", "season.occurrence.returned", "season.occurrence.reclaimed", "season.renewal.offered", "season.renewal.renewed", "season.renewal.declined", "season.renewal.lapsed", "season.sales.opened", "season.sales.paused", "season.sales.ended", "season.performance.added", "season.performance.removed", "season.amendment.applied" ] } ``` | When | Event | |---|---| | A SeatLayer checkout, renewal, or box office sale is paid | `season.order.paid` | | SeatLayer records a refund, full or partial | `season.order.refunded` | | Your server books a hold | `season.booking.confirmed` | | A booking or order is cancelled on every date | `season.booking.cancelled` | | One date is given back, or taken back again | `season.occurrence.returned` · `season.occurrence.reclaimed` | | A renewal offer is made, answered, paid, or lapses | `season.renewal.*` | | Sales open, pause, or end | `season.sales.*` | | A published package gains or loses a date, or a date moves | `season.performance.added` · `season.performance.removed` · `season.amendment.applied` | Check the signature, then act once per `occurrenceId`: a delivery can arrive more than once. If your endpoint was down, `GET /v1/seasons/:key/outbox` lists every season event and `POST /v1/seasons/:key/outbox/:occurrenceId/replay` sends one again. See [Webhook events](/webhooks/events/#season-orders) for the payloads. Some changes have no event yet. Until they do, read the state back: | Change | Check back with | |---|---| | Tickets emailed again | The `resend` answer (`sentTo`) is the record | | A renewal email sent | `GET /renewal-hosted` (`notices.invitedAt`, `lastReminderAt`) | | An import finished | `GET /imports/:importId` | | Draft, date, or price changes before publishing | `GET /v1/seasons/:key`, `GET /performances`, `GET /prices` | ## 9. Run the season from the dashboard Everything in this step happens on the season's page under **Seasons** in the dashboard. Admins and designers can make changes; viewers can look. ### Holders tab The Holders tab lists every season ticket holder: people who bought through SeatLayer checkout or at the box office, bookings your own system made, and holders brought over from last season. Filter by All, Active, Cancelled, or Renewals. The **⋯** menu on a holder offers what fits how they bought: | Sold through | Actions | |---|---| | SeatLayer checkout | Refund…, Cancel season ticket…, Cancel one date…, Resend tickets | | Box office | Cancel season ticket…, Cancel one date…, Resend tickets | | Your own checkout | Cancel season ticket…, Cancel one date… | - **Refund…** pays back all or part of what is left on the order. A full refund cancels every date: the seats go back on sale and the tickets stop working. A partial refund keeps the seats and tickets. - **Cancel season ticket…** cancels every date and puts the seats back on sale. It returns no money. For SeatLayer checkout, refund first or confirm the money was handled another way; you can email the buyer. For a box office sale, give the money back the way it was paid. For your own checkout, choose whether the holder keeps the right to renew these seats next season. - **Cancel one date…** cancels a single date. That date's tickets stop working and its seats go back on sale; every other date stays. It returns no money, so refund that date separately if you need to. A date someone was already admitted with cannot be cancelled. - **Resend tickets** emails the buyer their tickets again. Refunds, and cancelling a season ticket or one of its dates when it was paid through SeatLayer or at the box office, are admin only. Your server can list holders and refund, cancel, or resend with a secret key through the [holder API](/server-api/seasons/#holders-orders-renewal-emails-and-reports); a box office sale is cancelled here only. ### Renewal links and emails Once last season's holders are in (see the next section), **Send renewal offers** gives each one a deadline. With SeatLayer checkout you can also tick **Email holders their renewal link**: SeatLayer emails the link now, then a reminder 7 days and 1 day before the deadline when there is time for one. Each link opens `/r/` on SeatLayer. The holder does not log in. They see their seats, then renew and pay, or give the seats up. Holders can pay only once payments are connected and every package has a price; the Holders tab says what is missing. The **⋯** menu on an imported holder lets you copy their link, email it again, change their email and name, give them more time, or decline for them. Copying or emailing a new link, or changing the email, stops the old link working. Declining puts their seats back on general sale and cannot be undone. With your own checkout, SeatLayer sends no renewal emails: your system tells holders and confirms their renewal. ### Bring holders from last season **Bring holders from a previous season** on the Holders tab picks last season and shows who would come across before anything is saved. It brings every holder who still has a seat on at least one date of that season. Test bookings and fully refunded or cancelled orders are left out. Holders who bought through SeatLayer checkout come with their name and email, so SeatLayer can email their offer; holders your own system booked come without an email. A holder is refused, with the reason, when a seat is not on the new floor plan, is already held by someone else, or the holder was already brought across. You can also import holders from a CSV file, which runs the same checks first. ### Sell a season at the box office A season sold through SeatLayer checkout can also be sold at the counter. Open it from **Sell this season** on the season page, **Sell at the counter** on the Sell tab, or **Sell a season ticket** at the box office for any of the season's dates. Admins, designers, and box office staff can sell. - Only seats that are free on every date are offered. - The price is the package price per seat. Staff do not type it. - Staff record how the buyer paid. SeatLayer moves no money for a counter sale. - One sale holds up to 10 seats, and season sales must be open. - The buyer gets tickets for every date, with a receipt to print or email. To take a counter sale back, an admin cancels it on the Holders tab. ### Reports tab The Reports tab (admins and designers) shows what the season sold: - **Money** by package, channel (online or box office), and currency. Amounts in different currencies are never added together. Box office amounts are what staff recorded. A season on your own checkout has no amounts. - **Seats** sold per package and per date, leaving out test bookings. A cancelled date lowers only that date. Seats still free are shown per package and per date. - **Holders and renewals**, including the renewal rate. Two downloads take the season to a spreadsheet. **holders.csv** has every holder, or only one status (active, cancelled, or renewals); one file holds up to 50,000 holders, so filter by status past that. **orders.csv** has every order SeatLayer took for the season. It is not offered for a season on your own checkout. Season ticket orders are reported on the season only. A season date's event totals, its Orders list, and its orders.csv leave them out, so a season sale is never counted twice. The door list for each date still has their tickets. ## Go-live checklist - [ ] The Event set is final, compatible, and uses one published Chart snapshot. - [ ] The buyer chooses seats on the released browser picker, or your page calls [Season Best Available](/server-api/seasons/#season-best-available); the plan does not depend on a native Season picker. - [ ] Product owners chose Season instead of a single Event or Performance Group for a documented reason. - [ ] Package price, benefits, tax, payment, refund, pass, and support ownership are implemented outside the inventory handoff. - [ ] Test rehearsal proves hold, inspect, book, cancel, and webhook delivery. - [ ] Browser credentials are exact-origin, short lived, kept in memory, and never carry a secret key. - [ ] Lost responses reuse the same lifecycle and booking operation identities. - [ ] The commercial order stores its Season, Plan, booking, Contract, and Seat Right relationships. - [ ] Fulfilment covers every included Event and begins only after booking succeeds. - [ ] Cancellation has an explicit payment policy and Seat Right disposition. - [ ] Renewal terms and deadlines are owned by the host commerce system and tested against a successor Plan. ## Continue by surface | If you are implementing… | Continue with | |---|---| | Buyer selection and UI callbacks | [Season picker](/buyer-sdk/seasons/) | | Catalogue, lifecycle, booking, import, renewal, and support operations | [Seasons API](/server-api/seasons/) | | A shorter multi-performance purchase without renewal rights | [Performance Group integration](/integrations/performance-groups/) | | A mobile Event picker | [Native app SDKs](/buyer-sdk/mobile/) | # Integrate a ticketing backend > Connect SeatLayer holds and bookings to your existing order, payment, and reconciliation flow. Source: https://docs.seatlayer.io/integrations/ticketing-backend/ · Markdown: https://docs.seatlayer.io/integrations/ticketing-backend/index.md Use this pattern when you already own the event catalogue, buyer, checkout, payment, commercial Orders, receipts, tickets, delivery, scanning, and refunds. SeatLayer owns live inventory. The buyer/server handoff is `holdId`; your stable `bookingRef` links the resulting inventory booking to your commercial order. > **Platform builders use the same purchase flow** > > Multi-tenant products add [workspace-scoped](/platform/workspaces/) > provisioning and webhook routing, > but the browser-hold and server-book boundary does not change. ## The transaction boundary The browser must not post trusted prices, seat labels, or a booking request. Those values either come from hold inspection or are owned by your server. ## Implement the flow 1. **Configure the buyer's browser-access lane** For an ordinary Platform Public sale, register the checkout page's exact Embed domain and pass the matching account `publicKey`; SeatLayer returns a Public-only in-memory session with the chart and compact inventory snapshot. For login, presale, partner, or channel inventory, your authenticated backend instead calls [`POST /v1/events/:key/buyer-access-sessions`](/server-api/buyer-access-sessions/) and supplies the result through `buyerAccessTokenProvider`. Managed public/unlisted events need neither browser credential. 2. **Create the hold in the browser** Let `SeatPicker` hold during its checkout action, or explicitly call `SeatingChart.hold()`. Persist the resulting `holdId` with the in-progress order. 3. **Inspect the hold on your server** Resolve the hold using a secret key. Reject an expired hold and calculate the order from the returned items rather than browser input. 4. **Run payment and order logic** Create a durable order id. Prefer a payment authorization that can be captured after booking when your payment provider supports it; otherwise define the void/refund compensation path before launch. 5. **Book atomically** Call the book endpoint using the order id as `bookingRef`. Reuse that exact value for every retry of the same purchase. 6. **Confirm, fulfil, and reconcile** Drive the buyer response from the synchronous booking result, then create and deliver tickets from your platform. Process signed inventory webhooks idempotently to repair or confirm downstream state. ## 1. Create and hand off the hold With the headless chart, call `hold()` after selection: ```js title="browser/seats.js" const chart = new seatlayer.SeatingChart({ container: "#chart", event: "", publicKey: "", // ordinary Public Platform sale onHold: async ({ holdId, expiresAt }) => { await fetch("/api/checkout/start", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ holdId, expiresAt }), }); }, }); await chart.render(); await chart.hold(); ``` For a scoped private audience, replace `publicKey` with a server-backed `buyerAccessTokenProvider`. The hold and booking boundary does not change. `SeatPicker` performs the hold as part of its CTA and exposes the same boundary through `onCheckout(_, _, handoff)`. Prefer its third `handoff` argument for new integrations. > **Every chart category is sellable by default** > > The Buyer SDK offers every category on the published chart. If your platform > prices tickets per category, make sure each chart category has a matching > ticket before the event goes on sale, or restrict the buyer with > `selectableObjects`. Otherwise a buyer can hold seats your checkout cannot > price. Pass your own prices with `pricing.prices` so the map shows what > checkout will charge. ## 2. Inspect authoritative items **GET /v1/events/:eventKey/holds/:holdId**. Authentication: Secret key ```js title="server/inspect-hold.js" const holdResponse = await fetch( `https://api.seatlayer.io/v1/events/${eventKey}/holds/${holdId}`, { headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, }, }, ); if (!holdResponse.ok) { return reply.status(409).send({ code: "hold_unavailable" }); } const hold = await holdResponse.json(); if (hold.status !== "active" || hold.expiresAt <= Date.now()) { return reply.status(409).send({ code: "hold_unavailable" }); } const total = hold.items.reduce( (sum, item) => sum + item.unitPrice * (item.quantity ?? 1), 0, ); ``` Use the returned currency, tier, quantity, and price fields supported by your order model. Treat the browser handoff as display context, never the charging authority. ## 3. Book after the payment decision **POST /v1/events/:eventKey/book**. Authentication: Secret key ```js title="server/book-order.js" const bookingResponse = await fetch( `https://api.seatlayer.io/v1/events/${eventKey}/book`, { method: "POST", headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ holdId, bookingRef: order.id, }), }, ); if (bookingResponse.status === 409) { await voidOrRefundPayment(order); return reply.status(409).send({ code: "inventory_conflict" }); } if (!bookingResponse.ok) { // State may be uncertain after a timeout or transport failure. // Retry with the same order.id, never mint a new bookingRef. throw new Error(`SeatLayer booking failed: ${bookingResponse.status}`); } await markOrderConfirmed(order.id); await issueAndDeliverYourTickets(order.id); ``` `bookingRef` is your idempotency boundary. If a request times out, retry the same purchase with the same value. A confirmed replay is safe, but the current replay response contains `booked: []`; keep the durable receipt details in your order record and webhook handler. ## Choose a payment sequence | Sequence | Best when | Required recovery | |---|---|---| | Authorize → book → capture | Your provider supports separate authorization and capture | Void authorization if booking conflicts | | Charge → book | Immediate capture is required | Refund promptly on conflict and monitor compensation failures | | Book → charge | Only for trusted/no-payment flows where unpaid inventory is acceptable | Cancel booking if payment fails | Do not hide this tradeoff inside a generic “checkout failed” branch. Record each transition so support staff can distinguish payment state from inventory state. ## Reconcile signed webhooks ```js title="server/webhook.js" app.post("/webhooks/seatlayer", rawBodyMiddleware, async (req, res) => { verifySeatLayerSignature(req.rawBody, req.headers); const { event, payload } = JSON.parse(req.rawBody); if (event === "seat.booked") { await confirmOrderIdempotently(payload); } res.sendStatus(200); }); ``` Webhooks are the reconciliation path, not a reason to keep the buyer waiting. Verify the signature against the raw request body, return a successful status quickly, and process every delivery idempotently. ## Expected failure behavior | Failure | Application response | |---|---| | Hold is expired before payment | Clear the stale cart and return to selection | | Booking returns `409` | Void/refund as needed; do not partially confirm | | Booking response times out | Retry with the same `bookingRef` | | Payment succeeds but booking remains uncertain | Keep an explicit recovery state and reconcile/retry | | Webhook arrives twice | Deduplicate by delivery/event identity and order state | | Webhook arrives before local confirmation commits | Upsert idempotently rather than rejecting it | To reverse a completed sale, run your ticket cancellation, monetary refund, and SeatLayer inventory cancellation as observable operations in your system. SeatLayer releases inventory; it does not move money or cancel/deliver your tickets. > **Inventory booking is not SeatLayer fulfilment** > > The `/book` call does not create a supported SeatLayer commerce Order, > ticket, QR/PDF/email, attendee/door record, or refund for this product. Keep > those records in your platform even if an older deployment returns extra > commerce-shaped fields. ## Production checklist - [ ] The browser sends only `holdId` and non-authoritative UI context. - [ ] Hold inspection happens with a server-only secret key. - [ ] Price and currency are derived from the inspected hold. - [ ] One durable order id is reused as `bookingRef`. - [ ] Our platform creates, delivers, recovers, and scans its own tickets. - [ ] Payment compensation for `409` is tested. - [ ] Timeout retries use the same request identity. - [ ] The webhook handler verifies the raw body and is idempotent. - [ ] Support can see payment state and inventory state separately. - [ ] Test mode has completed a full purchase and duplicate retry. Continue with the [booking endpoint](/server-api/booking), [idempotency and conflict behavior](/server-api/idempotency-and-conflicts), or [webhook signatures](/webhooks/signatures). # WordPress > Add a seating chart to a WordPress page with a block or shortcode, and take payment through your own payment gateway. Source: https://docs.seatlayer.io/integrations/wordpress/ · Markdown: https://docs.seatlayer.io/integrations/wordpress/index.md The SeatLayer plugin puts a live seating chart on any WordPress page or post. Install it from [wordpress.org/plugins/seatlayer-seating-charts](https://wordpress.org/plugins/seatlayer-seating-charts/), or from **wp-admin > Plugins > Add New** by searching for *SeatLayer*. The [WordPress seating chart plugin](https://seatlayer.io/integrations/wordpress/) page covers what it does before you install it. Visitors pick seats and pay through **your own** payment gateway. Your payment gateway controls processing fees, applicable taxes, and payout timing; SeatLayer bills Hosted Ticket usage separately. The renderer behind the chart is [benchmarked at 200,000 seats](https://app.seatlayer.io/demo/play/century-stadium-200k), so the same plugin serves a small theatre and a stadium. The event must use a published reserved-seat Chart. GA capacity may be included inside that Chart; the plugin is not a pure-GA ticketing flow without a seating chart. > **What this plugin is** > > An embed for a self-serve Hosted Ticketing event. Venues, charts, and events are still designed in > the SeatLayer dashboard; the plugin renders one of your events and hands buyers > to checkout. It is not a standalone seating designer. ## Before you start You need four things, in this order: 1. **A SeatLayer account.** Hosted Ticketing is self-serve; there is no access request or approval step. 2. **A published chart and a Managed event.** Create both at [app.seatlayer.io](https://app.seatlayer.io). Note the event key. It is the `slug` you chose and the value the plugin needs. 3. **A connected payment account.** In the dashboard, go to **Payments** and connect your payment gateway. Until you do, the chart will render but buyers will see "this event is not taking online payments yet". A free event does not need a payment account. 4. **The plugin, installed and activated** on your WordPress site. In **wp-admin > Plugins > Add New**, search for *SeatLayer* and activate **SeatLayer Seating Charts**, or install it from the directory listing at [wordpress.org/plugins/seatlayer-seating-charts](https://wordpress.org/plugins/seatlayer-seating-charts/). ## Add a chart to a page Use the **SeatLayer seating chart** block, choose your event, and publish. If you prefer a shortcode, or you are on the classic editor: ```text title="Shortcode" [seatlayer_chart event="summer-fest"] ``` Optional attributes: | Attribute | Default | What it does | |---|---|---| | `event` | None | Required. Your event key. | | `height` | `640` | Container height in pixels (320–2000). | | `max_selection` | `10` | Most seats one buyer may select (1–50). | | `locale` | buyer's browser | Any of the 37 supported languages, e.g. `de`, `ja`, `zh-Hant`. Omit it and each buyer gets their own browser language, English if we do not ship theirs. | | `currency` | event default | ISO code, e.g. `USD`. | ```text title="With options" [seatlayer_chart event="summer-fest" height="720" max_selection="4"] ``` ## The secret key is optional The plugin renders a **Managed public/unlisted event**, so its buyer chart works with only the event key. This is the anonymous Managed-event path, not the publishable-key bootstrap used by a Platform/SDK event. A custom Platform embed uses `publicKey` for Public sale or an authenticated token provider for private audiences. A secret key under **Settings → SeatLayer** does exactly one thing: it lets the block editor list your events in a dropdown instead of you pasting keys. > **Where the key is stored** > > A secret key saved here lives in your WordPress database, like any other plugin > setting, and it can create and modify events. Anyone with database or > administrator access to that site can read it. If that is not acceptable, leave > it blank and type event keys by hand. Nothing else in the plugin needs one. ## How checkout works This checkout design keeps a WordPress site out of the payment path: 1. The buyer selects seats on your page. The widget creates a **hold** on SeatLayer's servers. A competing request receives a conflict instead of a second successful hold. 2. On checkout, the buyer is sent to SeatLayer's hosted payment page, carrying only the hold id. **No amount is passed.** The server recomputes the total from its own hold records, so nothing on your site can change what a buyer pays. 3. They pay through your own connected payment account. Your payment gateway controls processing fees, applicable taxes, and payout timing; SeatLayer bills Hosted Ticket usage separately. 4. Once the payment provider confirms, the seats are booked and the buyer is emailed their tickets. Replies to that email reach **you**, not SeatLayer. Because payment confirmation comes from a signed provider webhook rather than the buyer's browser, a closed tab or lost connection mid-payment cannot lose an order. ## Refunds Refund from the order in SeatLayer (**Refund or cancel…**), and a full refund puts the seats back on sale. Refunds you make in Stripe, PayPal or Razorpay sync back to SeatLayer automatically. With other providers, refund from the order in SeatLayer, or record an outside refund with **Refund or cancel… → Cancel without refund**. A **partial** refund does not release seats because a partial amount cannot identify its seat. ## Performance The chart's JavaScript loads only on pages that contain a chart, so embedding one on a single ticket page does not add weight to the rest of the site. ## Does it work alongside WooCommerce? Yes. The plugin runs on a site that also uses WooCommerce, and the two run side by side rather than through one checkout. Ticket purchases use your own payment gateway and do not create WooCommerce cart items or orders, so seats are not WooCommerce products. Keep using WooCommerce for merchandise on the same site, and use the plugin for seated tickets. ## What this plugin does not do yet The plugin does not put ticket management, Box Office, Control Room, or Door UI inside WordPress. Those Hosted Ticketing surfaces stay in SeatLayer and operate on the same event inventory; SeatLayer issues the seat-specific QR/PDF tickets after a confirmed order. ## Troubleshooting **The chart area is blank.** Check the event key, and that the chart is *published*. A draft chart has no buyer-facing version. Your browser console will name the failure. **"This event is not taking online payments yet."** No gateway is connected for this event's mode. Connect one under **Payments** in the dashboard, and note that a `test`-mode event needs test credentials. **Buyers pay but never get tickets, and orders stay unconfirmed.** For Razorpay, this is almost always a missing webhook. Copy the webhook URL shown on the Payments screen into your Razorpay dashboard and subscribe to `payment.captured`, `payment.failed` and `refund.processed`. Payment confirmation depends on it. **The event dropdown is empty in the editor.** Either no secret key is saved (expected; type the key instead), or the saved key was rejected. The editor shows which. # Angular SeatManager > Embed the live SeatManager control room in an Angular app with @seatlayer/angular/manager. Inputs, live updates, outputs, the template ref, and the token rules. Source: https://docs.seatlayer.io/platform/angular-seat-manager/ · Markdown: https://docs.seatlayer.io/platform/angular-seat-manager/index.md `SeatLayerSeatManagerComponent` from `@seatlayer/angular/manager` puts the live control room inside your Angular app. It is a standalone component over the [`SeatManager` class](/platform/seat-manager) in `@seatlayer/js/manager`: the same board, the same tools, the same token rules. This page covers what is different in Angular. Read the [control-room guide](/platform/embedded-control-room) for the board itself. Available from `@seatlayer/angular` 0.106.0. Angular 17 or newer. ## Import it from the manager entry ```ts import { SeatLayerSeatManagerComponent } from "@seatlayer/angular/manager"; ``` The component is not exported from the package root. `/manager` is a separate entry point that holds the control room and nothing else, so a buyer page that imports `@seatlayer/angular` stays the same size, and a control room does not carry the buyer picker. ## Mount the board Your backend mints a short-lived, event-scoped `mse_` token after it checks that the signed-in user may manage the event. Never put an `sk_` secret key in browser code. Keep the token in memory, never in a URL, browser storage, or logs. ```ts import { Component, Input } from "@angular/core"; import { SeatLayerSeatManagerComponent, type SeatManagerMode, type SeatManagerOpenOrder, type SeatManagerRoomState, } from "@seatlayer/angular/manager"; @Component({ selector: "app-control-room", standalone: true, imports: [SeatLayerSeatManagerComponent], template: ` `, }) export class ControlRoomComponent { @Input({ required: true }) session!: { token: `mse_${string}`; expiresAt: number }; mode: SeatManagerMode = "view"; // An arrow function, so the board can call it without losing `this`. mintManageSession = async () => { const next = await fetch("/api/events//manage-session", { method: "POST", }).then((response) => response.json()); return { token: next.token, expiresAt: next.expiresAt }; }; onRoomState(state: SeatManagerRoomState) { console.log("room is", state); } openOrder(order: SeatManagerOpenOrder) { // Route to your own order page. } showOperatorError(error: unknown) { console.error(error); } } ``` The element is `display: block`; give it a real height and the board fills it. The board is destroyed when Angular destroys the component. ## Inputs The inputs are the [constructor options](/platform/seat-manager#constructor-options) without `container`. **Live** inputs change the running board in place: the camera, selection, live connection and DOM stay. **Read once** inputs are read when the board is built. Changing `eventKey` or `apiBase` builds a new board. | Input | When it applies | |---|---| | `eventKey`, `apiBase` | Changing either builds a new board | | `token`, `tokenExpiresAt` | Live | | `onTokenRefresh` | Live. A function input that returns the new token | | `capabilities` | Live | | `currency` | Live | | `theme` | Live | | `themeMode` | Applied before the first paint, then live | | `keepLiveWhileHidden` | Live | | `mode` | Live. Use `[(mode)]` to follow the operator's tool | | `followLive` | Live. Use `[(followLive)]` to follow the operator's choice | | `selectableObjects`, `unavailableObjectsSelectable` | Live | | `unavailableObjects`, `unavailableObjectsReason` | Live | | `categoryPrices` | Live | | `maxSelectedObjects`, `numberOfPlacesToSelect` | Live | | `isObjectSelectable` | Live | | `sale` | Live, compared by value | | `mapInsets` | Live, compared by value | | `tools`, `chrome` | Read once | | `selectedObjects` | Read once | | `kpis` | Read once. `false` when your own header shows the numbers | | `timeZone` | Read once | | `organizationName` | Read once | | `colourBy` | Read once | | `focusSection`, `focusSeat`, `focusSeats`, `focusCategory` | Read once | | `arrivedFrom` | Read once. Its `back.go()` runs inside the Angular zone | "Compared by value" means a new object with the same fields does nothing, so a template can build `sale` or `mapInsets` inline without repainting the board on every change detection. ## Outputs | Output | Payload | |---|---| | `(ready)` | none | | `(tallies)` | `SeatManagerTallies` | | `(activity)` | `SeatManagerActivity` | | `(controlRoom)` | `ControlRoomSnapshot` | | `(modeChange)` | `SeatManagerMode` | | `(followLiveChange)` | `boolean` | | `(selectionChange)` | `ExpandedSeat[]` | | `(objectSelected)`, `(objectDeselected)` | `ExpandedSeat` | | `(selectionValidityChange)`, `(selectionValid)`, `(selectionInvalid)` | `SeatManagerSelectionValidity` | | `(selectionLimit)` | `number` | | `(filteredSectionChange)` | `SeatManagerFilteredSection[]` | | `(areaClick)` | `SeatManagerArea` | | `(actionComplete)` | `SeatManagerActionResult` | | `(connectionChange)` | `SeatManagerConnection` | | `(roomStateChange)` | `SeatManagerRoomState`: `connecting`, `live`, `paused`, `offline` or `ended` | | `(errored)` | `unknown` | | `(openOrder)` | `SeatManagerOpenOrder`: `{ id, displayRef }` | | `(openTrend)` | `SeatManagerOpenTrend`: `{ kpi, sectionId? }` | | `(openDesigner)` | none. Bind it and Sections on a chart without sections offers "Open the designer" | The board offers "Open order" and the trend link only when you bind `(openOrder)` or `(openTrend)`, so it never shows a link that goes nowhere. The board checks this when it is built. `(errored)` rather than `(error)`, as on the seating chart: `error` collides with the native DOM error event on the host element. ## Zone behaviour The board runs a render loop, timers and a live connection. They run outside the Angular zone, so they do not schedule change detection for your whole app. Each output, `onTokenRefresh` and `arrivedFrom.back.go()` re-enter the zone, so your handlers, `HttpClient` calls and router navigation behave as usual. ## Template ref A template ref (`#room`) has the same methods as the React handle: | Method | Result | |---|---| | `setMode(mode)` | Switch tool | | `setToken(token, expiresAt?)` | Rotate the token without a rebuild | | `setHeatOverlay(enabled)`, `setFollowLive(enabled)` | Toggle the overlay or camera following | | `setTrendWindow(minutes)` | Refresh and return the control-room snapshot | | `enterFullscreen()`, `exitFullscreen()`, `isFullscreen()` | Fullscreen | | `block(labels?, { releaseAt?, reason? })`, `unblock(labels?)`, `unblockAll()` | Block or unblock inventory | | `cancelBooking(labels, bookingRef)` | Cancel by booking reference | | `setCategory(categoryKey, labels?)`, `setTableBooking(tableIds, mode, bounds?)` | Event-only category and table changes | | `selectAll()`, `selectSection(id)`, `selectByLabels(labels)` | Select seats | | `selectObjects(labels)`, `deselectObjects(labels)` | Custom selection by label | | `selectCategories(keys)`, `deselectCategories(keys)` | Custom selection by category | | `setSelectableObjects`, `setUnavailableObjectsSelectable`, `setUnavailableObjects`, `setCategoryPrices`, `setObjectSelectable`, `setMaxSelectedObjects`, `setNumberOfPlacesToSelect` | The live inputs, called directly | | `getSelectionValidity()` | Exact-count state, or `null` | | `setFilteredSection(label)`, `clearFilteredSection()`, `getFilteredSections()` | Section filter | | `focusSection(label)` | Frame matching sections without filtering | | `clearSelection()`, `getSelection()` | Selection | | `getReport()`, `getControlRoomSnapshot(minutes?)`, `getLog({ limit?, before? })` | Reports and audit log | | `setHoldTtl(msOrNull)` | Set or clear the event checkout window | | `getConnection()` | Live link state, or `null` before the board exists | | `zoomToFit()` | Fit the chart | | `showObjects(labels, name?)` | Light and frame these seats, named in the legend. `null` clears | The `[focusSection]` input opens the board on a section. The `focusSection()` method frames a section later. In code, the input's class field is `focusSectionOnOpen`, because a class cannot have a field and a method with the same name. A method called before the board exists returns an empty answer. `getReport`, `getControlRoomSnapshot` and `setTrendWindow` reject with `not ready`. ## Types `@seatlayer/angular/manager` exports `SeatManagerOpenOrder`, `SeatManagerOpenTrend`, `SeatManagerOptions`, `SeatManagerMode`, `EventScopedManageToken`, `SeatManagerTallies`, `SeatManagerSale`, `SeatManagerActivity`, `SeatManagerActionResult`, `SeatManagerConnection`, `SeatManagerRoomState`, `SeatManagerFilteredSection`, `SeatManagerSelectionValidity`, `SeatManagerCategoryPrice`, `EventTableBookingMode`, `ControlRoomActivityEntry`, `ControlRoomSectionMetric` and `ControlRoomSnapshot`. Continue to the [SeatManager reference](/platform/seat-manager), the [control-room guide](/platform/embedded-control-room), and [embed sessions](/platform/embed-sessions). # Concurrency: tested at 10,000 buyers on one event > Tested September 2026: 10,000 simulated concurrent buyers on one 12,000 seat event, hold latency p99 9 ms, zero server errors, 400,000 live updates delivered. Source: https://docs.seatlayer.io/platform/concurrency-performance/ · Markdown: https://docs.seatlayer.io/platform/concurrency-performance/index.md - **10,000 concurrent buyers tested on one event:** hold p99 9 ms, zero server errors. - **400,000 of 400,000 live seat updates** delivered to 10,000 connected viewers. - **3,552 requests per second** sustained for two minutes on a single event. Simulated buyers in an isolated test setup, measured on 18 September 2026. [Method and raw runs are published](https://github.com/seatlayer/seatlayer-performance/blob/main/results/2026-09-18/concurrency-benchmark.md). Server-side latency below is processing time on the SeatLayer side, excluding network. ## Measured workloads | Workload | Result | | --- | --- | | 10,000 simulated concurrent buyers with think time | 390,795 requests at 3,552.7 requests per second; 0.0082% transport failures; zero server errors; server-side availability latency p99 4 ms; server-side hold latency p99 9 ms | | 500-client public API read burst | 1,000 reads succeeded; combined read p95 1.19 seconds | The mixed workload performed availability reads, occasional compact inventory reads, and one to two seat holds followed by release. Each virtual user waited 1.5 to 2.5 seconds between iterations, and 5% of iterations attempted a hold. The run ramped to 10,000 buyers over 120 seconds and held them there for a 120 second plateau, with virtual users pinned at exactly 10,000 for every sample of the plateau window. Across the 390,795 plateau requests the transport failure rate was 0.0082% and no response carried a server error status. 12,977 holds were accepted and 1,098 were refused, which is expected when seat labels are drawn at random across 12,000 seats and two buyers reach for the same seat. Hold updates were measured in a separate socket run: 40 distinct seat holds per stage sent as concurrent hold requests, with a 10 second delivery deadline. Every requested socket connected and every expected update arrived. | Connected viewers | Hold request p95 | Update p95 | Update p99 | Received / expected | | --- | --- | --- | --- | --- | | 100 | 10.2 ms | 26.9 ms | 27.4 ms | 4,000 / 4,000 | | 500 | 6.4 ms | 34.1 ms | 34.6 ms | 20,000 / 20,000 | | 1,000 | 20.6 ms | 51.1 ms | 51.9 ms | 40,000 / 40,000 | | 2,000 | 14.4 ms | 59.4 ms | 60.9 ms | 80,000 / 80,000 | | 5,000 | 75.8 ms | 220.4 ms | 225.6 ms | 200,000 / 200,000 | | 10,000 | 169.6 ms | 382.9 ms | 394.0 ms | 400,000 / 400,000 | Update latency grows with recipient count: p95 was 59.4 ms at 2,000 connected viewers and 382.9 ms at 10,000. No missing deliveries, no hold failures and no unexpected disconnects at any stage. ## What this does and does not show - This was a local benchmark: the load generator shared a machine with the service, so end-to-end request percentiles include that contention. The server-side figures are the ones to read. - The fixture is one synthetic 12,000 seat event. When two buyers compete for a seat, exactly one competing request wins. Simulated buyers use think time, so 10,000 buyers is not 10,000 simultaneous bookings. - The test measures seat inventory on its own, with trusted access: no public authentication and no rate limiting. The compact inventory response reported a byte count rather than the full inventory. - Payments, order creation, confirmed bookings and best-available selection were outside the run. - The 500-client read burst was run against a non-production SeatLayer environment with a small existing fixture, so it exercised real public routes, with a 138 byte compact inventory response. - Local throughput does not override platform limits. This is workload-specific evidence, not a production capacity ceiling. ## Renderer and concurrency evidence The [200,000-seat renderer benchmark](/platform/renderer-performance/) measures browser chart readiness and interaction, a separate scenario from this 12,000 seat event workload. The two results are not combinable into a claim about 10,000 buyers on a very large event. # Embed sessions > Mint short-lived, origin-bound browser credentials for the embedded Designer and event control room. Source: https://docs.seatlayer.io/platform/embed-sessions/ · Markdown: https://docs.seatlayer.io/platform/embed-sessions/index.md Never expose an `sk_…` secret key to a browser. After your backend authorizes the signed-in user, ask SeatLayer to mint a short-lived embed session for exactly the chart editor or event operator surface that user may open. | Token | Surface | Resource scope | Authorization scope | |---|---|---|---| | `dse_…` | Embedded Designer | One workspace and chart | `read-only`, `edit`, or `publish` | | `mse_…` | SeatManager control room | One event | Explicit event capabilities | Both tokens are bound to an exact browser origin, shown once and never retrievable, expire automatically, and can be revoked. ## Shared rules - `expiresInSeconds`: 300–14,400; default 3,600. - `allowedOrigin`: exact HTTPS origin, with no path, query, fragment, username, or password. - Development allows `http://localhost` and `http://127.0.0.1`. - Mint responses carry `Cache-Control: no-store`. - Never log a raw token or token-bearing Designer URL. - Revoking a session invalidates the credential without deleting the chart or changing event inventory. Buyer `bse_…` credentials use a separate contract and expiry range. Ordinary Platform Public sale uses direct `publicKey` bootstrap; scoped private audiences use the [buyer access session endpoint](/server-api/buyer-access-sessions/). ## Designer session **POST /v1/designer/sessions**. Authentication: Secret key ```bash curl -sX POST "https://api.seatlayer.io/v1/designer/sessions" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "workspaceId": "ws_organizer_42", "chartId": "chart_main_hall", "allowedOrigin": "https://admin.example.com", "authority": "edit", "mode": "safe", "safeModeOptions": { "allowDeletingObjects": false, "allowEditingAreaCapacity": false }, "expiresInSeconds": 3600 }' ``` ### Designer authority | Authority | Read | Save draft | Publish | |---|---:|---:|---:| | `read-only` | Yes | No | No | | `edit` | Yes | Yes | No | | `publish` | Yes | Yes | Yes | Prefer `authority`. Legacy `canPublish` remains compatible, but do not send conflicting values. When neither is supplied, the session has `edit` authority. `mode: "safe"` restricts destructive editing for a delegated organizer. Its options can independently allow object deletion or area-capacity edits. Feature policy can further limit the public Designer feature set; treat the session response as the effective policy. The response includes: ```json { "session": { "id": "dsess_1f0e", "token": "dse_9c1b", "workspaceId": "ws_organizer_42", "chartId": "chart_main_hall", "allowedOrigin": "https://admin.example.com", "authority": "edit", "canEdit": true, "canPublish": false, "mode": "safe", "expiresAt": 1767207600000, "designerUrl": "https://app.seatlayer.io/embed/designer#token=dse_9c1b" } } ``` The token is in the URL fragment, so it is not sent as part of the iframe's initial HTTP request. ### Start before the click The server trust boundary is required; a browser must never receive `sk_…` or mint its own `dse_…`. The extra wait on the final click is not required. Once the host has authenticated the user and resolved their chart and role, it can start the no-store session request on route intent or page load while the SDK and editor shell load. A server-rendered authenticated page may instead include the short-lived `designerUrl` in a no-store bootstrap. Keep it in memory, mount it as soon as the SDK is ready, and revoke it when access ends. **DELETE /v1/designer/sessions/:id**. Authentication: Secret key ## Manage session **POST /v1/events/:key/manage-sessions**. Authentication: Secret key or authorized dashboard session ```bash curl -sX POST \ "https://api.seatlayer.io/v1/events//manage-sessions" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "allowedOrigin": "https://admin.example.com", "capabilities": ["event:view", "event:block"], "expiresInSeconds": 3600 }' ``` | Capability | Allows | |---|---| | `event:view` | Live board, section rules, and checkout-window reads | | `event:block` | Block/unblock, section availability, and hold-TTL writes | | `event:categories:manage` | Event-only object category assignments; reusable chart and existing price snapshots stay unchanged | | `event:tables:manage` | Event-only table booking-mode changes for free inventory | | `event:cancel` | Booking-reference-guarded cancellation | | `event:reports` | Inventory report, CSV, audit log, and configured booked-value projection | | `event:channels:view` | Channel inventory, allocation, audit, preview, and channel-report reads | | `event:channels:manage` | Channel lifecycle, assignments, and hosted access-link management | | `event:orders:read` | Hosted Ticketing Order, CSV, detail, and timeline reads | | `event:refund` | Hosted Ticketing gateway refund | | `event:tickets:send` | Hosted Ticketing ticket resend | | `event:door:view` | Door preflight, attendance, and attendance CSV | | `event:door:checkin` | Door check-in mutation | | `event:boxoffice` | Hosted Ticketing counter inventory and sale | Omitting `capabilities` is accepted for compatibility and grants only `event:view`. It never grants a mutation, cancellation, report, channel, or Hosted Ticketing capability. Send the smallest explicit non-empty list whenever the operator needs more than the live read-only board. Empty, null, malformed, or unknown capability values are rejected. Hosted Ticketing capabilities are available only on a Hosted Ticketing event. Channel and commerce grants remain independent: for example, `event:boxoffice` does not allow a token to cross a private allocation without the separately granted `event:channels:manage` capability. **DELETE /v1/events/:key/manage-sessions/:id**. Authentication: Secret key The event key in the revoke path is part of the authorization scope. A session can be revoked only under its exact event. Another event or organization returns `404`; a same-organization key in the wrong live/test mode returns `403 mode_mismatch`. ## Rotate long-running sessions For `SeatManager`, pass `tokenExpiresAt` and `onTokenRefresh`. Ask SeatLayer for the replacement from your backend with the same event, origin, and capabilities, then return `{token, expiresAt}`. The SDK swaps it without rebuilding the board. For `EmbeddedDesigner`, implement `onRequestRelaunch`, ask SeatLayer for a fresh Designer session from your backend, and call `setDesignerUrl()`. Automatic renewal uses the expiry reported by the embedded app. ## Backend session-endpoint pattern Your own session route must check before it asks SeatLayer to mint: 1. the user is authenticated; 2. the user belongs to the host tenant; 3. the stored SeatLayer workspace/event belongs to that tenant; 4. requested authority or capabilities match the user's role; 5. the requested origin is on your allowlist. Return only the scoped session response. Keep `SEATLAYER_SECRET_KEY` in the server runtime. Continue to [embedded Designer](/platform/embedded-designer) or [embedded control room](/platform/embedded-control-room). # Embedded control room > Give authorized operators a live, capability-scoped SeatManager board inside your own application. Source: https://docs.seatlayer.io/platform/embedded-control-room/ · Markdown: https://docs.seatlayer.io/platform/embedded-control-room/index.md `SeatManager` is a same-document operator board, not an iframe. It renders the event map, subscribes to live inventory, shows occupancy and activity, and exposes controlled custom selection, blocking, category, table, section, channel, cancellation, and reporting tools. ## Trust boundary Your backend authorizes the operator and asks SeatLayer to mint an event-scoped `mse_…` token. The browser receives only that token. Never put an account secret key in the page. ## Mint a manage session ```bash curl -sX POST \ "https://api.seatlayer.io/v1/events//manage-sessions" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "allowedOrigin": "https://admin.example.com", "capabilities": [ "event:view", "event:block", "event:categories:manage", "event:tables:manage", "event:reports" ], "expiresInSeconds": 3600 }' ``` Add `event:cancel` only for operators allowed to return booked inventory to sale. A browser manage token cannot confirm a booking: the platform backend does that through `/book`. Managed Box Office is a separate commerce surface and capability. If your backend omits `capabilities`, the API issues a view-only token with exactly `event:view`. Blocking, cancellation, reports, channels, and Managed Ticketing operations always require an explicit capability. ## Mount the board ```js title="browser/control-room.js" import { SeatManager } from "@seatlayer/js/manager"; const session = await fetch("/api/events//manage-session", { method: "POST", }).then((response) => response.json()); const room = new SeatManager({ container: "#control-room", eventKey: "", token: session.token, tokenExpiresAt: session.expiresAt, mode: "view", keepLiveWhileHidden: true, followLive: false, onTokenRefresh: async () => { const next = await fetch("/api/events//manage-session", { method: "POST", }).then((response) => response.json()); return { token: next.token, expiresAt: next.expiresAt }; }, onError: (error) => showOperatorError(error), }); await room.render(); ``` Give the container a real height. SeatManager fills that box and watches it with `ResizeObserver`. Fullscreen uses the browser Fullscreen API. ## Operator modes Tools are grouped in the toolbar as **Watch** (`view`, `inspect`, read-only), **Manage** (`block`, `sections`, `categories`, `tables`, `channels`, changes this event only) and **Host tools** (`select`, `filterSections`, hands a selection or filter to your app). Pass `tools` to offer a subset; it filters what is shown and never grants a capability. | Mode | Purpose | Capability | |---|---|---| | `view` | Live occupancy, activity, momentum, presence, and aggregate inventory performance | `event:view` | | `inspect` | Read the current state and context of one seat | `event:view` | | `select` | Select live event objects for a host-owned custom operation; no inventory mutation | `event:view` | | `filterSections` | Filter and frame matching section labels for host-owned reporting | `event:view` | | `block` | Select and block/unblock individual inventory | `event:block` | | `sections` | Open, close, hide, or schedule sections/zones | `event:block` | | `categories` | Change event-only category assignments | `event:categories:manage` | | `tables` | Change free tables between per-chair and whole-table booking | `event:tables:manage` | | `channels` | Inspect or manage private sales allocations | `event:channels:view` / `event:channels:manage` | `event:reports` is separate from the live board. Add it only when the embedded staff workflow must open booking history, category/section/channel breakdowns, downloadable inventory reports, or the full audit history. SeatLayer Orders, tickets, refunds, and attendance are not part of the Platform/SDK Control room. Following live activity is off by default so a busy event does not repeatedly move the operator's camera. Heat overlays express sales velocity without replacing chart category colors. ## Common actions ```ts title="operator/actions.ts" room.setMode("block"); room.selectSection("sec-stalls"); await room.block(undefined, { releaseAt: Date.parse("2026-08-01T19:00:00Z"), reason: "Soundcheck hold", }); await room.unblock(["A-3"]); await room.unblockAll(); await room.cancelBooking(["C-14", "C-15"], "order_5567"); await room.setHoldTtl(10 * 60 * 1000); room.setMode("select"); room.selectObjects(["A-1", "A-2"]); const selectionState = room.getSelectionValidity(); room.setMode("filterSections"); const matchedSections = room.setFilteredSection("Balcony"); room.clearFilteredSection(); await room.setCategory("vip", ["A-1", "A-2"]); await room.setTableBooking(["table-1"], "whole"); const report = await room.getReport(); const log = await room.getLog({ limit: 50 }); ``` Cancellation changes inventory only; your application owns its commercial order, ticket cancellation, refund policy, payment execution, and buyer communication. The booking reference prevents releasing a newer booking. Category assignments are event-only: the reusable venue chart is unchanged, and existing hold/booking price snapshots are not rewritten. Table-mode changes alter inventory identity and are therefore refused while any affected table is held, booked, or blocked, or when a private-channel allocation would be lost. ## Long-running boards Pass both `tokenExpiresAt` and `onTokenRefresh`. SeatManager proactively requests a replacement and also retries once after an authorization failure. Your refresh endpoint must repeat the full host-user authorization check rather than accepting the expired token as proof. `keepLiveWhileHidden: true` forces a fresh paint after live deltas when browsers throttle background animation frames. Use it for a wallboard; consider battery and resource costs on laptops. ## Custom operator UI Use `ManageApi` when the packaged board does not match your workflow. It exposes the same token-authenticated reads and mutations: availability, category/table management, block/unblock, cancellation, hold TTL, report, control-room snapshot, audit log, and CSV Blob. Keep capability rules identical to SeatManager. ## Verification - [ ] The backend authorizes event and tenant before every SeatLayer session request. - [ ] The token carries only necessary capabilities. - [ ] The board has a bounded, responsive container. - [ ] Token rotation is tested across laptop sleep and network loss. - [ ] Detailed reports and full audit history stay unavailable unless `event:reports` is minted. - [ ] Cancellation is paired with an explicit refund workflow. - [ ] Conflict errors ask the operator to reload or reselect. - [ ] `destroy()` runs during unmount and logout. - [ ] Revocation occurs when access ends. Continue to the [SeatManager reference](/platform/seat-manager), [embed sessions](/platform/embed-sessions), and [reports](/server-api/reports). # Embedded Designer > Let organizers create and edit charts inside your product with workspace-scoped, reviewable browser sessions. Source: https://docs.seatlayer.io/platform/embedded-designer/ · Markdown: https://docs.seatlayer.io/platform/embedded-designer/index.md Embed the full SeatLayer Designer in your CMS without creating a SeatLayer login for every organizer. Your server owns tenancy and authorizes the request; SeatLayer mints the short-lived session, and the SDK owns iframe lifecycle and message validation. The [seat map designer](https://seatlayer.io/venue-designer/) page shows what organizers get inside that session. ## End-to-end flow 1. Create or select a chart in the organizer workspace. 2. Authorize the current host user and decide their Designer authority. 3. On authenticated intent or page load, start your backend's `dse_…` session request while the SDK and editor shell load. 4. Mount the returned `designerUrl` as soon as both are ready. 5. React to saved, published, close, and error events. 6. Replace the session before or after expiry. 7. Revoke it when access ends. ## Mint from the server ```ts title="server/designer-session.ts" export async function createDesignerSession(input: { workspaceId: string; chartId: string; allowedOrigin: string; }) { const response = await fetch( "https://api.seatlayer.io/v1/designer/sessions", { method: "POST", cache: "no-store", headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ ...input, authority: "edit", mode: "safe", expiresInSeconds: 3600, }), }, ); if (!response.ok) throw new Error("Unable to open Designer"); return response.json(); } ``` Use `read-only` for review, `edit` for draft work, or `publish` only when the host user is allowed to release a chart. Safe mode is a useful default for delegated venue editors. SeatLayer generates the `dse_…` token. Your backend must first authenticate the organizer, verify tenant/workspace/chart ownership, choose the authority and exact origin, and then call this endpoint with `sk_…`. Never call it from browser code, expose `sk_…`, or try to mint a Designer token client-side. Return the scoped session through a `Cache-Control: no-store` host response. Do not wait for the final **Open Designer** click if the user is already authorized. Start the host session request on route intent or authenticated page load and keep its promise in memory. Alternatively, include the returned `designerUrl` in an authenticated, no-store server-rendered bootstrap. This moves the required server trust boundary off the click path without weakening it. ## Mount with JavaScript ```js title="browser/designer.js" let editor; async function requestDesignerSession() { const response = await fetch("/api/designer-session", { method: "POST", credentials: "same-origin", cache: "no-store", }); if (!response.ok) throw new Error("Unable to open Designer"); return response.json(); } // Start both jobs when the authenticated editor route loads. Draw your own // shell/skeleton immediately; the iframe still waits for designerUrl. showDesignerShell(); const sdkPromise = import("@seatlayer/js"); const initialSessionPromise = requestDesignerSession(); async function mountDesigner() { const [{ EmbeddedDesigner }, { session }] = await Promise.all([ sdkPromise, initialSessionPromise, ]); editor = new EmbeddedDesigner({ container: "#designer", designerUrl: session.designerUrl, expectedChartId: session.chartId, expectedWorkspaceId: session.workspaceId, height: "fill", minHeight: 0, // Let a short CMS panel fit without forcing page overflow. showLoadingState: true, onReady: () => markEditorReady(), onSaved: (message) => saveHostRevision(message.chartId), onPublished: (message) => refreshChart(message.chartId), onClose: () => closeEditor(), onError: (message) => reportSafeError(message.code), onRequestRelaunch: async () => { const next = await requestDesignerSession(); editor.setDesignerUrl(next.session.designerUrl); }, }); editor.mount(); } void mountDesigner(); ``` The Designer iframe cannot fetch the chart until the `dse_…` session exists. Once mounted, the SeatLayer app already races its authenticated chart/session read against the lazy Designer bundle. The host-side win is therefore to overlap session creation with SDK import and shell rendering, not to move session issuance into the browser. `height: "fill"` detects whether the container has a definite height. It fills that box or uses the remaining viewport height. Set `minHeight: 0` for a bounded CMS panel; a larger explicit minimum can force the iframe beyond a short panel. Give the editor container a definite height (for example `height: calc(100dvh - 96px)`) and `min-height: 0` when it is a flex child. Put surrounding padding on an outer wrapper. A numeric height opts into fixed-pixel behavior and legacy resize messages. ## React ```tsx title="components/VenueEditor.tsx" import { EmbeddedDesigner } from "@seatlayer/react"; export function VenueEditor({ session, relaunch }) { return ( refreshVenue(chartId)} onPublished={({ chartId }) => activateRevision(chartId)} onRequestRelaunch={relaunch} /> ); } ``` Resolve `session` in the authenticated route loader or server component where possible, set the response to no-store, and render the shell while the client component bundle loads. Keep the raw session only in memory. ## Message safety The SDK accepts only documented `seatlayer.designer.*` messages from the exact iframe window and Designer origin. `expectedChartId` and `expectedWorkspaceId` add host-side resource checks. | Event | Host response | |---|---| | `ready` | Remove outer loading state | | `saved` | Update host draft status | | `published` | Refresh event/chart state and show release result | | `close` | Navigate away or collapse editor | | `error` | Show safe recovery UI; do not expose raw tokens | Do not build business logic from the legacy resize message. ## Session replacement The SDK can proactively relaunch when `onRequestRelaunch` exists. It uses the session expiry received from the iframe and replaces the iframe with the new fragment URL. A slept device that wakes after expiry gets one automatic recovery attempt before the error card appears. Call `destroy()` when the host unmounts the editor. ## Publication behavior Publishing updates the chart. Existing events are separately protected: pristine events may auto-refresh; events with inventory state can require an explicit map update, and an update fails if booked or held labels would vanish. Surface this result to the organizer. ## Verification - [ ] The secret key is never present in browser code or network responses. - [ ] Workspace and chart ownership are checked before minting. - [ ] Origin matching is exact in production. - [ ] Authority matches the host user's role. - [ ] Safe mode and feature policy match the editing job. - [ ] Save, publish, close, expiry, relaunch, and revoke flows are tested. - [ ] The container works at narrow widths and has a usable minimum height. - [ ] Host code destroys the embed during teardown. See [embed sessions](/platform/embed-sessions), [publishing and versioning](/designer/publishing-and-versioning), and [Designer MCP](/agents/designer-mcp). # Stadium and arena renderer performance > 200,000 seats chart-ready in 1.95 s at 57.6 FPS zoom, a 999 ms observation at 53,018 seats, and dated renderer and 3D benchmarks with their methods. Source: https://docs.seatlayer.io/platform/renderer-performance/ · Markdown: https://docs.seatlayer.io/platform/renderer-performance/index.md SeatLayer supports large arena and stadium buyer experiences with reusable decks and sections, progressive rendering, viewport detail, and complete 2D selection with optional 3D on the same inventory. Evaluate a 50K-plus venue using the public 53,018-seat stadium and 13,784-seat arena demos, and read the 100,000- to 200,000-seat fixture benchmark below. This page separates the stadium-scale run and the latest picker-readiness observation from dated interaction and GPU benchmarks, with the method and limits for each. **Historical run, 27 August 2026: at 53,018 seats the buyer renderer measured 17.6 ms p95 frames while panning, zooming and selecting in this desktop run. The venue is on screen in 348 ms and every seat is drawn within 4.3 s while it stays interactive; the flat-to-3D transition measured a 34.2 ms p95 frame.** Open the chart and test it yourself, no signup: [the 200,000-seat public demo](https://app.seatlayer.io/demo/play/century-stadium-200k) and the [100,000-seat arena demo](https://app.seatlayer.io/demo/play/century-arena-100k). ## September 2026 stadium-scale benchmark **On 15 September 2026, public 100,000-, 150,000- and 200,000-seat venue fixtures were benchmarked against a local production build. 200,000 seats were chart-ready in 1.95 s with 57.6 FPS while zooming and 60.0 FPS while panning.** | Venue fixture | Chart ready | Zoom | Pan | | --- | --- | --- | --- | | 100,000 seats | 1.02 s | 58.6 FPS | 60.0 FPS | | 150,000 seats | 1.44 s | 58.6 FPS | 60.0 FPS | | 200,000 seats | 1.95 s | 57.6 FPS | 60.0 FPS | Method: medians of three runs per fixture, desktop Chrome 152, 1280 × 720 viewport at devicePixelRatio 2, against a local production build. The fixtures, the full run log and SHA-256 fixture manifests are published in the open, MIT-licensed [seatlayer-performance repository](https://github.com/seatlayer/seatlayer-performance), so the run can be reproduced without trusting this page. This section is the canonical method target for the "benchmarked at 200,000 seats" claim wherever it appears. What this run does **not** publish: - **No live-URL measurement.** These are local production-build runs. They do not include the network fetch of a 200,000-seat chart from the live API, so they must never be quoted as what `app.seatlayer.io` does. A [200,000-seat public stadium demo](https://app.seatlayer.io/demo/play/century-stadium-200k) and a [100,000-seat arena demo](https://app.seatlayer.io/demo/play/century-arena-100k) are open live; their timing varies by device, network and run and is not this benchmark. - **Concurrency is measured separately.** Renderer evidence does not establish concurrent buyer or event capacity. See the [single-event concurrency benchmark](/platform/concurrency-performance/) for the 10,000-simulated-concurrent-buyer workload, the delivery of all 400,000 expected hold updates to 10,000 connected viewers, and the scope of that isolated test setup. - **No hard venue ceiling.** 200,000 seats is the largest fixture measured so far, not a maximum. Validate an unusually large venue with your own chart. ## September 2026 live demo observation On 14 September 2026, an owner-supplied screenshot of the public 53,018-seat stadium demo recorded chart ready in 999 ms (approximately one second). Three separate checks in a desktop Chrome session displayed 6.2, 5.9 and 3.7 seconds. These are picker-readiness observations, not full-page load or complete-seat-reveal measurements; results vary by run and device. | Observation, 14 September 2026 | Displayed chart-ready time | | --- | --- | | Owner-supplied production Chrome screenshot | **999 ms** | | Separate desktop session: first visit | 6.2 s | | Same session: first normal reload | 5.9 s | | Same session: second normal reload | 3.7 s | The separate session used Chrome on macOS 26.6.2 with a visible desktop viewport captured at 1920 × 1059. Cache state and machine load were not controlled. The screenshot does not establish the owner's hardware, exact browser version or deployed commit. The table preserves all observed results; it is not a controlled before/after benchmark or a one-second service guarantee. The demo reads `chart_rendered.renderingTimeMillis` from the picker. Its timer starts inside `render()`, after the page has obtained the chart, and uses a local demo inventory transport. It excludes earlier page navigation, application download and chart retrieval, and does not wait for every individual seat to finish its progressive reveal. Consequently, **999 ms chart ready cannot be compared as a speedup against the older 4,260 ms every-seat-drawn milestone**. A supported short description is: “The public 53,018-seat stadium demo recorded approximately one-second picker readiness in a desktop observation.” Cite this section with that statement so the date, run variation and metric remain available. The interaction and GPU figures below retain their original dates; they were not remeasured in September. ## Historical August 2026 method The run compares two published charts on the same machine in the same session, so the second column is a scale comparison rather than a lone number. | | | | --- | --- | | Measured | 27 August 2026, commit `84380e3b` (fixture waits for the progressive reveal to finish before probing) | | Machine | Apple Silicon laptop, Chromium via Playwright, 1280x800, foregrounded window, load average ~4 | | Fixture | `e2e/fixtures/renderer-13k-benchmark.html?venue=mega` (53,018 seats) against `?venue=uber` (13,784 seats) | | Spec | `e2e/renderer-50k-benchmark.spec.ts` | Two details in that row do the real work. The window is foregrounded, because a hidden tab freezes `requestAnimationFrame` and a background run measures a renderer that is not drawing. And the fixture waits for every seat to land before it probes. Probing mid-reveal measures the reveal, not the renderer. ## Historical August 2026 numbers The chart is progressive, so readiness has two milestones: the venue is usable when its sections are on screen, and every individual seat lands after that without the map going unresponsive in between. | Measurement | 13,784 seats | 53,018 seats | | --- | --- | --- | | Venue on screen | 182 ms | 348 ms | | Every seat drawn | 1,630 ms | 4,260 ms | | JS heap | 53.5 MB | 121.9 MB | Interaction is reported as the 95th-percentile frame duration and the frame rate recorded by the probe. The FPS value is a separate observation, not a mathematical conversion of the p95 duration. p95 exposes the slower end of the recorded frames that a mean can hide. | Interaction | 13,784 seats | 53,018 seats | | --- | --- | --- | | Flat-to-3D transition | 17.7 ms · 58 FPS | 34.2 ms · 49 FPS | | Focus a section | 17.7 ms · 59 FPS | 17.3 ms · 54 FPS | | Pan, focused | 17.6 ms · 60 FPS | 17.4 ms · 60 FPS | | Zoom, focused | 17.6 ms · 55 FPS | 17.6 ms · 46 FPS | | Selection burst of 32 seats | 17.7 ms · 60 FPS | 17.6 ms · 52 FPS | The p95 frame is flat across a 3.8x difference in seat count for every interaction except the flat-to-3D transition, which is the one moment the whole venue is rebuilt in a new projection. What scales is the one-off cost of drawing every seat, and the resident memory, not the cost of a frame once the chart is up. ## Historical 3D GPU measurement A separate headed Chromium run on **4 August 2026** measured the interactive 3D venue view on an Apple M3 Pro through ANGLE's Metal renderer. In that one desktop experiment, both the 14,142-seat arena and 53,018-seat stadium recorded 60 FPS at p50, p05, and minimum during a sustained orbit drag and a scripted fly-to-seat descent through the chair LOD band. Draw calls remained 3 while idle and 4 in flight at both sizes. That result belongs to the specific M3 Pro/Metal run and the two scripted interactions in `e2e/view3d-50k-benchmark.spec.ts`; it is not a universal 60 FPS claim, a mobile result, or a guarantee for other GPUs and venues. These figures are transcribed from SeatLayer’s archived 4 August engineering measurement, rather than a new run. The archived fixture and current public demo can differ; the public 53,018-seat chart above is available for evaluating your own browser and device. ## What this run does not publish Camera-glide numbers measured while the progressive seat reveal was still running are not published; the fixture now waits for every seat before probing (an earlier same-day run mid-reveal showed 150-349 ms frames, which measured the reveal, not the renderer). The viewport-culling probe reports 0 culled section groups at the 53,018-seat focus endpoint on this build and on the previous day's build alike; the fitted camera for a stadium-sized section stays below the live-seat threshold, so the venue paints from the cached overview bitmap there. Frame timings above are unaffected; the probe needs a zoomed-in endpoint to count culled groups. No maximum seat count is published either. 53,018 is the largest chart measured, not a ceiling, and these figures describe the buyer renderer rather than an end-to-end authoring session. The machine and build are stated above precisely because a number without them is not reproducible; validate an unusually large or unusual venue against your own chart. ## How to reproduce ```bash pnpm exec playwright test e2e/renderer-50k-benchmark.spec.ts ``` The spec drives both fixtures, waits for the reveal to settle, records short probes for each interaction, and prints the same tables. Run it foregrounded on a quiet machine; a laptop under load, a hidden window, or a probe that starts before the reveal finishes will not reproduce these numbers. # Sales channels and allocations > Split one event into sales channels and allocations for partners, presales, sponsors, and box office without splitting the seat count. Source: https://docs.seatlayer.io/platform/sales-channels/ · Markdown: https://docs.seatlayer.io/platform/sales-channels/index.md A sales channel is an **allocation label** on inventory you already have. It primarily answers *which buyers are allowed to select this seat before it sells?* and can optionally carry a sparse price book for that audience. Everything else stays exactly where it was. One event, one chart, one authoritative seat count. This is the canonical **feature overview** for channels, allocation, organizer controls, and Partner access. Continue according to your role: | Your job | Read next | |---|---| | Configure an allocation and connect an authenticated private selling flow | [Organizer guide: integrate private sales](/integrations/private-and-partner-sales/) | | Operate inventory an organizer assigned to your organisation | [Partner guide: sell an allocation](/integrations/partner-selling/) | | Implement exact allocation and lifecycle operations | [Sales Channels API](/server-api/channels/) | | Mint short-lived browser authority for private inventory | [Buyer Access Sessions API](/server-api/buyer-access-sessions/) | | Read allocation and sale attribution per channel | [Reports](/server-api/reports/) | > **Channels are an access model, not an inventory model** > > An [Event, Performance Group, or Season](/start/inventory-models/) determines > the inventory transaction. A channel determines who may select inventory on > one Event. A ticket offer determines which price applies. These controls are > related, but none substitutes for another. ## The one-minute model Take a 1,000-seat event: | Allocation | Seats | Who may select them | |---|---:|---| | Public sale | 800 | Anyone on a Managed public page or a public-key Platform embed | | Travel Agency A | 120 | Buyers carrying an Agency A buyer access session | | Sponsor guests | 50 | Sponsor invitees | | Box office | 30 | Your own staff selling flow | All four audiences read the same event, the same chart geometry, and the same inventory. Selling `A-12` through Travel Agency A makes `A-12` booked **everywhere, immediately**: on the public page, in your reports, and in every open buyer session. There is no reconciliation step or second source of truth, because there is no second inventory. ## Assignment is not status A seat's **channel** and a seat's **status** are independent. Every seat always has both. | | | |---|---| | **Status** | `free`, `held`, `booked`, `blocked`: what is physically true | | **Channel** | Public sale, or exactly one private channel: who may select it | Assigning a seat to a channel never changes its status. A free seat in a private channel is still free; it is simply invisible to buyers who are not eligible for that channel. This is why assignment is safe to run on a live, selling event. It is also why a channel is not a way to take a seat off sale. If nobody should sell it, block it. ## What a channel is not The five things that get confused with channels, and the feature that actually does each job: | What you want | Use | |---|---| | Nobody may sell this seat | [Block](/server-api/blocking) | | Reserve it during checkout | [Hold](/server-api/holds) | | Open or close a section for everyone | [Section availability](/server-api/inventory) | | Change the event's base price or visual treatment | [Category and pricing](/designer/accessibility-and-pricing) | | Give one allocated audience a different price | **Sales channel pricing** | | Let only a particular audience select it | **Sales channel** | ## One seat, one channel A seat belongs to Public sale or to exactly one private channel. Never two. Overlapping membership makes "how many seats does the sponsor have?" unanswerable and turns every allocation edit into a precedence question. A buyer may see several allocations, but a seat belongs to only one. A [buyer access session](/server-api/buyer-access-sessions) can name several channels at once, so a sponsor who is also an agency customer sees both allocations without either seat being in two places. That keeps every allocation total exact and every seat's answer to "whose is this?" a single value. ## Public sale is a real allocation, not an absence Seats not assigned to a private channel are **Public sale**. It behaves like any other allocation in reports and in the API, with counts, sell-through, and inventory movements. Two differences apply: - it is the default, so every event you already have is entirely Public sale and stays that way until you say otherwise. There is no migration; - a Managed public/unlisted event needs no grant for Public sale, while a Platform/SDK embed uses its publishable `publicKey` to obtain an origin-bound, Public-only session directly from SeatLayer. In the API, Public sale is the reserved channel id `public`, and a seat on public sale reports `channelId: null`. The direct public-key bootstrap fixes its scope to Public sale and accepts no browser-supplied channel list. A server-requested [buyer access session](/server-api/buyer-access-sessions) must still say `includePublic` explicitly. There is no default, and it is required whenever the buyer also needs private channel inventory. ## Manage a channel in Control Room Open the event, choose **Channels**, and use the channel rail as one continuous workflow: allocate inventory, choose how it is distributed, then preview what that audience can see. ### Assign inventory at the right scale Choose the destination channel first, then select inventory in the way that matches the job: - **Sections** adds one or more complete sections; - **Rows** adds several logical rows, including segmented rows shown as one row; - **Drag box** selects a region directly on the map; - **Category** adds every seat in a price or access category; - **Seat list** provides the same selection with keyboard-accessible checkboxes; - clicking individual seats remains available for small corrections. Selections are additive and stay staged until you review and apply them. The review separates seats added from Public sale, seats moved from another private channel, seats already at the destination, and seats that cannot move because they are held or booked. Moving inventory out of another private channel is the only ordinary assignment that needs an extra confirmation. Category, section, and row selection expand to the exact inventory units that exist when the assignment is applied. They are not permanent allocation rules: after publishing new seats or changing labels, review the Channel again because new inventory starts on Public sale until it is deliberately assigned. After Apply, the review closes and a success bar reports exactly how many seats moved and how many were skipped. **Details** keeps the authoritative server breakdown available without making it a second required step. ### Choose how buyers reach a channel Open a private channel in **Channels**. Under **How buyers reach it**, the current route is marked **Current**; choose **Switch** on another to change it: | Route | Use it when | |---|---| | **Buyer links** | You want to send a hosted capability link to guests, sponsors, or a known group. | | **Your website or app** | For this private channel, your backend authenticates each eligible buyer and creates a short-lived, scoped buyer session. Ordinary Public sale uses `publicKey` directly and does not call this endpoint. | | **Your own staff** | Box office or internal staff fulfil the allocation. | | **Protected reserve** | Nobody should have buyer access yet. The inventory remains outside Public sale. | Changing the route does not move seats. The channel's card adds **in use now** only when the server confirms an active link or buyer session. ### Set a channel price only where it differs Open a private channel and choose **Edit prices**. Each blank field inherits the event's current category or ticket-tier price; enter only the audience prices that differ. Saving is versioned, so if another operator changed this price book while you were editing, Control Room refreshes the current values and asks you to review instead of overwriting them. Channel pricing is authoritative, not a display hint. The buyer sees the scoped price, the server resolves it again when the hold is created, and that hold freezes both the unit price and channel pricing version. A later edit changes future selections only. Public inventory and private inventory without an override continue to use event pricing. If an active event ticket release overlaps a channel price, the narrower channel price wins and does not consume the release quota. At the Box Office, staff can deliberately select a named release; that explicit choice wins over the channel price and retains the release attribution. > **A buyer link is not a partner login** > > A hosted link is a bearer capability: forwarding the link forwards its access. > Its redemption limit counts successful link exchanges (opens that create a > buyer session), not named people or completed Orders. > Use it for a controlled buyer audience, not as an identity or an operating > account. For recurring external operators, use Partner access. For buyers > authenticated by your own application, follow the > [private and partner sales guide](/integrations/private-and-partner-sales). ### Give a partner its own context: Partner access When an agency, sponsor, school, corporate group, promoter partner, or reseller needs to work its allocation rather than only read a report, open the private channel and choose **Partner access**. Name the partner organisation, invite one person by email, pick a role, and choose the permissions that channel carries. The partner does **not** get a login to your workspace and there is no separate portal. They accept with their own SeatLayer account (the invitation only works for the invited email; a new account is created on the spot if needed), and the access appears in their workspace switcher next to their own workspaces. Their own events, floor plans, and billing stay in their workspace; your channel lives in a *partner context* with its own left menu: Overview, Allocations, Distribution, Reports, and Team. ![One account, two contexts: the switcher lists the account's own workspaces and, below them, the partner access other organizers granted.](/diagrams/partner-one-account.svg) ![From a sales channel to partner access: the organizer assigns seats and sets up access, SeatLayer sends a single-use invitation, the partner signs in with their own account, the membership binds only when the email matches, and the context appears in the switcher.](/diagrams/partner-invite-flow.svg) #### Roles | Role | Can | |---|---| | **Viewer** | See the assigned allocation, exact seats, attributed sales, and export the CSV. | | **Coordinator** | Viewer, plus the write actions you enable: create and revoke buyer links, return free seats, request more inventory. | | **Partner manager** | Coordinator, plus invite and remove people in the partner organisation and change their roles. | Roles are set per person. You can change a role or remove a person at any time, and a Partner manager can do the same for their own teammates from the Team page (never for themself). A change takes effect on the partner's next request. #### Permissions and terms on the assignment | Setting | Effect | |---|---| | Create buyer links | Hosted buyer links inside your limits (expiry, redemptions, seats per buyer, session length). Only offered on events sold through SeatLayer checkout. | | Show Public sale in links | Off by default; when on, the partner may let its buyers also pick from public inventory. | | Return free inventory | Free seats only, against the assignment version the partner reviewed; held, booked, and blocked seats never move. | | Request more inventory | One open request at a time. You allocate N free Public-sale seats directly from the request, mark it handled, or decline. | | Release date | At that moment, whatever is still free in the channel returns to Public sale automatically, audited, through the same versioned move. | | Access ends | The context disappears for every member after this date. | ![The privacy boundary: the partner sees its own channel's seats, allocation, attributed sales, links, team, and audit trail; it never sees sibling channels, unassigned inventory, buyer identities, the organizer's settings, or other events.](/diagrams/partner-boundary.svg) Every partner request is authorised on the server against the membership, assignment, role, and enabled permission. Hiding a button is not an authorisation boundary. A partner cannot expand its own allocation, see a sibling channel, buyer identities, booking references, or anything in your settings and billing. #### Revoking - **Remove a person**: that account loses the partner context for every allocation you have given that partner organisation. - **Remove the assignment**: the channel leaves every member's context; the inventory does not move. - **Remove the partner organisation**: ends every assignment of that partner in your workspace. Existing buyer holds keep their ordinary lifecycle. Buyer links the partner created stay live until you revoke them from the channel's access links, so buyers who already received a link are not cut off by surprise. ![Allocation lifecycle: assign, distribute, sell with attribution frozen at booking, return or request, and the scheduled release back to Public sale.](/diagrams/partner-allocation-lifecycle.svg) ### Preview before distribution Use **Preview buyer access** to request the same server projection a buyer session receives. This checks the real channel scope and optional Public sale access; it is not a browser-side approximation and cannot create holds. ### Share a live report with the channel partner From **Performance → Channel**, choose **Share partner report** on a private channel. The partner opens a clean, read-only report with: - the selected channel's allocation, sell-through, and attributed sales; - the exact assigned seat or inventory labels and their current status; - search and status filters for large allocations; and - a CSV export of that same scoped inventory. Other channel names, buyer identity, booking references, and event-wide totals are never included. Attributed revenue is off by default and must be enabled for each link. Creating and revoking links remains an organizer action; the partner does not need a SeatLayer account. The URL is shown once, can expire after 7–180 days, records its view count and last-viewed time, and can be revoked immediately. It is a bearer capability, so forwarding it gives the recipient the same read-only access. > **Buyer access and report access are separate** > > Use **How buyers reach it** when the partner's customers need to choose or > buy seats. Use **Share partner report** when the partner's team only needs to > monitor its allocation. A report link can never hold, book, release, or move > inventory. For a recurring partner that needs organizer-approved distribution, return, or request actions, use **Partner access** instead. It keeps the same event/channel privacy boundary while binding access to the partner's own verified SeatLayer account rather than a shared report URL. ## A channel id is not authorization A channel id is a **label**, not a key. Sending one from a browser grants nothing, and a browser cannot select private inventory by naming a channel it guessed, was told, or read out of your page source. The server derives what a caller may select from the credential the caller presents, and ignores any channel the client claims. What actually grants access: - a [buyer access session](/server-api/buyer-access-sessions) (`bse_…`), requested by your backend and minted by SeatLayer for one buyer, one event, one origin, and a named set of channels; - a [hosted access link](/server-api/channels), which a buyer exchanges for one of those sessions; - a Partner access membership, checked on every request against the partner's own verified account, the assignment, the role, and the permission set; - a secret key that explicitly names the channel it is acting for. See the [booking behavior change](/server-api/booking). An unauthorized buyer does not receive a private seat's real state at all. Private inventory reads as one flat unavailable state, whatever is really happening inside it, and a public map receives no updates from activity it is not allowed to observe. # SeatManager reference > SeatManager options, modes, callbacks, methods, data authority, and the lower-level ManageApi. Source: https://docs.seatlayer.io/platform/seat-manager/ · Markdown: https://docs.seatlayer.io/platform/seat-manager/index.md `SeatManager` is the complete operator SDK surface. It mounts a native canvas and control rails in your document, subscribes to live state, and sends private actions with an event-scoped manage token. ```ts import { SeatManager } from "@seatlayer/js/manager"; const room = new SeatManager({ container: "#control-room", eventKey: "", token, }); await room.render(); ``` ## Constructor options | Option | Type | Required | Notes | |---|---|---:|---| | `container` | `string \| HTMLElement` | Yes | Must resolve to a sized element | | `eventKey` | `string` | Yes | One event | | `token` | `string` | Yes | Browser-safe `mse_…`; never expose `sk_…` | | `apiBase` | `string` | No | Defaults to `https://api.seatlayer.io` | | `tokenExpiresAt` | `number` | No | Epoch ms; enables proactive rotation | | `mode` | `SeatManagerMode` | No | Default `view` | | `currency` | `string` | No | Fallback; event/chart currency wins | | `theme` | `ChartTheme` | No | Operator chrome override; canvas uses chart | | `keepLiveWhileHidden` | `boolean` | No | Default `true` | | `followLive` | `boolean` | No | Default `false` | | `selectedObjects` | `string[]` | No | Public labels preselected after live inventory loads in `select` mode | | `selectableObjects` | `string[]` | No | Labels allowed as exceptions when unavailable objects are otherwise excluded | | `unavailableObjectsSelectable` | `boolean` | No | Include held, booked, and blocked objects in `select`; default `true` | | `maxSelectedObjects` | `number` | No | Maximum custom-operation selection | | `numberOfPlacesToSelect` | `number` | No | Require exactly this many objects and use it as the selection cap | | `isObjectSelectable` | `(object, defaultValue) => boolean` | No | Final host policy for each object in `select` mode | | `chrome` | `'full' \| 'minimal'` | No | `minimal` shows only the live map, its zoom buttons, and Venue. Default `full` | | `unavailableObjects` | `readonly string[]` | No | Public labels shown as unavailable although the event has them free. Display only | | `unavailableObjectsReason` | `string` | No | Hover status word for those labels. Default `Unavailable` | | `categoryPrices` | `Record \| null` | No | Price shown on the map's seat and section hover, per category key. Display only. Since `0.99.0` | | `capabilities` | `SeatManagerCapability[]` | No | Declared token capabilities used to hide/fail-close privileged tools | | `tools` | `SeatManagerMode[]` | No | Which tools the toolbar offers. Default: every tool the capabilities permit. Listing a tool never grants it, and `view` is always kept | ```ts type SeatManagerMode = | "view" | "inspect" | "select" | "filterSections" | "block" | "sections" | "categories" | "tables" | "channels"; ``` `view` is the read-only live/static event view. `select` is a read-only custom-operation tool, while `filterSections` frames one public section label for host-owned reporting. None of those tools mutates inventory. `categories` and `tables` make event-only changes and are hidden unless their dedicated capabilities are declared. The toolbar presents these in three labelled groups, so an operator can tell at a glance what a tab will do: | Group | Tools | What it means | |---|---|---| | Watch | `view`, `inspect` | Read-only. Nothing changes. | | Manage | `block`, `sections`, `categories`, `tables`, `channels` | Changes this event only. The reusable chart is never edited. | | Host tools | `select`, `filterSections` | Hands a selection or a filter to *your* app; the control room itself changes nothing. | Each rail repeats that scope as a badge next to its title (`Read-only`, `This event only`, `Your app`). Use `tools` to hide what a surface does not need: a dashboard that never consumes `onSelectionChange` should not show a Select tab that leads nowhere: ```ts new SeatManager({ container: "#room", eventKey: "ev_123", token: manageToken, capabilities: ["event:view", "event:block", "event:categories:manage"], // Watch + Manage only. The whole "Host tools" group disappears. tools: ["view", "inspect", "block", "sections", "categories"], }); ``` `tools` is a filter, never a grant: a mode whose capability is missing stays hidden however it is listed, and any refused mode falls back to `view`. ## Callbacks | Callback | Signature | |---|---| | `onReady` | `() => void` | | `onTallies` | `(tallies: SeatManagerTallies) => void` | | `onActivity` | `(activity: SeatManagerActivity) => void` | | `onControlRoom` | `(snapshot: ControlRoomSnapshot) => void` | | `onTokenRefresh` | `() => Promise<{token: string; expiresAt: number}>` | | `onModeChange` | `(mode: SeatManagerMode) => void` | | `onFollowLiveChange` | `(enabled: boolean) => void` | | `onSelectionChange` | `(seats: ExpandedSeat[]) => void` | | `onObjectSelected` / `onObjectDeselected` | `(object: ExpandedSeat) => void` | | `onSelectionValidityChange` | `(state: SeatManagerSelectionValidity) => void` | | `onSelectionValid` / `onSelectionInvalid` | `(state: SeatManagerSelectionValidity) => void` | | `onSelectionLimit` | `(max: number) => void` | | `onFilteredSectionChange` | `(sections: SeatManagerFilteredSection[]) => void` | | `onActionComplete` | `(result: SeatManagerActionResult) => void` | | `onError` | `(error: unknown) => void` | ## Methods | Method | Result | |---|---| | `render()` | Load, subscribe, and return `Promise` | | `setMode(mode)` | Switch operator tool | | `setHeatOverlay(enabled)` | Toggle velocity outline | | `setFollowLive(enabled)` | Toggle camera following | | `setTrendWindow(minutes)` | Refresh and return control-room snapshot | | `enterFullscreen()` / `exitFullscreen()` | Native fullscreen | | `isFullscreen()` | Current fullscreen state | | `setToken(token, expiresAt?)` | Rotate credential without remount | | `block(labels?, {releaseAt?, reason?})` | Block labels or current selection | | `unblock(labels?)` | Unblock labels or selection | | `unblockAll()` | Unblock all event inventory | | `cancelBooking(labels, bookingRef)` | Booking-reference-safe cancellation | | `setCategory(categoryKey, labels?)` | Assign an event-only category to labels or the selection | | `setTableBooking(tableIds, mode, bounds?)` | Change free event tables between per-chair, whole-table, or variable occupancy | | `selectAll()` | Select all blockable seats | | `selectSection(sectionId)` | Select section | | `selectByLabels(labels)` | Select exact labels | | `selectObjects(labels)` / `deselectObjects(labels)` | Custom selection by public label | | `selectCategories(keys)` / `deselectCategories(keys)` | Custom selection by chart category | | `setSelectableObjects(labels)` | Replace unavailable-object exceptions | | `setUnavailableObjects(labels, reason?)` | Replace the labels shown as unavailable, and optionally their hover word | | `setCategoryPrices(prices)` | Replace the hover prices without remounting. Since `0.99.0` | | `setUnavailableObjectsSelectable(enabled)` | Include or exclude unavailable inventory | | `setObjectSelectable(predicate)` | Replace the final host selection policy | | `setMaxSelectedObjects(max?)` | Change or clear the custom selection cap | | `setNumberOfPlacesToSelect(required?)` | Change or clear exact-count selection | | `getSelectionValidity()` | Exact-count state, or `null` when no count is required | | `setFilteredSection(label)` | Filter and frame every section with this public label | | `clearFilteredSection()` | Clear filtering and restore the full event frame | | `getFilteredSections()` | Read all currently matched sections | | `clearSelection()` / `getSelection()` | Manage block selection | | `getReport()` | Private event report | | `getControlRoomSnapshot(minutes?)` | Booked value, velocity, presence, activity | | `getLog({limit?, before?})` | Paginated audit log | | `setHoldTtl(msOrNull)` | Set or clear event checkout window | | `zoomToFit()` | Fit chart | | `destroy()` | Close socket, timers, and mounted UI | ## Host prices on the map hover By default the seat and section hover show the chart's own category prices. When you sell at a price the chart does not carry, such as a season package, pass `categoryPrices` (available since `@seatlayer/js` `0.99.0`): ```ts import { SeatManager, type SeatManagerCategoryPrice } from "@seatlayer/js/manager"; const prices: Record = { stalls: 480, // one amount circle: { min: 320, max: 400 }, // a from–to range }; const room = new SeatManager({ container: "#control-room", eventKey: "", token, categoryPrices: prices, }); await room.render(); // Later, without remounting: room.setCategoryPrices({ stalls: 520 }); // override these categories room.setCategoryPrices(null); // hide the price on hover room.setCategoryPrices(undefined); // back to the chart's prices ``` ```ts type SeatManagerCategoryPrice = number | { min: number; max: number }; ``` - Amounts use the same units as the chart's category prices. - A category the map leaves out keeps its chart price. - `null` hides the price on the hover. `undefined`, or leaving the option out, shows the chart's prices. - The prices are display only. Nothing is sent to the server, and holds and bookings keep their own server-side prices. ## Event-only category and table controls `setCategory()` changes the event snapshot and current free inventory pricing; it does not edit the reusable chart. Existing hold and booking line-item price snapshots remain immutable. `setTableBooking()` changes inventory identity, so SeatManager reloads the authoritative event chart after success. The server refuses the operation when any affected table unit is held, booked, or blocked, or when changing labels would drop a private-channel assignment. Variable occupancy bounds must fit the physical table capacity. These controls require inventory model 2. `boxBook()` exists as a compatibility stub in current package declarations. Do not call it. Use the server-side [`box-book` route](/server-api/cancellations-and-box-office). ## Tallies ```ts interface SeatManagerTallies { free: number; held: number; booked: number; blocked: number; total: number; capacityPct: number; sellThroughPct: number; grossRevenue: number; revenueStatus: "loading" | "current" | "stale"; currency: string; } ``` `capacityPct` is booked divided by total capacity. `sellThroughPct` is booked divided by capacity excluding blocked inventory. `grossRevenue` and `revenue` are legacy SDK field names. They come from authenticated configured-price snapshots captured with booked inventory and should be labelled **booked value** in a Platform product. They are never reconstructed from the current list price, but they also do not prove the amount charged, settled, or refunded in your commerce system. ## Control-room snapshot ```ts interface ControlRoomSnapshot { version: number; currency: string; totals: { free: number; held: number; booked: number; blocked: number; }; revenue: { gross: number; bySection: ControlRoomSectionMetric[]; }; velocity: { windowMinutes: number; bySection: Array<{ sectionId: string; netBooked: number; grossRevenue: number; previousNetBooked: number; trend: "rising" | "steady" | "cooling"; }>; }; presence: { shoppingSessions: number; activeHolds: number; }; activity?: ControlRoomActivityEntry[]; event: { key: string; name: string; seatTotal: number; currency?: string; }; } ``` Section metrics include section and zone identity plus total, free, held, booked, not-for-sale, and configured booked-value snapshots. ## Action results ```ts interface SeatManagerActionResult { action: | "block" | "unblock" | "unblockAll" | "cancelBooking" | "setHoldTtl" | "setCategory" | "setTableBooking"; labels: string[]; count: number; } ``` Errors from permission, conflict, expiry, and transport failures reach `onError` and reject the relevant promise. Do not infer success from an optimistic repaint. ## ManageApi `ManageApi` is the lower-level transport for custom operator interfaces: ```ts import { ManageApi } from "@seatlayer/js/manager"; const api = new ManageApi("https://api.seatlayer.io", token); const report = await api.report(eventKey); const rules = await api.availability(eventKey); ``` It provides `chart`, `objects`, `socketUrl`, `block`, `unblock`, `unblockAll`, `unbook`, `setCategory`, `setTableBooking`, `setHoldTtl`, `availability`, `setAvailability`, `report`, `controlRoom`, `log`, and `reportCsv`. `reportCsv()` returns a `Blob` because a Bearer token cannot be attached to a plain download link. ## Lifecycle Call `render()` before imperative methods. Call `destroy()` on route changes, component unmount, logout, and event switching. Reuse `setToken()` for rotation rather than reconstructing the board. See the [control-room guide](/platform/embedded-control-room) and [manage-session security model](/platform/embed-sessions). # Ticket pricing and offers > Set event-owned normal prices and run early-bird, scheduled, limited, and category-specific ticket offers. Source: https://docs.seatlayer.io/platform/ticket-pricing-and-offers/ · Markdown: https://docs.seatlayer.io/platform/ticket-pricing-and-offers/index.md Ticket offers temporarily replace the normal price of eligible ticket categories. They are useful for an early-bird discount, a scheduled price change, or a limited promotion for one part of the venue. An offer changes **price**. It does not reserve inventory, restrict who can buy, or close ticket sales. In the [SeatLayer product model](/start/product-model/), pricing is independent from both the [inventory model](/start/inventory-models/) and [Sales Channel access](/platform/sales-channels/). Choose the Event, Performance Group, or Season transaction first; then apply only the pricing rules supported by that product and commerce boundary. ## The one-minute model Creating an event copies the seating chart's category and ticket-type prices. From then on, those normal prices belong to that event. Edit them in **Pricing & availability** without changing the chart or another event that uses it. When a buyer selects a seat, SeatLayer checks whether an active offer matches that seat's category. When an offer ends or reaches its limit, the next active matching offer takes over. If none matches, that event's normal price returns automatically. If the source chart's price defaults change later, linked events keep their current prices. The Designer asks which events should receive the new defaults; you can also review and apply them from the event's Pricing & availability screen. Existing holds and bookings retain their locked price either way. ## Common setups | Goal | Example | |---|---| | Early-bird discount | Stalls at €45 instead of €60, limited to 100 tickets | | Scheduled price | Balcony at €30 until Friday at 23:59 | | Category promotion | Boxes at €80 for the first 20 tickets | | Price sequence | Early bird → Standard → Final price | ## Create an offer Open the event and choose **Pricing & availability** (or **Tickets** when SeatLayer Checkout is enabled). Use **Edit normal prices** to change this event's base category or ticket-type prices. Those changes apply to future selections in this event only. Then, to add a temporary offer: 1. Choose an example or select **Create another offer**. 2. Give the offer the public name buyers should see. 3. Select the eligible ticket categories. 4. Enter the offer price and, if useful, a genuine regular or earlier price. 5. Add an optional ticket quantity, start time, or end time. 6. Review the summary and choose **Publish changes**. The organizer list shows Draft, Upcoming, Live, Sold out, and Ended states. Use the priority controls when scheduled offers overlap; the offer nearest the top wins for a seat matched by more than one active offer. ## Quantities include active carts An offer quantity measures tickets currently secured or purchased under that offer. A ticket in an active buyer cart temporarily uses one unit. If the buyer removes it or the hold expires, that unit returns automatically. For an offer limited to 100 tickets: | State | Available at the offer price | |---|---:| | 20 purchased, 5 held | 75 | | Those 5 holds expire | 80 | | 10 more are purchased | 70 | The organizer screen and every open SeatPicker receive the updated quantity and fallback price without a page reload. ## Eligible categories and ticket types An offer can apply to one category or to **All eligible categories**. A category that already uses ticket types such as Adult, Child, or VIP is excluded because each ticket type has its own price. Use the chart's ticket types to price those choices rather than putting a second price authority over them. ## Offers are not presale access Before a scheduled offer begins, tickets remain available at their normal prices. Scheduling an offer does not hide seats or make the event private. | What you need | Use | |---|---| | Change the price temporarily | **Ticket offer** | | Adult, Child, VIP, or concession choices | Ticket types | | Private/member/partner access | [Sales channels](/platform/sales-channels) | | Keep seats off sale | [Blocking](/server-api/blocking) | | Stop all online sales | Event sales controls | ## What buyers see The canonical SeatPicker shows the public offer name, current price, genuine comparison price, remaining quantity, and end time. The same resolved price is used in map tooltips, ticket-price rows, the selection tray, hosted pages, iframes, popup pickers, and SDK integrations. The server remains the charging authority. If an offer sells out while a buyer is looking at the map, the picker receives the next matching offer or normal price and redraws without losing the buyer's current selection. ## Ending an offer Use **End now** to stop an offer price immediately. Tickets already purchased retain the price paid, and active carts retain their secured price until their hold expires. The ended offer remains in the organizer list as historical context. # Vue SeatManager > Embed the live SeatManager control room in a Vue 3 app with @seatlayer/vue/manager. Props, live updates, events, the template ref, and the token rules. Source: https://docs.seatlayer.io/platform/vue-seat-manager/ · Markdown: https://docs.seatlayer.io/platform/vue-seat-manager/index.md `SeatManager` from `@seatlayer/vue/manager` puts the live control room inside your Vue 3 app. It is a thin layer over the [`SeatManager` class](/platform/seat-manager) in `@seatlayer/js/manager`: the same board, the same tools, the same token rules. This page covers what is different in Vue. Read the [control-room guide](/platform/embedded-control-room) for the board itself. Available from `@seatlayer/vue` 0.106.0. ## Import it from the manager entry ```ts import { SeatManager } from "@seatlayer/vue/manager"; ``` `SeatManager` is not exported from the package root. The `/manager` entry holds the control room and nothing else, so a buyer page that imports `@seatlayer/vue` stays the same size, and a control room does not carry the buyer picker. ## Mount the board Your backend mints a short-lived, event-scoped `mse_` token after it checks that the signed-in user may manage the event. Never put an `sk_` secret key in browser code. Keep the token in memory, never in a URL, browser storage, or logs. ```vue ``` Give the component a real height. The board fills its box. It is destroyed when the component unmounts. ## Props The props are the [constructor options](/platform/seat-manager#constructor-options) without `container`. **Live** props change the running board in place: the camera, selection, live connection and DOM stay. **Read once** props are read when the board is built. Changing `eventKey` or `apiBase` builds a new board. | Prop | When it applies | |---|---| | `eventKey`, `apiBase` | Changing either builds a new board | | `token`, `tokenExpiresAt` | Live | | `onTokenRefresh` | Live | | `capabilities` | Live | | `currency` | Live | | `theme` | Live | | `themeMode` | Applied before the first paint, then live | | `keepLiveWhileHidden` | Live | | `mode` | Live | | `followLive` | Live | | `selectableObjects`, `unavailableObjectsSelectable` | Live | | `unavailableObjects`, `unavailableObjectsReason` | Live | | `categoryPrices` | Live | | `maxSelectedObjects`, `numberOfPlacesToSelect` | Live | | `isObjectSelectable` | Live | | `sale` | Live, compared by value | | `mapInsets` | Live, compared by value | | `tools`, `chrome` | Read once | | `selectedObjects` | Read once | | `kpis` | Read once. `false` when your own header shows the numbers | | `timeZone` | Read once | | `organizationName` | Read once | | `colourBy` | Read once | | `focusSection`, `focusSeat`, `focusSeats`, `focusCategory` | Read once | | `arrivedFrom` | Read once | | `onOpenOrder`, `onOpenTrend` | Read once. See below | | `onOpenDesigner` | Read once. When set, Sections on a chart without sections offers "Open the designer" | "Compared by value" means a new object with the same fields does nothing. You can build `sale` or `mapInsets` inline in a template without repainting the board on every render. ## Events Each reporting callback is a Vue event. | Event | Payload | |---|---| | `@ready` | none | | `@tallies` | `SeatManagerTallies` | | `@activity` | `SeatManagerActivity` | | `@control-room` | `ControlRoomSnapshot` | | `@mode-change` | `SeatManagerMode` | | `@follow-live-change` | `boolean` | | `@selection-change` | `ExpandedSeat[]` | | `@object-selected`, `@object-deselected` | `ExpandedSeat` | | `@selection-validity-change`, `@selection-valid`, `@selection-invalid` | `SeatManagerSelectionValidity` | | `@selection-limit` | `number` | | `@filtered-section-change` | `SeatManagerFilteredSection[]` | | `@area-click` | `SeatManagerArea` | | `@action-complete` | `SeatManagerActionResult` | | `@connection-change` | `SeatManagerConnection` | | `@room-state-change` | `SeatManagerRoomState`: `connecting`, `live`, `paused`, `offline` or `ended` | | `@error` | `unknown` | ## Callbacks that are props Three callbacks are props, not events: - `onTokenRefresh` returns the new token, so it has to be a function the board can call. - `onOpenOrder` and `onOpenTrend` tell the board you can open an order or a trend view. The board offers "Open order" and the trend link only when you pass one, so it never shows a link that goes nowhere. Vue sends `@open-order="fn"` to the `onOpenOrder` prop, so you can write either `:on-open-order="fn"` or `@open-order="fn"`. The same goes for `@open-trend` and `@token-refresh`. ## Template ref The template ref is typed `SeatManagerExposed` (also exported as `SeatManagerHandle`). It has the same methods as the React handle: | Method | Result | |---|---| | `setMode(mode)` | Switch tool | | `setToken(token, expiresAt?)` | Rotate the token without a rebuild | | `setHeatOverlay(enabled)`, `setFollowLive(enabled)` | Toggle the overlay or camera following | | `setTrendWindow(minutes)` | Refresh and return the control-room snapshot | | `enterFullscreen()`, `exitFullscreen()`, `isFullscreen()` | Fullscreen | | `block(labels?, { releaseAt?, reason? })`, `unblock(labels?)`, `unblockAll()` | Block or unblock inventory | | `cancelBooking(labels, bookingRef)` | Cancel by booking reference | | `setCategory(categoryKey, labels?)`, `setTableBooking(tableIds, mode, bounds?)` | Event-only category and table changes | | `selectAll()`, `selectSection(id)`, `selectByLabels(labels)` | Select seats | | `selectObjects(labels)`, `deselectObjects(labels)` | Custom selection by label | | `selectCategories(keys)`, `deselectCategories(keys)` | Custom selection by category | | `setSelectableObjects`, `setUnavailableObjectsSelectable`, `setUnavailableObjects`, `setCategoryPrices`, `setObjectSelectable`, `setMaxSelectedObjects`, `setNumberOfPlacesToSelect` | The live props, called directly | | `getSelectionValidity()` | Exact-count state, or `null` | | `setFilteredSection(label)`, `clearFilteredSection()`, `getFilteredSections()` | Section filter | | `focusSection(label)` | Frame matching sections without filtering | | `clearSelection()`, `getSelection()` | Selection | | `getReport()`, `getControlRoomSnapshot(minutes?)`, `getLog({ limit?, before? })` | Reports and audit log | | `setHoldTtl(msOrNull)` | Set or clear the event checkout window | | `getConnection()` | Live link state, or `null` before the board exists | | `zoomToFit()` | Fit the chart | | `showObjects(labels, name?)` | Light and frame these seats, named in the legend. `null` clears | A method called before the board exists returns an empty answer. `getReport`, `getControlRoomSnapshot` and `setTrendWindow` reject with `not ready`. ## Types `@seatlayer/vue/manager` exports `SeatManagerExposed`, `SeatManagerHandle`, `SeatManagerProps`, `SeatManagerOptions`, `SeatManagerMode`, `EventScopedManageToken`, `SeatManagerTallies`, `SeatManagerSale`, `SeatManagerActivity`, `SeatManagerActionResult`, `SeatManagerConnection`, `SeatManagerRoomState`, `SeatManagerFilteredSection`, `SeatManagerSelectionValidity`, `SeatManagerCategoryPrice`, `EventTableBookingMode`, `ControlRoomActivityEntry`, `ControlRoomSectionMetric` and `ControlRoomSnapshot`. Continue to the [SeatManager reference](/platform/seat-manager), the [control-room guide](/platform/embedded-control-room), and [embed sessions](/platform/embed-sessions). # Workspaces > Isolate charts and events for organizers, customers, brands, venues, or environments inside one SeatLayer organization. Source: https://docs.seatlayer.io/platform/workspaces/ · Markdown: https://docs.seatlayer.io/platform/workspaces/index.md A workspace is SeatLayer's resource-isolation boundary. Every chart and event belongs to exactly one workspace; billing, API keys, webhooks, and team roles belong to the organization. ```text title="workspace-model.txt" Organization ├─ account, team, API keys, plan, credits, currency, webhooks ├─ Workspace A │ └─ charts, events, embed sessions └─ Workspace B └─ charts, events, embed sessions ``` Use one workspace per organizer or customer for a multi-tenant platform. Smaller teams can use workspaces for venues, brands, regions, or environments. ## Isolation contract - `workspaceId` is enforced by SeatLayer, not just a routing tag. - A chart's workspace is immutable. - An event inherits the published chart's workspace. - Cross-workspace chart/event and chart/session pairings return `404`. - List endpoints resolve one active workspace at a time. - A disabled workspace is unavailable to normal resource reads and creates. - Billing and team roles are still organization-wide. > **externalRef is not authorization** > > `externalRef` helps you reconcile SeatLayer resources with your tenant ids. > It does not isolate access. Authorize the user in your application, then use > the stored `workspaceId`. ## List workspaces **GET /v1/workspaces**. Authentication: Secret key or dashboard session ```bash curl -s "https://api.seatlayer.io/v1/workspaces" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` ## Create a workspace **POST /v1/workspaces**. Authentication: Secret key or admin dashboard session ```bash curl -sX POST "https://api.seatlayer.io/v1/workspaces" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "idempotency-key: workspace_customer_123" \ -H "content-type: application/json" \ -d '{ "name": "Customer 123", "externalRef": "customer_123", "defaultRegion": "western-europe" }' ``` `name` is required and at most 120 characters. `externalRef` is optional, at most 128 characters, and unique within the organization. `defaultRegion` is an optional [Event region](/server-api/event-regions/); omitted workspace creates use `western-europe`. ```json { "workspace": { "id": "ws_8b5c", "name": "Customer 123", "externalRef": "customer_123", "status": "active", "isDefault": false, "defaultRegion": "western-europe", "createdAt": 1761436800000, "updatedAt": 1761436800000 } } ``` Always use `Idempotency-Key` during tenant provisioning and store the returned workspace id on your tenant record. ## Read one workspace **GET /v1/workspaces/:id**. Authentication: Secret key or dashboard session An id from another organization returns `404`, avoiding cross-tenant resource disclosure. ## Update, disable, or make default **PATCH /v1/workspaces/:id**. Authentication: Secret key or admin dashboard session ```json { "name": "Customer 123 (Europe)", "externalRef": "customer_123", "status": "active", "isDefault": true, "defaultRegion": "western-europe" } ``` Secret keys have account authority and may update an organization-owned workspace through the Server API. Dashboard writes require an admin session. `externalRef: null` clears the tag. `isDefault` can only be set to `true`. The default workspace cannot be disabled; promote another active workspace first. Changing `defaultRegion` affects only later Events. An Event may override it; existing Events never move. ## Scope charts and events ```bash curl -s \ "https://api.seatlayer.io/v1/events?workspaceId=ws_8b5c&externalRef=show_42" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` When a secret-key request omits `workspaceId`, SeatLayer resolves the organization's default workspace. Platform code should pass it explicitly to avoid accidental placement. ## Canonical tenant provisioning 1. Authorize the platform administrator in your application. 2. Create a workspace with a stable [idempotency key](/server-api/idempotency-and-conflicts/). 3. Persist `workspace.id` beside your tenant id. 4. Duplicate or create charts in that workspace. 5. Create events from charts in the same workspace. 6. Mint browser embed sessions scoped to that workspace or its event. 7. Route webhooks using the explicit `workspaceId` and your own stored mapping. 8. Aggregate tenant usage from its events; the credit wallet remains org-wide. ## Checklist - [ ] Every host tenant record stores an immutable workspace id. - [ ] Every list and create request passes the intended workspace explicitly. - [ ] `externalRef` is used for reconciliation only. - [ ] Your application enforces per-tenant roles above SeatLayer's org-wide roles. - [ ] Disabled-workspace behavior is handled as an operator state, not a 500. - [ ] Provisioning retries reuse one idempotency key and body. Continue to [platform integration](/integrations/platforms), [embedded Designer](/platform/embedded-designer), and [embed sessions](/platform/embed-sessions). # Choose an SDK or API > Compare SeatLayer client SDKs, seven server SDKs, and REST/OpenAPI, then follow the shortest production path for your application stack. Source: https://docs.seatlayer.io/sdks/ · Markdown: https://docs.seatlayer.io/sdks/index.md Choose the surface that matches where your code runs and how much of the buyer experience your product should own. Client SDKs select and hold inventory; trusted server code inspects the hold and confirms booking. > **Check exact release status before installing** > > Package versions and release channels change independently. Use the > [machine-readable SDK catalog](/sdk-catalog.json) for the current package, > registry, source, stability, and verification evidence, then follow the > selected SDK guide for implementation. ## Choose by runtime | Your code runs in | Start here | Best when | |---|---|---| | Browser or web framework | [Web SDKs](/buyer-sdk/web/) | JavaScript, TypeScript, React, Vue, or Angular owns the buyer page | | Native mobile application | [Native app SDKs](/buyer-sdk/mobile/) | Flutter, React Native, Swift, or Kotlin owns the app screen | | Trusted application server | [Server SDKs](/server-sdk/) | You want an idiomatic client for Node.js, Python, PHP, Java, Go, Ruby, or .NET | | Any HTTPS-capable backend | [REST API and OpenAPI](/server-api/) | You want direct HTTP control or generated clients for another language | ## Make the next choice on the platform page Each platform page owns the choices that only apply there: - **Web:** the [Web SDKs](/buyer-sdk/web/) page compares the JavaScript, React, Vue, and Angular packages and the ready-made `SeatPicker` against the map-only `SeatingChart`. - **Native apps:** the [Native app SDKs](/buyer-sdk/mobile/) page compares Flutter, React Native, iOS, and Android and the four levels of buyer UI you can own. - **Server:** the [Server SDKs](/server-sdk/) page lists the seven languages and the booking contract they share. - **Several dates in one purchase:** choose an [Event, Performance Group, or Season](/start/inventory-models/) first. The [Client SDKs](/buyer-sdk/) page shows which buyer component fits each model. If you have not yet decided whether your product or SeatLayer owns checkout, start with [Choose an integration](/start/choose-an-integration/). ## Keep the trust boundary fixed The browser or mobile app may select inventory, create a temporary hold, and send its opaque `holdId` to your backend. Only trusted server code should hold a secret key, inspect authoritative hold contents, calculate the charge, and book with a stable `bookingRef`. Continue with the [REST API overview](/server-api/) for the HTTP contract behind every SDK. # Box office > Sell at the counter from the live seat map, take payment your way, print on an 80 mm roll or A4, and find any order. Source: https://docs.seatlayer.io/sell/box-office/ · Markdown: https://docs.seatlayer.io/sell/box-office/index.md The box office sells from the same live inventory as your website. A seat sold at the counter is gone from every buyer's screen at once. Open the event and choose the **Box office** tab. It appears on events that use SeatLayer checkout. ## Make a sale 1. **Choose seats** In **Sell** mode, use **List** or **Map**. Type a seat like `H12` in **Find tickets**, tap seats on the map, or use **Best [n] seats in [price]** and **Add best**. **Pick again** swaps in the next best block. Standing areas are sold by quantity, and tables whole or by the chair. 2. **Record the payment** Under **How were you paid?** choose how the money was taken. **Cash** is preselected; the list also shows **Card** (on your own card machine), **Complimentary**, and any method your admin added. SeatLayer records how you were paid. It never charges anyone at the counter. To give a discount, choose **+ Discount** and pick a preset; the sale shows the price, the discount and the new total before you confirm. 3. **Buyer details** **Buyer name**, **Email** and **Note** are optional. With an email, the tickets are sent there. If the event asks for a name on every ticket, the form says so. 4. **Complete and print** Choose **Complete sale** (Ctrl or ⌘ + Enter). The receipt shows the total and how it was paid. **Print tickets** (key P) prints; **Next sale** starts the next one. ## Payment methods, discounts and comps An admin sets these up once in **Settings → Box office**. - **Counter payment methods.** Cash, Card and Complimentary come as standard. Choose **+ Add a method** for others, such as a bank transfer (up to 12). Rename, reorder, or hide them; the order sets the number keys 1–9. Cash is always offered and can only be renamed. A method that was used is hidden rather than deleted, so its orders and reports keep the name. - **Discounts and comps.** **+ Add a discount** creates a preset such as "Student 20%": a percentage, or a fixed amount in one currency. A fixed amount is offered only on events in that currency, and is never converted. **+ Add a comp reason** creates a 100% preset such as "Press (free)", which is recorded as a comp with its name as the reason. Staff can only apply presets; they cannot type their own discount. The preset's name prints on the ticket, the receipt and the confirmation email, and a comp shows as Complimentary. When staff choose **Complimentary**, they pick a comp reason or choose **Other…** and say why it is free. Only admins can change these settings. Choose **Save box office settings** when you are done. ## Printing Set **Paper** once per device: **A4 sheet** or **80 mm roll**. For one-click printing, make the roll printer the device's default printer, with 80 mm paper. Tickets print from a PDF, so any printer the browser can reach works. ## Find an order Switch to **Find order** (key F). Search by name, email, phone (four digits or more), the 8-digit order number or the code on a ticket, or scan the ticket. From the order you can: - **Print again**. The panel shows how many times it was printed, and every copy is logged. Each copy has the same QR code, so the door still admits the ticket once. - **Email tickets** to the address on the order or a new one. - **Change seats**. - Read the order's **History**. Refunds and cancellations need an admin. See [Refunds and cancellations](/sell/refunds-and-cancellations/). # Connect payments > Connect one of 14 payment gateways on the Payments page, keep test and live side by side, and assign a connection to an event. Source: https://docs.seatlayer.io/sell/connect-payments/ · Markdown: https://docs.seatlayer.io/sell/connect-payments/index.md Buyers pay into your own gateway account. SeatLayer creates the payment on your account and records the order; it never holds your funds. The gateway sets its own processing fees and payout timing. Fourteen gateways are supported. The [payments overview](https://seatlayer.io/payments/) compares them side by side. | Gateway | Regions | Buyers pay with | |---|---|---| | Stripe | Most countries | Card | | Razorpay | India | Card, UPI, netbanking | | PayPal | Most countries | PayPal, card | | Tap | Middle East and the Gulf | Card, Apple Pay, KNET, mada | | Mercado Pago | Latin America | Card, Pix, Mercado Pago wallet | | Xendit | Southeast Asia | Card, e-wallet, QR | | Flutterwave | Africa | Card, mobile money, bank transfer | | Square | North America, Europe, Australia and Japan | Card, Apple Pay, Google Pay | | Mollie | Europe | Card, iDEAL, Bancontact, PayPal | | Paystack | Africa | Card, bank transfer, mobile money | | Authorize.Net | United States, Canada, UK, Europe and Australia | Card | | Checkout.com | Europe, UK, the Middle East, Asia Pacific and the Americas | Card, Apple Pay, Google Pay, local methods | | iyzico | Turkey | Card, installments | | Braintree | United States, Canada, UK, Europe, Australia and more | Card | Open **Settings → Payments**. Stripe and Razorpay are open to every account: each has a card with **Connect** and its name, for example **Connect Stripe**. The other twelve are listed under **More payment options** until they are on for your account. An admin chooses **Request** next to one; we switch it on and its card appears with the others. Then connect it the same way. For a gateway that is not listed, choose **Ask us**. Every form asks for the **Currency** your events are priced in, limited to the currencies that gateway takes. Each section below lists them. All keys and secrets are encrypted on save and cannot be read back, by you or by SeatLayer staff. ## Test and live There is no mode switch. The credentials decide the mode: a test key makes a test connection, a live key makes a live one. For gateways whose keys look the same in both environments (Square, Authorize.Net, Braintree), SeatLayer checks which environment accepts them. You can keep test and live side by side, and one connection per currency. Test events use a test connection and issue tickets marked as not valid for entry. ## Stripe Currencies: every currency SeatLayer sells in. See the [Stripe ticketing page](https://seatlayer.io/payments/stripe/) for what buyers see. 1. **Create a restricted key in Stripe** Choose **Open Stripe API keys** and create a restricted key with these permissions: Checkout Sessions: Write, Refunds: Write, Webhook Endpoints: Write, Basic Business Contact Information: Read. See [Stripe permissions](/billing/stripe-permissions/) for why each one is needed. For a test event, switch Stripe's Test mode on first. 2. **Paste it** Paste the key into **Restricted or secret key** and choose **Connect Stripe**. A restricted key (`rk_…`) or the secret key (`sk_…`) both work; the publishable key (`pk_…`) does not. 3. **Nothing else to do** SeatLayer verifies the key with Stripe and creates the payment webhook on your account. ## Razorpay Currencies: every currency SeatLayer sells in. Razorpay is listed first for events priced in INR. 1. **Generate API keys** Choose **Open Razorpay API keys** and generate a key pair. Razorpay shows the Key Secret only once. 2. **Create a webhook** In Razorpay, go to Account & Settings → Webhooks → Add new webhook. Use the URL shown under **Your webhook URL**, subscribe to `payment.captured`, `payment.failed` and `refund.processed`, and choose a secret. Without this webhook, paid orders never confirm. 3. **Paste all three values** Enter **Key ID**, **Key secret** and **Webhook secret (from step 2)**, pick the **Currency** your events are priced in, and choose **Connect Razorpay**. ## PayPal Currencies: USD, EUR, GBP, CAD, AUD, SGD, JPY, CHF, SEK, NZD, MXN, BRL, PHP, THB, MYR. See the [PayPal ticketing page](https://seatlayer.io/payments/paypal/) for more. 1. **Create an app in PayPal** Choose **Open PayPal apps**, open Apps & Credentials and create an app. Use a Sandbox app for test events and a Live app for live events. 2. **Accept payments in your event currency** In your PayPal account settings, set payments in other currencies to be accepted automatically. Otherwise PayPal holds each payment until you approve it. 3. **Paste the app's credentials** Enter **Client ID** and **Secret** and choose **Connect PayPal**. SeatLayer sets up PayPal's payment notifications for you. ## Tap Currencies: USD, EUR, GBP, AED, SAR, KWD, QAR, BHD, OMR. 1. **Open your Tap API credentials** Choose **Open Tap dashboard**, then goSell → API credentials. Copy the sandbox secret key (`sk_test_…`) for a test event or the production secret key (`sk_live_…`) for a live one. 2. **Paste the secret key** Enter **Secret key** and choose **Connect Tap**. Tap signs its payment notifications with this key, so there is nothing else to copy. ## Mercado Pago Currencies: BRL, MXN, ARS, CLP, COP, PEN, UYU. Your Mercado Pago account's country decides which one, and SeatLayer checks it when you connect. 1. **Open your application's credentials** Choose **Open Mercado Pago Developers**, open Your integrations, choose your application, then Credentials. Copy the test Access Token for a test event or the production one for a live event. 2. **Paste the Access Token** Enter **Access Token** and choose **Connect Mercado Pago**. Each payment is read back from Mercado Pago to confirm it, so there is no webhook to set up. ## Xendit Currencies: IDR, PHP, THB, VND, MYR, SGD. 1. **Create a secret key in Xendit** Settings → API keys → Generate secret key, with Write access to Money-in. Switch Xendit to Test mode for a test event. 2. **Add the webhook URL** In Xendit, go to Settings → Webhooks and paste **Your webhook URL** for Payment session and Refund. Copy the verification token shown on that page. 3. **Paste both values** Enter **Secret key** and **Verification token (from step 2)**, then choose **Connect Xendit**. ## Flutterwave Currencies: NGN, GHS, KES, UGX, TZS, RWF, XAF, XOF, EGP, ZAR, USD, EUR, GBP. 1. **Copy your secret key from Flutterwave** Settings → API keys. Use the test key (`FLWSECK_TEST-…`) for test events and the live key (`FLWSECK-…`) for live events. 2. **Add the webhook URL** In Flutterwave, go to Settings → Webhooks, paste **Your webhook URL**, choose a secret hash of 12 characters or more, and turn on retries for failed webhooks. 3. **Paste both values** Enter **Secret key** and **Secret hash (from step 2)**, then choose **Connect Flutterwave**. ## Square Currencies: USD, EUR, GBP, CAD, AUD, JPY. A Square location settles in one currency, and the connection takes that location's currency. See the [Square ticketing page](https://seatlayer.io/payments/square/) for more. 1. **Copy your access token** Choose **Open Square Developer Console**, open your app (create one if you have none), then Credentials. Switch to Sandbox at the top for a test event, or Production for a live event, and copy the access token. The Application ID (`sq0idp-…`) is not the access token. 2. **Add the webhook** In the same app and environment: Webhooks → Subscriptions → Add subscription. Paste **Your webhook URL**, choose the events `payment.created`, `payment.updated`, `refund.created` and `refund.updated`, and save. Open the subscription and copy its signature key. 3. **Paste them** Enter **Access token**, **Signature key (from step 2)** and **Location ID** (from Locations in your Square app), pick the **Currency**, and choose **Connect Square**. Leave **Location ID** empty to use your main location. SeatLayer checks that the location is active and can take card payments, and that its currency matches the one you picked. Each payment is read back from Square before a sale confirms. ## Mollie Currencies: EUR, GBP, USD, CHF, DKK, NOK, SEK, PLN, CZK, HUF, RON, AED, AUD, BRL, CAD, HKD, ILS, JPY, MXN, MYR, NZD, PHP, SGD, THB, TWD, ZAR. 1. **Copy your API key** Choose **Open Mollie API keys**: Developers → API keys, for the website profile you sell tickets with. Copy the Test API key (`test_…`) for a test event or the Live API key (`live_…`) for a live one. An access token (`access_…`) does not work. 2. **Paste it** Enter **API key** and choose **Connect Mollie**. SeatLayer gives Mollie its webhook URL with every payment, so there is nothing to set up in Mollie. Turn off bank transfer and pay-later methods such as Klarna for ticket sales. A bank transfer can take days to arrive, longer than seats are held, and pay-later methods wait for a capture SeatLayer does not make. ## Paystack Currencies: NGN, GHS, ZAR, KES, USD, XOF. 1. **Copy your secret key** Choose **Open Paystack API keys**: Settings → API Keys & Webhooks. For a test event, switch Paystack to Test Mode and copy the test secret key (`sk_test_…`). For a live event, copy the live secret key (`sk_live_…`). The public key (`pk_…`) does not work. 2. **Add the webhook URL** On the same page, paste **Your webhook URL** as the Test Webhook URL or the Live Webhook URL, matching the key, and save. 3. **Paste the secret key** Enter **Secret key** and choose **Connect Paystack**. Paystack signs its notifications with this key, so there is no second secret. If Paystack does not answer when you connect and you have IP whitelisting on, turn it off: SeatLayer calls Paystack from many addresses. ## Authorize.Net Currencies: USD, CAD, GBP, EUR, AUD, NZD, DKK, NOK, PLN, SEK. Each account settles in the one currency its processor gives it, and the connection must use that currency. 1. **Open API Credentials & Keys** Choose **Open Authorize.Net** and sign in to the Merchant Interface, then Account → Settings → Security Settings → General Security Settings → API Credentials & Keys. Use your sandbox account for a test event and your production account for a live one. 2. **Get a Transaction Key and a Signature Key** Copy the API Login ID. Under Create New Key(s), choose New Transaction Key, submit and copy it; then do the same for New Signature Key. Authorize.Net shows each key once. A new key replaces the old one, so update any other system that uses it. 3. **Paste all three** Enter **API Login ID**, **Transaction Key** (16 characters) and **Signature Key** (128 characters), pick the **Currency**, and choose **Connect Authorize.Net**. SeatLayer adds its own webhook to your Authorize.Net account. A production account with Test Mode on is refused, because Authorize.Net keeps no record of test-mode sales. Turn Test Mode off under General Security Settings, then connect again. ## Checkout.com Currencies: USD, EUR, GBP, CAD, AUD, INR, AED, SAR, KWD, QAR, BHD, OMR, SGD, JPY, CHF, SEK, NZD, ZAR, MXN, BRL, CLP, COP, PEN, UYU, IDR, PHP, THB, VND, MYR, NGN, KES, UGX, TZS, RWF, XAF, XOF, EGP. 1. **Create a secret key** Choose **Open Checkout.com Dashboard**, then Developers → Keys, and create a secret key with the default permissions. A sandbox key starts with `sk_sbox_`; a live key starts with `sk_`. Your client ID (`cli_…`) is under Settings → Account structure. If you have not used Checkout.com's hosted payment page before, ask your Checkout.com contact to turn it on. 2. **Add the webhook** In the same Dashboard: Developers → Webhooks → Create configuration. Paste **Your webhook URL**, select Generate key next to the signature key and copy it, then choose the events `payment_captured`, `payment_refunded` and `payment_refund_declined`. 3. **Paste them** Enter **Secret key**, **Client ID**, **Webhook signature key (from step 2)** and **Your country** (two letters, for example GB). Fill in **Processing channel ID (optional)** only if you have more than one. Pick the **Currency** and choose **Connect Checkout.com**. The key and the client ID must come from the same account and environment. ## iyzico Currencies: TRY, USD, EUR, GBP, NOK, CHF. 1. **Copy your API keys** Choose **Open iyzico**, then Settings → Merchant Settings, and choose Show detail under API Keys. Use your sandbox panel (sandbox-merchant.iyzipay.com) for a test event; its keys start with `sandbox-`. Use your live panel for a live event. 2. **Add the notification URL (optional)** Settings → Merchant Settings → Merchant Notifications: paste **Your notification URL** and save. It lets a payment confirm even if the buyer closes the page before coming back. If another system already uses that field, leave it as it is. 3. **Paste both keys** Enter **API key** and **Secret key**, pick the **Currency**, and choose **Connect iyzico**. If your checkout form offers bank transfer, ask iyzico to turn it off for ticket sales. A transfer can take days, longer than seats are held. ## Braintree Currencies: USD, CAD, GBP, EUR, AUD, NZD, CHF, DKK, NOK, SEK, PLN, CZK, HUF, RON, JPY, SGD, HKD, MYR, THB, PHP, IDR, VND, KRW, TWD, CNY, INR, AED, SAR, QAR, ILS, TRY, ZAR, EGP, NGN, GHS, KES, TZS, UGX, RWF, XAF, XOF, MXN, BRL, ARS, CLP, COP, PEN, UYU. Each Braintree merchant account settles in one of them. 1. **Open your API keys** Choose **Open Braintree** and sign in to the Control Panel: sandbox for a test event, production for a live one. Click the gear icon, then API. Copy the public key, and click View to copy the private key. With no key yet, click Generate New API Key. 2. **Find your merchant ID** Gear icon → Business. The merchant ID is at the top. Under Merchant Accounts you can see each merchant account and its currency. 3. **Paste them** Enter **Merchant ID**, **Public key** and **Private key**, pick the **Currency**, and choose **Connect Braintree**. SeatLayer uses the merchant account in that currency. Fill in **Merchant account ID (optional)** only if you have more than one merchant account in it. 4. **Optional: add the webhook** Gear icon → API → Webhooks → Create New Webhook. Paste **Your webhook URL** and tick Transaction Settled and Refund Failed. Card payments confirm without it. With it, SeatLayer hears about refunds you make in Braintree and refunds that fail later. Buyers pay on a SeatLayer payment page that runs Braintree's card form. Each payment is confirmed with Braintree before the sale. ## Check the connection When a connection is ready, the card shows **Connected** and the row reads **Ready**. A row can also read **Not checked yet**, **Test events only** or **Cannot take payments yet**; the last means the gateway refused the key or a permission is missing. **Re-check**, **Replace key** and **Disconnect** are on each row where they apply. ## Assign the connection to an event Open the event, then **Setup → Checkout**. Under **Payment account**, choose **Use [gateway] for this event**, for example **Use Stripe for this event**. The row changes to **✓ Ready**. The connection's mode and currency must match the event. Only gateways that take the event's currency are listed; the ones you have not switched on yet appear below with **Request**. See [Event setup](/sell/event-setup/) for the rest of the Checkout screen. ## Refunds made in your gateway dashboard When you refund a payment in the gateway's own dashboard, the gateway notifies SeatLayer and the order updates. A full refund puts the seats back on sale and the tickets stop scanning. A partial refund is recorded on the order and the seats stay sold, because SeatLayer cannot tell which seats a partial amount was for. SeatLayer acts only when the gateway reports the refund as completed; a pending refund changes nothing until then. | Gateway | Refunds made in its dashboard sync back | |---|---| | Stripe, Razorpay, PayPal, Tap, Xendit, Flutterwave, Square, Mollie, Paystack, Authorize.Net, Checkout.com | Yes | | Mercado Pago | Yes on live events. Mercado Pago sends no notifications for test payments. | | Braintree | Only with the optional webhook from step 4 above. | | iyzico | No. iyzico sends no refund notification. | Where a refund does not sync, refund from the order in SeatLayer instead. If you already refunded in the gateway, record it with **Refund or cancel… → Cancel without refund** so the seats go back on sale. See [Refunds and cancellations](/sell/refunds-and-cancellations/). # Door check-in > Scan tickets in a phone or tablet browser, read each result, follow arrivals live, and download the door list as a paper fallback. Source: https://docs.seatlayer.io/sell/door-check-in/ · Markdown: https://docs.seatlayer.io/sell/door-check-in/index.md Check-in runs in the browser on a staff phone or tablet. There is no app to install. Scans read the same live inventory as the website and the box office. Admins, designers and box office staff can scan; viewers cannot. See [Staff roles](/sell/staff-roles/). ## The Door tab Open the event's **Door** tab. It shows how the night is going: | Figure | What it counts | |---|---| | **Checked in** | Tickets scanned, out of the valid tickets issued. | | **Attendance** | Scanned divided by valid, as a percentage. | | **Not yet arrived** | Valid tickets never scanned. | | **Refunded** | Tickets voided by a refund or cancellation, and how many of those after entry. | **Arrivals** charts check-ins over time, with the time of the first one. **Start scanner** opens the scanner. **Download door list** saves the guest list. ## Start the scanner Choose **Start scanner**. The scanner is tied to this one event and to your sign-in, and stays open for a two-hour shift. When it ends, the screen reads **Door session ended**; choose **Reauthorize and continue** and the ticket you were scanning is kept, so there is no need to scan it again. ## Scan a ticket Choose **Use camera** and hold the QR code inside the frame. If the browser cannot read QR codes or camera access is blocked, type or paste the code into **Ticket code or scan URL** and choose **Check ticket**. Checking a ticket records nothing. The screen shows the result, the seat, the guest's name and the booking form answers for that ticket. Admission is recorded only when you press the check-in button, which names the guest, for example **Check in Maria**. A ticket for a group of standing guests reads **Check in 3 guests**. | Result | Meaning | |---|---| | **Valid ticket** | Admit the guest with the check-in button. | | **Checked in** | Done, with the time. Choose **Scan next ticket**. | | **Already checked in** | This ticket was admitted before, at the time shown. It cannot be admitted twice. | | **Ticket void** | The ticket was cancelled or fully refunded and cannot admit anyone. | | **Wrong event** | The ticket is for another event. The buyer's details stay hidden. | | **Ticket not found** | The code does not match any ticket this scanner can see. | | **Seat change in progress** | Staff are moving this order to other seats. Wait, then choose **Try again**. | | **Couldn’t reach the Door service** | A connection problem, not a verdict on the ticket. Choose **Try again**, and do not turn the guest away on this screen. | A check-in cannot be undone from the scanner. Check-in never changes what was sold: admitting a guest does not touch the order or the seat. ## Test events Tickets from a test event scan normally, so you can rehearse the door. Every result for them carries the strip **Test ticket: not valid for entry**, so nobody mistakes one for a real admission. ## When the network fails The scanner needs a connection for every check. There is no offline mode. The door list is the fallback: download it before doors open. **Download door list (CSV)** gives one line per seat, sorted by buyer name so a queue can be worked by the name people say out loud. Each line has the seat, the order reference, whether the ticket was scanned and when, and whether the order was refunded. Refunded holders stay on the list, marked. The file holds no email addresses on purpose, since it is meant to be printed and handed to door staff. ## Looking someone up The door scanner works from the ticket. To find a guest by name, email or phone, use **Find order** in the [Box office](/sell/box-office/). # Event setup > Choose free or paid, assign a payment account, build the booking form, set the buyer language and cap how many tickets one buyer can take. Source: https://docs.seatlayer.io/sell/event-setup/ · Markdown: https://docs.seatlayer.io/sell/event-setup/index.md Every hosted event has a **Setup** tab. It asks one question per screen: **Details**, **Tickets** and **Checkout**. When those are answered, **Sell & share** is only about where buyers book. **Done here? Go to Sell & share →** at the bottom takes you there. A few settings live in **Edit details** instead, the panel that holds the event's name, date, venue and poster: the buyer language, the currency, the ticket limits and the seat hold time. Setup links to it where needed. Admins and designers can open **Edit details**; switching live and test, choosing free or paid, and assigning a payment account need an admin. ## Details **Details** states what the event is and whether it is live or a test run. - **Event details**: name, date, venue, poster and description. Choose **Edit details** to change them. - **Buyer language**: the language buyers read on the seat picker, booking page, confirmation email and tickets. Choose **Change language** to pick one. See [Buyer language](#buyer-language) below. - **Checkout: SeatLayer**: buyers pay on the booking page and SeatLayer issues the tickets. **Open Checkout** goes to the Checkout screen. - **Event mode**: **Live: takes real payments** or **Test: sandbox, nothing is charged**. Only an untouched event can switch, so set it before you sell anything. Switching clears the event's payment account, because a connection made for one mode is not valid in the other; assign one again afterwards. ## Tickets **Tickets** shows what a seat costs in each category, and how tables are sold on a chart that has them. Prices are changed in one place: **Edit on Pricing & availability →** opens the editor, where offers and releases also live. ## Checkout **Checkout** has three cards, in the order the decisions depend on each other. ### How buyers pay Choose **Pay online** or **Free**. - **Pay online**: buyers pay by card on the booking page, and SeatLayer holds the seats while they do. The card shows which gateway is connected and the currency, for example "Stripe connected · EUR". - **Free**: no payment step. Buyers pick their seats and confirm, and get the same ticket. The choice is **Locked after the first booking.** Each confirmed ticket uses one hosted ticket credit on both paid and free events; the card shows your balance and a **Buy credits** link. Test events never use credits. See [Pricing](/billing/pricing/#hosted-ticketing). ### Payment account Shown for **Pay online** events. Each gateway that takes the event's currency has a row. Choose **Use [gateway] for this event**, for example **Use Stripe for this event**, and the row changes to **✓ Ready**. With no connection yet, the row offers **Connect** for the event's mode and currency. Gateways not yet on for your account are listed under the rows with **Request**. Connecting and requesting are covered in [Connect payments](/sell/connect-payments/). The assigned row also holds **After payment**, where buyers land once their payment clears: - **SeatLayer confirmation page**, the default. - **Back on your page**, the website where your tickets are installed. Save the website first with **Save website**; until one is saved, buyers finish on the SeatLayer page. Only an organization admin can save a website. ### Booking form The form buyers fill in on the way to paying. It has two parts. **Attendee details** are asked for every ticket in a booking. Set each of **Name for each ticket**, **Email for each ticket** and **Phone for each ticket** to **Off**, **Optional** or **Required**. A name is printed on the ticket and shown at the door. **Questions** are your own. Choose **Add question**, write the **Question**, and pick an **Answer type**: **Short text**, **Long text**, **Single choice**, **Multiple choice**, **Yes/No**, **Number**, **Date**, **Email** or **Phone**. Under **Who answers**, choose **Ask once per booking** or **Ask for each ticket**. Turn on **Required** to make buyers answer it. Turn on **Hide the answer from the buyer** for something only staff should see: buyers still answer it, but the answer stays off their tickets, emails and ticket page. A form can hold up to 20 questions, and a choice question up to 50 choices. **What buyers see** previews the form as you edit. Choose **Save booking form**; changes apply to new bookings once saved. Door staff see a ticket's answers when they scan it. ## Buyer language Set it in **Edit details → Buyer language**. It applies to the seat picker, the booking page, the confirmation email and the tickets for this event. The list offers **Auto** and eight fixed languages: English, Español, Deutsch, Français, हिन्दी, العربية, 简体中文 and 日本語. On **Auto**, the event uses your organization's default language when it has one. When it has none, the option reads **Auto, visitor's browser (recommended)** and each buyer's browser decides, across the 43 languages the seat picker and checkout ship in. The language never converts prices; the event's currency stays the same. ## Currency For a paid event, **Edit details → Currency** sets what buyers are charged in. Left on the organization default, the event uses your organization's currency. It is **Locked: this event has orders** once anyone has bought. The payment account you assign must be connected in the same currency. ## Ticket limits Set these in **Edit details → Booking → Ticket limits**. | Field | Range | Left empty | |---|---|---| | **Max tickets per order** | 1 to 100 | 10 | | **Max tickets per buyer** | 1 to 1,000 | 10 on a free event, no limit on a paid event | **Max tickets per order** caps one checkout on the public booking page. A private channel or partner link keeps its own quantity limit unless you set this field; when you do, the lower of the two applies. **Max tickets per buyer** counts every booking made with the same email for this event, paid or free. A buyer over the limit is told how many more that email can book, or that it already has the maximum. Free events have one more guard that you cannot change: at most 60 free tickets are confirmed per event per minute, across all buyers. Past that, buyers see "This free event is booking very quickly right now. Please try again in a minute." An order larger than 60 tickets still goes through in a quiet minute. Developers can set both limits with `PATCH /v1/events/:key`, using `maxTicketsPerOrder` (1 to 100) and `maxTicketsPerBuyer` (1 to 1,000). Send `null` to go back to the default. A value out of range is refused with 422 `invalid_ticket_limit` and nothing is saved. ## Seat hold duration **Edit details → Advanced → Seat hold duration** sets how long seats stay held while a buyer checks out: 2, 5, 10, 15, 20, 30, 45 or 60 minutes. Left on **Use the widget setting, otherwise 15 minutes**, the event keeps the default. A new value applies to new holds straight away; holds already running keep their timer. # Checkout > What a buyer sees from choosing seats to receiving tickets, and where to send them after payment. Source: https://docs.seatlayer.io/sell/hosted-checkout/ · Markdown: https://docs.seatlayer.io/sell/hosted-checkout/index.md Checkout opens from the seat picker on your website or the hosted event page. It works the same on both and reads the same live inventory. ## What the buyer sees 1. **Seats held** After **Continue to checkout**, the seats are held for the buyer. The **Checkout** card shows **Seats held for** a countdown, which turns amber under two minutes. If time runs out, the seats are released and the buyer can choose again. 2. **Order and details** **Your order** lists every seat with its section, row, ticket type and price, and the total stays in view. The buyer enters an **Email**, which is where the tickets go, and optionally a **Name**. Events can ask for a name on each ticket or a few questions; see [the booking form](/sell/event-setup/#booking-form). On a phone the card opens as a bottom sheet. 3. **Pay on your gateway** **Continue to payment** takes the buyer to your gateway's payment page. A free event shows **Confirm booking** instead and skips payment. 4. **Tickets** The buyer sees **Your tickets are confirmed**. The tickets and QR codes go to their email with a printable PDF, and a link opens them at any time without logging in. ## Test mode On a test event, the button reads **Continue to test payment** and the card shows **No real charge · tickets not valid for entry**. Use your gateway's test cards. ## After payment Choose where buyers land in **Setup → Checkout**, under **After payment** on the assigned payment account: the **SeatLayer confirmation page**, or **Back on your page**. Buyers are only sent back to a website you have saved for your organization. See [Event setup](/sell/event-setup/#payment-account). ## Languages and themes Checkout comes in 43 languages and supports light and dark themes. It uses the event's buyer language, or the buyer's browser language when the event is left on **Auto** and your organization has no default. See [Buyer language](/sell/event-setup/#buyer-language). # Sell your own tickets > What Hosted Ticketing runs for a venue or organizer, from the seat map on your website to the box office counter and the door. Source: https://docs.seatlayer.io/sell/overview/ · Markdown: https://docs.seatlayer.io/sell/overview/index.md Hosted Ticketing is for venues and organizers selling their own seated events. SeatLayer runs the checkout, the order, the tickets and the door. You keep the chart, the prices, your payment gateway and your buyers. If you are building a ticketing product and want to keep your own checkout, use the [Platform SDK quickstart](/start/quickstart/) instead. ## What you get | Part | What it does | |---|---| | [Payments](/sell/connect-payments/) | Buyers pay into your own gateway account, through one of 14 gateways. SeatLayer never holds your money. | | [Event setup](/sell/event-setup/) | Free or paid, the payment account, the booking form, the buyer language and ticket limits. | | [Checkout](/sell/hosted-checkout/) | Seat hold with a countdown, order lines, one email field, then your gateway's payment page. | | Tickets | One ticket per seat, with a QR code, a printable PDF and a confirmation email. | | [Box office](/sell/box-office/) | Sell at the counter from the same live map, print on an 80 mm roll or A4, find any order. | | [Refunds and cancellations](/sell/refunds-and-cancellations/) | Refund in full or in part through the gateway, or cancel when the money was handled elsewhere. | | [Door check-in](/sell/door-check-in/) | Scan tickets in a phone browser. No app to install. | | [Staff roles](/sell/staff-roles/) | Give counter staff a role that can sell and scan but cannot refund. | Online sales, the counter and the door all read one live inventory. A seat sold at the counter disappears from every buyer's screen at once. ## How it works 1. **Build the chart and create the event** Start from a template or draw the room, then create the event on it. See [Your first managed event](/start/first-event/). 2. **Connect your payment gateway** Connect your gateway on **Settings → Payments** and assign it to the event in **Setup → Checkout**. Free events skip this step. See [Event setup](/sell/event-setup/). 3. **Put it where buyers are** Paste the event block into your website, or share the hosted event page. Open the **Box office** tab on the day. ## What developers can reach Hosted Ticketing is run from the dashboard, and its orders stay there. There is no public API to list or read the orders of one event, and no `order.*` webhook. What a developer can use: - Seat webhooks. A hosted sale books seats like any other booking, so `seat.booked` fires for it. See [webhook events](/webhooks/events/). - The event's inventory report as JSON, through the [Server API](/server-api/reports/). It shows seats and configured prices, not the money your gateway collected. - Event settings such as ticket limits, through `PATCH /v1/events/:key`. See [Event setup](/sell/event-setup/#ticket-limits). Order data leaves the dashboard as files, downloaded by a signed-in team member: | File or view | Where | |---|---| | `orders.csv`, every order with its buyer, filtered as on screen | **Orders → Export CSV** | | `door-list.csv`, one line per seat, sorted by buyer name, with no email addresses | **Door → Download door list (CSV)** | | Attendance: checked in, not yet arrived, refunded | **Door** tab | | The event report as CSV | **Performance → Export CSV** | | Box office money by payment method, and discounts and comps by reason | **Performance** tab | ## Price Each confirmed ticket costs $0.50, paid from the Hosted Ticket wallet. Every new organization gets 25 Hosted Tickets once. A ticket on a free event uses one Hosted Ticket too. Your gateway charges its own processing fee and sets payout timing. See [Pricing](/billing/pricing/). # Refunds and cancellations > Refund an order in full or in part through the gateway, or cancel it without a refund when the money was handled elsewhere. Source: https://docs.seatlayer.io/sell/refunds-and-cancellations/ · Markdown: https://docs.seatlayer.io/sell/refunds-and-cancellations/index.md Every order has one place to refund or cancel it. Open the event's **Orders** tab, open the order, and choose **Refund or cancel…**. Free orders show **Cancel** only. Only an admin can refund an order or cancel an online order. See [Staff roles](/sell/staff-roles/) for what other roles can do. ## Refund the buyer Choose **Refund the buyer**. SeatLayer sends the money back through the gateway the buyer paid with. - **Refund everything** returns the whole amount. The seats go back on sale and the tickets stop scanning. - **Refund part of it** returns the amount you enter. The seats stay sold. Confirm with **Refund [amount]**. ## Cancel without refund Choose **Cancel without refund** when the money was already handled another way: you refunded outside SeatLayer, you lost a payment dispute, or cash was returned at the counter. 1. Pick what happened, and add a note for the order history. A note is required for **Something else**. 2. Tick the box to confirm the money was already handled. 3. Optionally email the buyer that their booking is cancelled. 4. Choose **Cancel order and release N seats**. The seats go back on sale and every ticket stops scanning. This cannot be undone. If a ticket was already used at the door, you are asked to confirm that you checked the door history. ## Refunds made in the gateway dashboard Most gateways tell SeatLayer about a refund you make in their own dashboard, and the order updates by itself. Which ones do, and what happens to the seats, is listed in [Connect payments](/sell/connect-payments/#refunds-made-in-your-gateway-dashboard). For a gateway that does not, use **Cancel without refund** above. # Staff roles > Invite your team, and give counter and door staff a role that can sell and scan but cannot refund or change settings. Source: https://docs.seatlayer.io/sell/staff-roles/ · Markdown: https://docs.seatlayer.io/sell/staff-roles/index.md Everyone on your team signs in with their own email and gets one role for the whole organization. There are four roles. | Role | What they can do | |---|---| | Admin | Everything: billing, keys, team, payments, charts, events, refunds and cancellations. | | Designer | Create and edit floor plans and events, sell at the box office, find orders, email tickets and check people in. No billing or keys, and no refunds. | | Viewer | Read-only: events and live availability. | | Box office | Counter and door only: sell, apply discount presets, find orders, print and email tickets, change seats, check people in. | ## Invite someone Only an admin can manage the team. Other roles can open the page and see who has access. 1. **Open the team page** Go to **Settings → Team**. 2. **Enter their email and a role** Type the email address, choose the role, and select **Invite**. 3. **They accept** They get an email with a link. The invitation lasts 7 days. Until they accept, their row reads **Invited: awaiting acceptance**. You can resend it, copy the link with **Copy invite link** to share it yourself, or cancel it. An expired invitation reads **Invitation expired: resend to renew**. Resending starts a new 7 days. ## Change a role or remove someone Pick a new role from the member's row; it applies straight away. To take someone off the team, choose **Remove from team**. An organization always keeps at least one admin, so the last admin cannot be demoted or removed: promote someone else first. ## What each role can do with orders and the door | Action | Admin | Designer | Box office | Viewer | |---|:---:|:---:|:---:|:---:| | Sell at the box office counter | Yes | Yes | Yes | No | | Find orders, print and email tickets | Yes | Yes | Yes | No | | Scan tickets at the door | Yes | Yes | Yes | No | | Cancel a counter sale after returning the money by hand | Yes | Yes | No | No | | Refund an order, or cancel an online order | Yes | No | No | No | | See sales figures and the event history | Yes | Yes | No | No | | Set up payments, box office methods and discount presets | Yes | No | No | No | Refunds move money through your gateway, so they stay with admins. See [Refunds and cancellations](/sell/refunds-and-cancellations/). ## The box office role Give this role to people who work the counter or the door on the night. When they open an event they see only its **Box office** and **Door** tabs. They cannot refund, cancel, change counter payment methods or discount presets, or open settings, billing, keys, team or charts. They can only apply the discount presets an admin created, never type their own. This is enforced by the server, not just hidden in the screen. Every request a box office account makes is checked against a short list of allowed actions, and anything else is refused. A new feature stays closed to this role until it is added to that list on purpose. # REST API and OpenAPI > Choose direct REST or an official server SDK, find the resource guide for your workflow, and use generated OpenAPI contracts for automation. Source: https://docs.seatlayer.io/server-api/ · Markdown: https://docs.seatlayer.io/server-api/index.md The Server API is the trusted control plane for inventory provisioning, hold inspection, booking, operational changes, reports, and webhook configuration. Call it from a backend or another environment where buyers cannot inspect your secret key. If you have not yet separated inventory, audience access, pricing, and SDK choice, read the [SeatLayer product model](/start/product-model/) first. This landing page groups the same contracts by backend workflow. ## Choose REST or a server SDK | Surface | Choose it when | Start here | |---|---|---| | Official server SDK | Your backend uses Node.js, Python, PHP, Java, Go, Ruby, or .NET | [Choose a server SDK](/server-sdk/) | | REST API | You want direct HTTP control or use another language | [Authentication](/start/authentication/) and [OpenAPI 3.1](/openapi.json) | Both surfaces operate on the same public contracts. The [operation support matrix](/server-api/operation-support/) records exact REST and server-SDK coverage without requiring narrative pages to duplicate it. ## Find a workflow - **Events and inventory** — Create and update [Events](/server-api/events/), manage [inventory rules](/server-api/inventory/), and use [blocks](/server-api/blocking/) for operational holds. - **Hold and book** — Understand [seat holds](/server-api/how-seat-holds-work/), inspect or create [holds](/server-api/holds/), then [book inventory](/server-api/booking/). - **Sales channels and private access** — Allocate one Event with the [Sales Channels API](/server-api/channels/) and authorize an eligible browser with a [Buyer Access Session](/server-api/buyer-access-sessions/). - **Multi-event inventory** — Coordinate one atomic run with the [Performance Groups API](/server-api/performance-groups/), or publish renewable Plans and Seat Rights with the [Seasons API](/server-api/seasons/). - **Operations and reconciliation** — Handle [idempotency and conflicts](/server-api/idempotency-and-conflicts/), [cancellations and box office](/server-api/cancellations-and-box-office/), and [reports](/server-api/reports/). - **Errors and limits** — Read the [error body and every error code](/server-api/errors/), and the [rate limits](/server-api/rate-limits/) that apply to hold and Season routes. ## Machine-readable contracts - [OpenAPI 3.1](/openapi.json) describes public HTTP operations and schemas. - [Operation support JSON](/operation-support-matrix.json) records REST and server-SDK implementation and release evidence. - [SDK catalog](/sdk-catalog.json) records current package, source, registry, documentation, and stability information. Treat generated contracts as exact operation truth and these guides as workflow explanation. Every failure uses one [error body](/server-api/errors/). Keep secret keys server-side, use test-mode resources while integrating, and follow each mutation's documented retry boundary. # Blocking seats > Take individual inventory off sale, schedule its release, and return blocked seats safely. Source: https://docs.seatlayer.io/server-api/blocking/ · Markdown: https://docs.seatlayer.io/server-api/blocking/index.md Blocking is organizer-controlled inventory: production holds, comps, sightline closures, press allocations, or seats not ready for sale. It is not a buyer hold and does not consume a booked-seat credit. ## Block seats **POST /v1/events/:key/block**. Authentication: Secret key, dashboard session, or event:block manage token ```bash curl -sX POST "https://api.seatlayer.io/v1/events//block" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{"labels":["A-1","A-2"]}' ``` Success returns: ```json { "ok": true, "blocked": ["A-1", "A-2"] } ``` Blocking is atomic across the requested labels. Every label must exist and be `free`. If one is held, booked, blocked, or missing, nothing changes: ```json { "error": "conflict", "conflicts": [ { "label": "A-2", "status": "held" } ] } ``` ## Schedule automatic release Add a future epoch-ms `releaseAt`: ```json { "labels": ["P-1", "P-2"], "releaseAt": 1767200400000 } ``` A past or invalid time does not schedule a release; the block remains until an explicit unblock. Store your intended release time in your own operation record because the basic block response returns only the blocked labels. ## Say why Add an optional `reason` (up to 280 characters). It is kept with the block in the event's history, next to who blocked the seats: ```json { "labels": ["P-1", "P-2"], "reason": "Camera platform" } ``` ## Unblock selected seats **POST /v1/events/:key/unblock**. Authentication: Secret key, dashboard session, or event:block manage token ```bash curl -sX POST "https://api.seatlayer.io/v1/events//unblock" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{"labels":["A-1","A-2"]}' ``` Non-blocked labels are ignored. The response contains the labels actually returned to sale. ## Unblock all **POST /v1/events/:key/unblock-all**. Authentication: Secret key, dashboard session, or event:block manage token Puts every manually blocked seat back on sale, except seats kept aside in a named reservation from the Control room; those stay reserved until their reservation is released. No body is required. A successful response is: ```json { "ok": true, "freed": 12 } ``` ## Buyer and operator behavior Blocked inventory is exposed to buyer surfaces as `not_for_sale`; buyers do not see your internal reason. Live SDK and control-room views repaint after the mutation without polling. | State change | Use | |---|---| | `free → blocked` | `block` | | `blocked → free` | `unblock` or `unblock-all` | | `booked → free` | [booking-safe cancellation](/server-api/cancellations-and-box-office) | | `free → booked` from a trusted Platform backend | [direct inventory booking](/server-api/cancellations-and-box-office) | ## Checklist - [ ] Secret keys are used only from a trusted server. - [ ] Browser operator tokens carry only `event:block` when that is sufficient. - [ ] Batch conflicts are shown as a retry/reselect action. - [ ] Scheduled-release times are future UTC epoch milliseconds. - [ ] Blocking is not treated as a payment reservation. - [ ] The host keeps its own reason and operator audit context. For sections and zones, use [inventory and availability](/server-api/inventory). # Book inventory > Commit held inventory from your trusted server with an idempotent booking reference and safe conflict handling. Source: https://docs.seatlayer.io/server-api/booking/ · Markdown: https://docs.seatlayer.io/server-api/booking/index.md **POST /v1/events/:eventKey/book**. Authentication: Secret key Booking turns a temporary hold into a permanent **inventory booking**. It does not create your commercial order or fulfil the purchase. This endpoint is server-only and accepts `sk_test_…` or `sk_live_…` credentials whose mode matches the event. The event must explicitly use **Platform checkout** (`buyerCheckout: "integrator"`); Managed and unresolved events return 404 so a server key cannot bypass SeatLayer's managed checkout. > **Server-side only** > > Never call this endpoint from browser code. The buyer SDK holds inventory and > hands your backend an opaque `holdId` through the > [checkout handoff](/buyer-sdk/holds-and-checkout/); only your backend has > booking authority. ## Request ```http title="HTTP request" POST /v1/events//book HTTP/1.1 Host: api.seatlayer.io Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "holdId": "hold_01J8F2A7MRQ4", "labels": ["STALLS-A-12", "STALLS-A-13"], "bookingRef": "order_1842" } ``` | Field | Type | Required | Description | |---|---|---:|---| | `holdId` | `string` | Yes for a buyer flow | Opaque hold identifier returned by the buyer SDK. | | `labels` | `string[]` | Recommended with `holdId` | The seats you priced at checkout, copied from the trusted server-side hold inspection. A hold keeps its id when the buyer changes seats, so pinning the labels makes booking fail with `409 hold_changed` instead of booking seats you never charged for. Required for a direct booking without a hold. | | `bookingRef` | `string` | Yes | Your stable order or reservation reference. It makes retries idempotent and links SeatLayer inventory to your own system. | | `blockRef` | `string` | No | Books seats you set aside with [`best-available-block`](/server-api/holds/#set-seats-aside-for-a-longer-offer). Send it with `labels` and without `holdId`; up to 160 characters. | Do not send buyer identity, payment details, ticket delivery data, or an amount to this endpoint. Those belong to your platform. > **Private inventory needs a named channel** > > This affects direct label bookings after you introduce > [sales channels](/platform/sales-channels). Booking a buyer hold is unaffected > because the hold already captured its inventory authorization. > > For a direct label booking against private inventory, pass the authorized > `channelIds`. Use `ignoreChannelRestrictions: true` with a short `reason` only > for a genuine back-office override; the action is audited. Public inventory > continues to work without a channel. ## Responses ### 200 Booked Every requested object is now booked in SeatLayer inventory. ```json title="200 response" { "ok": true, "booked": ["STALLS-A-12", "STALLS-A-13"] } ``` On an idempotent replay, `booked` may be empty because that same `bookingRef` already committed the inventory: ```json title="200 replay response" { "ok": true, "booked": [] } ``` Keep the commercial receipt, buyer, tickets, and delivery state in your own order record. Only `ok` and `booked` belong to the Platform booking response. ### 409 Conflict Nothing is booked when any requested object is unavailable. ```json title="409 response" {3-7} { "error": "conflict", "conflicts": [ { "label": "STALLS-A-13", "status": "booked" } ] } ``` ### 403 Mode mismatch A test key cannot book live inventory, and a live key cannot book a sandbox event. ```json title="403 response" { "error": "mode_mismatch" } ``` ## Safe retry behavior `bookingRef` is your [idempotency key](/server-api/idempotency-and-conflicts/). Repeating the same request after a timeout succeeds without creating a second inventory booking or spending credits twice. Always reuse the same value for the same platform purchase. ```js title="server/book.js" const trustedHold = await retrieveSeatLayerHold(eventKey, holdId); const labels = trustedHold.items.map((item) => item.label); const order = await priceAndAuthorizePayment(trustedHold); // Your commerce code. const response = await fetch( `https://api.seatlayer.io/v1/events/${encodeURIComponent(eventKey)}/book`, { method: "POST", headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ holdId, labels, bookingRef: order.id }), }, ); if (response.status === 409) { return recoverInventoryConflict(); } if (!response.ok) { // Reconcile order.id through exact Booking History before another action. throw new Error(`SeatLayer booking failed: ${response.status}`); } ``` If the connection fails before a response arrives, look up `GET /v1/events/:eventKey/bookings/:bookingRef`. Only then decide whether your workflow should repeat the **same** event, hold, labels, and `bookingRef`. Never change the reference merely because the first outcome is unknown. ## Read inventory Booking History Platform events expose the durable inventory ledger through the official server API: ```http title="List and search" GET /v1/events//bookings?q=order_1842&state=booked&limit=50 Authorization: Bearer sk_test_•••••••• ``` Pass the opaque `nextCursor` back as `cursor` to read the next page. Search is limited to inventory identifiers such as booking reference, label, object, category, section, and sales-channel reference. Narrow the list with `channel` (a sales channel id, or `public` for seats sold with no channel) and a booked-at window, `from` and `to` in epoch milliseconds. Filters apply before paging, so every page holds only matching bookings. ```http title="Exact booking and audit" GET /v1/events//bookings/order_1842 Authorization: Bearer sk_test_•••••••• ``` The detail response includes immutable configured-price snapshots, current object cancellation state, and a chronological `book` / `replay` / `partial_cancel` / `cancel` audit. Configured value is operational inventory value, not the amount charged or settled. Every amount is in the event's `currency`, on the booking and on each object; each object also names its category (`categoryLabel`, beside the stable `categoryKey`), and a seat that sits outside every section has a null `sectionLabel`. These routes never return buyer, payment, commercial Order, ticket, email, refund, or door data. The synchronous booking response or this exact Booking History record is the command outcome. A later [`seat.booked` webhook](/webhooks/events/#event-catalog) is a reconciliation signal; deduplicate it and never use it as permission to charge the buyer again. For a resumable background sync, use the [`/booking-changes` reconciliation feed](/server-api/reports#incremental-booking-reconciliation). Its append-only activity checkpoint surfaces cancellations to older booking references without paging the full Booking History again. > **Inspect the hold on your server** > > Resolve the hold before your payment decision. SeatLayer's hold response is > authoritative for the inventory, quantities, categories, and configured > price snapshots. Your platform remains authoritative for discounts, fees, > taxes, the final amount charged, and payment state; never trust those values > when they come from the browser. ## Fulfilment and refunds stay in your platform After a successful inventory booking, your platform confirms its commercial order and creates and delivers its own tickets, QR/barcodes, PDFs, and emails. It also owns ticket recovery, scanning/check-in, refunds, and customer support. If your platform later cancels or refunds the purchase, call [`/unbook`](/server-api/cancellations-and-box-office) with the matching `bookingRef` to return inventory to sale. SeatLayer changes inventory on this route; it does not move or refund your money. > **Behavior corrected on 2026-08-10** > > Older deployments briefly created internal `external` Orders and SeatLayer > ticket artifacts after `/book`. That behavior was removed. Existing > historical records may remain available during migration, but new Platform > bookings create inventory records only. # Buyer Access Sessions API > Mint, list, and revoke short-lived event-and-origin-bound bse_ tokens for private and scoped buyer audiences. Source: https://docs.seatlayer.io/server-api/buyer-access-sessions/ · Markdown: https://docs.seatlayer.io/server-api/buyer-access-sessions/index.md A buyer access session is the credential that lets one browser see and select one event audience's inventory. Use this server API when access depends on a login, invitation, presale, package purchase, partner handshake, or named sales channel. Your backend decides who deserves that scope and then asks SeatLayer to mint the session. Read [Sales channels and allocations](/platform/sales-channels/) for the access model and the [organizer private-sales guide](/integrations/private-and-partner-sales/) for the complete browser-to-booking flow. This page owns the exact session contract. For ordinary Public sale on a Platform/SDK event, pass `publicKey` to the Buyer SDK instead. SeatLayer validates the account, event mode, and exact registered browser origin, then returns a Public-only in-memory bearer together with the chart and compact inventory status. That direct bootstrap cannot expose private inventory. If `buyerAccessTokenProvider` or `buyerAccessToken` is also present, the explicit scoped credential always takes precedence over `publicKey`. **Your server authorizes; SeatLayer mints; the widget consumes.** These endpoints hand out authority to spend an allocation, so they accept a secret key and nothing else. A `bse_` token can never book: booking stays with your backend. The same session also authorizes SeatLayer-hosted view-from-seat image bytes. The current SDK fetches those Event-scoped assets with the bearer in the `Authorization` header and renders an in-memory blob URL. Do not append the token to an image URL or proxy it through query parameters. ## Private audience flow Use this flow only after you have chosen a private audience. It is not part of the ordinary Public-sale load path. 1. The buyer opens your page and signs in, enters a presale code, follows an invitation, or otherwise proves eligibility to **your** application. 2. Your backend decides which private channel(s), if any, that buyer may see; it also decides whether the same buyer may see Public sale. 3. Your backend calls this secret-key endpoint with that exact scope, the browser's exact origin, a short expiry, and a retry-safe `clientRequestId`. 4. Return only `{ token, expiresAt }` to the browser. Keep it in memory and supply it through `buyerAccessTokenProvider` so the SDK can refresh it. 5. The browser creates holds with that scoped access. Your backend inspects the hold and books it; neither the token nor the browser can book directly. For an ordinary Platform/SDK Public sale, stop before step 1: pass `publicKey` to the SDK and let SeatLayer perform the public, exact-origin bootstrap. ## Mint a session **POST /v1/events/:key/buyer-access-sessions**. Authentication: Secret key ```bash curl -s -X POST "https://api.seatlayer.io/v1/events//buyer-access-sessions" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "channelIds": ["chn_9f1c"], "includePublic": false, "allowedOrigin": "https://booking.travel-agency.example", "expiresInSeconds": 1800, "maxQuantity": 4, "buyerRef": "buyer_8372", "partnerRef": "travel-agency-a", "clientRequestId": "agency-login-01J8F2A7MRQ4" }' ``` | Field | Type | Required | Default | Notes | |---|---|---:|---|---| | `allowedOrigin` | `string` | Yes | None | One canonical HTTPS origin, checked on every request | | `includePublic` | `boolean` | **Yes, explicitly** | none | No default. See below | | `channelIds` | `string[]` | No | `[]` | Up to 20. Each must belong to this event and be active | | `expiresInSeconds` | `integer` | No | `1800` | 60 to 43,200 (12 hours) | | `maxQuantity` | `integer \| null` | No | `null` | 1 to 100. Guest-weighted, summed across **all** this buyer's live holds | | `buyerRef` | `string` | No | `null` | Your opaque, pseudonymous buyer reference. Max 120 characters | | `partnerRef` | `string` | No | `null` | Your opaque partner reference. Its presence marks the sale `partner` rather than `promoter` | | `clientRequestId` | `string` | No | `null` | Makes a retry safe. See below | Mode comes from the event, not the body. A key whose mode differs from the event's gets `403 buyer_access_mode_mismatch`. ### `includePublic` has no default You must send it. `includePublic: true` can expose public inventory to a partner; `includePublic: false` can leave a VIP with an empty map. SeatLayer refuses to guess. Omitting it returns `422 include_public_required`. An empty `channelIds` **and** `includePublic: false` is an empty scope and returns `422 invalid_channel_scope`. A session that can see nothing is a bug, not a configuration. ### Response ```json { "sessionId": "bas_2f4c", "token": "bse_ZXZ0X2FiYw_9d3f", "expiresAt": 1764601800000, "eventKey": "ev_9f3a", "includePublic": false, "maxQuantity": 4 } ``` Keep `sessionId` for audit and revocation. Return only `token` and `expiresAt` to the browser. > **The token exists only in this response** > > The token is shown once and is never retrievable: SeatLayer cannot show it > again. Hand it to the browser, keep it in > memory, keep it out of logs and telemetry, and let it expire. The response deliberately carries no channel names, no allocation labels, no partner details, and no internal notes. ### Retries are safe, and never replay a token Send a `clientRequestId`. On a repeat for the same issuer and id, SeatLayer **revokes any earlier session for that pair and issues a fresh one**. The response contains a new `sessionId` and bearer rather than a stored plaintext token. The earlier token stops working immediately. **The buyer keeps their seats.** A repeat for the same issuer and id is the same buyer, for example one who reloaded your page mid-checkout. Any live hold the earlier session owned moves to the new session in the same step, so the buyer can resume, extend and book it with the new token. There is never a moment with two live tokens, or with a hold that no live session owns. If the new session's scope is narrower and leaves out a held seat's channel, that hold stays with the earlier session and is released when it expires. This is why buyer access sessions do not use the platform's generic idempotency replay: that mechanism persists complete response JSON, and this response contains a bearer. ## List sessions **GET /v1/events/:key/buyer-access-sessions**. Authentication: Secret key Newest first. `limit` defaults to 50, capped at 200. ```json { "sessions": [ { "sessionId": "bas_2f4c", "channelIds": ["chn_9f1c"], "includePublic": false, "allowedOrigin": "https://booking.travel-agency.example", "mode": "live", "expiresAt": 1764601800000, "maxQuantity": 4, "buyerRef": null, "partnerRef": null, "accessSource": "promoter", "state": "active", "createdAt": 1764600000000, "revokedAt": null, "accessLinkId": null } ] } ``` `state` is `active` or `revoked`. Expiry is derived from `expiresAt`, never stored as a state, so an expired session still lists as `active` with a past timestamp. `accessSource` is `promoter`, `partner`, or `hosted_link`. `accessLinkId` is set when the session came from a [hosted access link](/server-api/channels) rather than a direct mint. The token is never here, in any form. ## Revoke a session **DELETE /v1/events/:key/buyer-access-sessions/:sessionId**. Authentication: Secret key ```json { "ok": true, "sessionId": "bas_2f4c", "grantVersion": 7 } ``` Revoking twice is not an error. ### Revocation is immediate and ordered By the time this call returns, **no further hold can use that session**: revocation takes effect before the call returns, and any live map still on screen is disconnected at the same moment with WebSocket close code `4401`. A revocation racing a hold therefore has one order, not a race: if the hold lands first it exists under the ordinary hold policy; once the revoke is acknowledged, nothing later can use that grant. Revocation stops new availability sessions, holds, replacement holds, resumes, and extensions. It does **not**: - change historical booking attribution; - prevent the buyer releasing a hold they already have; - stop your trusted backend booking an already-valid hold until its normal expiry. ## What a session cannot do | Situation | Result | |---|---| | Valid `publicKey` bootstrap on a Platform/SDK event | Public-only session plus chart and compact inventory status | | No `Authorization` or public bootstrap on a Platform/SDK event | `404 not_found`; an event key is routing, not audience authority | | No `Authorization` on a Managed public/unlisted event | Existing anonymous Public sale behavior is unchanged | | Expired, revoked, wrong origin, or wrong event | A specific typed error; no fallback to public | | Browser claims a `channelId` it was not granted | Ignored. The server derives scope from the credential | | Buyer opens a second tab | `maxQuantity` is summed across all live holds, so the allowance does not double | | Session expires while a hold is active | The buyer can still **release** it; your backend can still book it | | Session names a hold another session owns | Answered exactly as if the hold did not exist; see below. Never a `401`: the caller's own session is fine | The API fails closed rather than degrading to public. This prevents a private buyer from receiving a wider scope. ### A hold owned by another session A session may only resume, extend, replace or pay for holds it owns. For any other hold, each operation gives the same answer it gives for a hold id that does not exist, so the response never confirms that someone else's hold exists: | Operation | Answer | |---|---| | Resume (`POST /pub/events/:key/hold/resume`) | `404 hold_not_found` | | Checkout | `404 hold_not_found` | | Extend (`POST /pub/events/:key/extend`) | `409 cannot_extend` with `reason: "not_found"` | | Hold with `replaceHoldId` | `409 conflict` with an empty `conflicts` list | Treat each one as "this hold is gone": drop it and let the buyer choose again. Releasing a hold is the exception: the hold id alone is enough to give seats back. A revoked token still gets `401 buyer_access_invalid` on every operation. So does an expired session that tries to resume another session's hold. ## Errors | Status | Code | What to do | |---:|---|---| | 401 | `buyer_access_invalid` | Get a new session; do not retry the same bearer | | 401 | `buyer_access_expired` | Run your refresh flow | | 403 | `buyer_access_origin_mismatch` | Stop; check the configured origin | | 403 | `buyer_access_event_mismatch` | Stop; do not reuse a token across events | | 403 | `buyer_access_mode_mismatch` | Match test and live | | 403 | `channel_access_denied` | Return the buyer to inventory they may see. Do not reveal channel details | | 404 | `not_found` | Unknown or cross-tenant event, session, or channel | | 404 | `hold_not_found` | The hold does not exist, or another session owns it. Drop it and let the buyer choose again | | 409 | `allocation_exhausted` | This private allocation has no inventory left. Do not say "sold out"; the event may still have public inventory | | 422 | `invalid_channel_scope` | Empty scope, more than 20 channels, or a paused/archived channel | | 422 | `include_public_required` | Send `includePublic` explicitly | | 422 | `invalid_allowed_origin` | One canonical HTTPS origin | | 422 | `invalid_expiry` | 60 to 43,200 seconds | | 422 | `invalid_max_quantity` | 1 to 100 | | 422 | `invalid_reference` | A reference exceeded 120 characters | | 429 | `subscribe_churn_limited` | Returned by `POST /pub/events/:key/subscribe-tickets` (with `error: "rate_limited"`) when one session has minted 120 socket tickets in an hour. For the next hour, new sessions from the same browser and network then share a smaller budget. Every reconnect mints a ticket, so this only happens to a page stuck in a reconnect loop. Wait for `Retry-After`; the map and holds keep working over HTTP | ## Server SDK ```ts title="server/access.ts" import SeatLayer from "@seatlayer/server"; const seatlayer = new SeatLayer({ secretKey: process.env.SEATLAYER_SECRET_KEY! }); export async function grantAgencyAccess(eventKey: string, buyerId: string) { const session = await seatlayer.channels.createBuyerAccessSession(eventKey, { channelIds: ["chn_9f1c"], includePublic: false, allowedOrigin: "https://booking.travel-agency.example", expiresInSeconds: 1800, maxQuantity: 4, buyerRef: buyerId, clientRequestId: `agency-login-${buyerId}`, }); // Persist sessionId for audit and revocation. Return only these two. return { token: session.token, expiresAt: session.expiresAt }; } ``` ```python title="server/access.py" session = seatlayer.create_buyer_access_session( event_key, channel_ids=["chn_9f1c"], include_public=False, allowed_origin="https://booking.travel-agency.example", expires_in_seconds=1800, max_quantity=4, buyer_ref=buyer_id, client_request_id=f"agency-login-{buyer_id}", ) ``` ## Checklist - [ ] Authenticate the buyer yourself before asking SeatLayer to mint. A session is a decision, not a lookup. - [ ] For ordinary Platform Public sale, use the SDK's direct `publicKey` bootstrap instead of this server endpoint. - [ ] Mint here only when buyer identity or audience scope requires login, presale, partner, or channel access. - [ ] Send `includePublic` explicitly, every time. - [ ] Keep the token in memory in the browser; never in storage, a URL, or a log. - [ ] Send `clientRequestId` so a retry rotates instead of replaying. - [ ] Store `sessionId` so you can revoke without waiting for expiry. - [ ] Handle expired and revoked as distinct outcomes, not as a network failure. - [ ] Keep test and live scopes separate. Next: [sales channels API](/server-api/channels) and the [private and partner sales tutorial](/integrations/private-and-partner-sales). ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | Public Platform picker shows "The seat map didn't load"; bootstrap is `404 not_found` | Missing/wrong-mode `publicKey`, an unregistered exact origin, or a non-Public event lane. Refusals are deliberately indistinguishable | Pass the event account's matching publishable key and register the page's exact Embed domain | | Scoped private picker shows "The seat map didn't load"; `GET /pub/events/:key/chart` is `404 not_found` | No `Authorization: Bearer bse_…` on the request. A missing bearer is deliberately indistinguishable from an unknown event | Pass `buyerAccessTokenProvider`; after authenticating the buyer, have your server ask SeatLayer for the scoped session | | `403 buyer_access_origin_mismatch` | `allowedOrigin` differs from the page's origin (scheme, host, or port) | Mint with the exact origin the checkout page is served from; one session per origin | | `401 buyer_access_expired` after the map rendered | Token lapsed and the provider did not renew | Return a provider function (not a one-shot `buyerAccessToken`) so the SDK can re-mint | | `422 include_public_required` | `includePublic` omitted from an explicit private-audience mint | Send it explicitly. Use `true` only when that scoped audience should also see Public inventory; ordinary Public sale uses direct `publicKey` bootstrap instead | | `422 invalid_channel_scope` | `channelIds: []` with `includePublic: false` | A session must see inventory. Add channels or set `includePublic: true` | # Inventory cancellations and direct booking > Return Platform/SDK inventory safely and book free seats directly from a trusted backend. Source: https://docs.seatlayer.io/server-api/cancellations-and-box-office/ · Markdown: https://docs.seatlayer.io/server-api/cancellations-and-box-office/index.md These routes belong to **Platform/SDK events**, where your application owns money, commercial orders, fulfilment, refunds, and customer support. SeatLayer only changes authoritative inventory. Refund payment in your system, then unbook with the original booking reference. For a trusted POS or comp workflow, book free seats directly without a buyer hold. They return `404 not_found` for Hosted Ticketing and unresolved events. A Managed organizer must cancel through the coordinated SeatLayer Order/refund workflow, and must use Managed Box Office for counter sales. ## Cancel a booking **POST /v1/events/:key/unbook**. Authentication: Secret key, dashboard session, or event:cancel manage token ```bash curl -sX POST "https://api.seatlayer.io/v1/events//unbook" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "labels": ["A-1","A-2"], "bookingRef": "order_5567" }' ``` Every label must still be booked under that exact `bookingRef`. The operation is all-or-nothing: ### Success ```json { "ok": true, "unbooked": ["A-1", "A-2"], "conflicts": [] } ``` ### Booking mismatch ```json { "error": "booking_mismatch", "conflicts": [ { "label": "A-2", "status": "booked" } ] } ``` The booking reference guard prevents a delayed refund job from freeing a seat that has since been resold under another order. > **Unbook is not a refund** > > Return money in your payment system. `unbook` only changes SeatLayer > inventory. The original booked-seat credit is not returned, and a later > ordinary booking of the same freed unit is not metered again. To > sell a booked seat to someone else without freeing it first, use > [resale](/server-api/inventory/#resale). ## Book inventory directly **POST /v1/events/:key/box-book**. Authentication: Secret key or dashboard session ```bash curl -sX POST "https://api.seatlayer.io/v1/events//box-book" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "labels": ["C-14","C-15"], "bookingRef": "pos_88213" }' ``` `box-book` is a legacy name for a Platform inventory operation. It transitions inventory directly from `free` to `booked`; it does not create a SeatLayer Order, ticket, email, refund, or Door record. There is no hold or browser checkout handoff. The requested labels are atomic; a conflict leaves all of them unchanged. Event-scoped browser manage tokens cannot call this route; the platform backend should normally confirm paid inventory through `/book`. The route is idempotent by `bookingRef`. A replay succeeds with `{"ok":true,"booked":[]}` rather than repeating the sale. ## Recommended cancellation workflow 1. Load your order by the stable booking reference. 2. Lock or idempotently claim the cancellation in your database. 3. Refund or void payment according to your business policy. 4. Call `unbook` with the exact labels and original booking reference. 5. Record the response and reconcile any mismatch instead of broadening the release. 6. Let `seat.released` update downstream systems. If inventory must be guaranteed free before refunding, reverse steps 3 and 4 and implement compensation for a failed refund. Choose one policy explicitly. ## Recommended trusted-POS workflow 1. Read current availability in your operator surface. 2. Choose free labels and calculate payment in the trusted POS. 3. Create a stable POS order reference. 4. Call `box-book`. 5. On `409`, reload inventory and ask the operator to reselect. 6. Persist the successful response and issue the ticket in your system. ## Effects Successful actions update all mounted views in real time, write the event audit log, and emit signed webhooks: | Operation | Webhook | |---|---| | `box-book` | `seat.booked`; possibly `event.soldout` | | `unbook` | `seat.released` | Continue to [reports](/server-api/reports), [webhook events](/webhooks/events), and [idempotency and conflicts](/server-api/idempotency-and-conflicts). # Sales Channels API > Create channels, allocate inventory, set audience prices, preview access, pause or archive channels, and manage hosted links. Source: https://docs.seatlayer.io/server-api/channels/ · Markdown: https://docs.seatlayer.io/server-api/channels/index.md Channels are allocation labels on an Event's inventory. Read [Sales channels and allocations](/platform/sales-channels/) first for the feature model; use this page for exact trusted operations. The [organizer guide](/integrations/private-and-partner-sales/) and [partner guide](/integrations/partner-selling/) own the two role-specific workflows. Two capabilities gate everything here, and neither implies the other: | Capability | Allows | |---|---| | `event:channels:view` | List, allocation map, preview | | `event:channels:manage` | Create, rename, assign, pause, archive, access links | `event:block` grants neither. Manage tokens minted before channels existed keep exactly the capabilities they had. ## List channels **GET /v1/events/:key/channels**. Authentication: Secret key, dashboard session, or event:channels:view manage token Add `?includeArchived=1` to include archived channels. ```json { "assignmentVersion": 1, "publicSale": { "id": "public", "name": "Public sale", "state": "active", "counts": { "allocated": 5, "free": 5, "held": 0, "booked": 0, "blocked": 0, "units": 5 } }, "channels": [ { "id": "chn_9f1c", "name": "Travel Agency A", "color": "#7C5CFF", "marker": "diagonal", "externalRef": "ta-a", "state": "active", "archiveDestination": null, "accessIntent": "none", "priceOverrides": [ { "categoryKey": "standard", "tierId": null, "price": 18 }, { "categoryKey": "vip", "tierId": "child", "price": 30 } ], "pricingVersion": 2, "createdAt": 1764600000000, "updatedAt": 1764600000000, "archivedAt": null, "counts": { "allocated": 3, "free": 3, "held": 0, "booked": 0, "blocked": 0, "units": 3 }, "access": { "intent": "none", "hasActiveGrants": false, "lastMintAt": null } } ] } ``` `counts` are guest-weighted, so a grouped table contributes its guests; `units` is the number of inventory rows behind them. `allocated` is `free + held + booked + blocked`. `state` is `active`, `paused`, or `archived`. ### The `access` object `accessIntent` is the sale route you chose; the two derived fields are read from the grant store, so a row can distinguish "server integration configured" from "server integration chosen, nothing ever minted". | Field | Meaning | |---|---| | `intent` | `none`, `internal`, `server`, or `hosted_link`; see the matrix below | | `hasActiveGrants` | At least one unexpired, unrevoked buyer session names this channel | | `lastMintAt` | Most recent mint for this channel, active or not. `null` if never | `accessIntent` **is enforced**. Each value opens exactly one way of selling the allocation and refuses the other three with `409 channel_access_intent_forbids`: | `accessIntent` | What it opens | What it refuses | |---|---|---| | `none` (default) | Nothing. The allocation is a protected reserve | Both mint routes | | `internal` | Your own staff, through the trusted secret-key inventory routes | Buyer sessions, hosted links | | `server` | `POST /v1/events/:key/buyer-access-sessions` | Hosted links | | `hosted_link` | Creating, rotating, and redeeming a hosted access link | Buyer sessions | A new channel starts at `none`, so nothing can be sold from it until you pick a route. What a caller may select is still derived from the presented credential. This field decides which credentials may be issued at all. ## Create a channel **POST /v1/events/:key/channels**. Authentication: Secret key, dashboard session, or event:channels:manage manage token | Field | Type | Required | Notes | |---|---|---:|---| | `name` | `string` | Yes | Unique per event, case-insensitive. Truncated at 80 characters | | `color` | `string` | No | Organizer-facing only; never reaches a buyer | | `marker` | `string` | No | Organizer-facing only | | `externalRef` | `string` | No | Your own stable id. This is the reconciliation key on reports and webhooks | | `accessIntent` | `string` | No | `none` (default), `internal`, `server`, `hosted_link` | | `reason` | `string` | No | Recorded in the channel audit trail | Returns `201 {"ok": true, "channel": {…}}` with zeroed counts. Creating a channel does **not** move `assignmentVersion`. No inventory changed hands. `Public sale` and `public` are reserved names and return `409 channel_name_taken`. ## Update a channel **PATCH /v1/events/:key/channels/:channelId**. Authentication: Secret key, dashboard session, or event:channels:manage manage token Send `name`, `accessIntent`, `priceOverrides`, or a combination. A pricing write must also include `expectedPricingVersion`. Sending none of the three mutable fields returns `422 channel_name_required`. Returns `{"ok": true, "channel": {…}}` with the bare record. It has no `counts` or `access` sub-object. Neither operation moves `assignmentVersion`. ### Set audience-specific prices `priceOverrides` is a sparse price book for inventory assigned to this channel. Omit a category or tier to inherit the event price. Send an empty array to return the whole channel to event pricing. ```json { "expectedPricingVersion": 2, "priceOverrides": [ { "categoryKey": "standard", "tierId": null, "price": 18 }, { "categoryKey": "vip", "tierId": "adult", "price": 65 }, { "categoryKey": "vip", "tierId": "child", "price": 30 } ], "reason": "Partner contract renewal" } ``` - Untiered categories require `tierId: null`. A category with ticket tiers requires one row for each tier you want to override; a category-level row is invalid for it. - Category and tier ids must exist in the event's current pricing catalogue. Prices are major currency units from 0 through 1,000,000, and the list is capped at 500 rows. - `pricingVersion` is independent from `assignmentVersion`. Send the version you last read. A stale write changes nothing and returns `409 channel_pricing_conflict` with the current and expected versions. Re-read and ask the operator to review; do not auto-overwrite a colleague's change. - A changed price affects future selections immediately. Existing holds and booked lines retain their immutable unit price and `channelPricingVersion`. The authoritative server order is: an explicit staff-selected ticket release, then a channel override, then the active event ticket release, then the event tier/category price. Ordinary channel-priced sales do not consume an event release quota. The browser receives only the compact pricing projection for its granted channels, and the server resolves the same price again when it creates the hold. ### Switching the sale route while buyers are inside it Changing `accessIntent` on a channel that has live access is refused with `409 channel_intent_switch_blocked`, so an integration is never cut off silently: ```json { "code": "channel_intent_switch_blocked", "details": { "from": "hosted_link", "to": "internal", "liveAccessLinks": 2, "activeSessions": 7, "acknowledgeWith": { "acknowledgeLiveAccess": true } } } ``` Retry with `{"accessIntent": "internal", "acknowledgeLiveAccess": true}` to make the switch. Buyer sessions already minted **keep working**, as do their holds. They expire on their normal schedule, and no new session can be minted. Hosted links on the channel are revoked, because redemption refuses from that moment and a link left listed as active would advertise a door that no longer opens. The response reports what happened as `"intentSwitch": {"closedLinks": 2, "keptSessions": 7}`. Re-declaring the route a channel already has is idempotent and never blocked. To end live sessions deliberately, revoke them (`DELETE /v1/events/:key/buyer-access-sessions/:sessionId`) or revoke the link with `endActiveSessions`. ## Apply an allocation **POST /v1/events/:key/channels/assignments**. Authentication: Secret key, dashboard session, or event:channels:manage manage token This operation is **versioned**, **non-destructive**, and reports exactly what it did. ```json { "targetChannelId": "chn_9f1c", "labels": ["A-1", "A-2", "A-3"], "assignmentVersion": 1 } ``` | Field | Type | Required | Notes | |---|---|---:|---| | `labels` | `string[]` | Yes | De-duplicated server-side. At most **5,000** units per request | | `assignmentVersion` | `integer` | Yes | The version you last read. Required for concurrency control; see below | | `targetChannelId` | `string \| null` | No | `null` or `"public"` both mean Public sale. Defaults to Public sale | | `reason` | `string` | No | Recorded in the audit trail | ### `assignmentVersion` is concurrency control `assignmentVersion` moves **only** when an assignment changes inventory or when a channel is archived. A busy on-sale moves the event's `updatedAt` constantly and never touches this, so your operator's staged edit does not go stale just because tickets are selling. Send the version you read. If someone else changed the allocation in between, **nothing moves** and you get: ```json { "error": "channel_assignment_conflict", "code": "channel_assignment_conflict", "message": "The channel allocation changed after it was read; refresh and review", "details": { "assignmentVersion": 2, "expectedAssignmentVersion": 1 } } ``` `409`. `details.assignmentVersion` is the server's current value. Re-read it, show the operator what changed, and retry. Do not retry automatically with the returned version: that turns a safety mechanism into a silent overwrite of a colleague's work. ### The six buckets ```json { "ok": true, "targetChannelId": "chn_9f1c", "assignmentVersion": 2, "requested": 6, "applied": 3, "buckets": { "changedFromPublic": { "count": 2 }, "movedFromOtherChannel": { "count": 1, "channels": [ { "channelId": "chn_4a7b", "name": "Sponsor guests", "count": 1 } ] }, "alreadyInTarget": { "count": 1 }, "skippedHeld": { "count": 1, "labels": ["A-7"], "truncated": false }, "skippedBooked": { "count": 0, "labels": [], "truncated": false }, "notFound": { "count": 1, "labels": ["ZZ-9"], "truncated": false } } } ``` Every requested label lands in exactly one bucket. | Bucket | Meaning | |---|---| | `changedFromPublic` | Moved out of Public sale | | `movedFromOtherChannel` | Moved out of another channel, itemised by source | | `alreadyInTarget` | Already there. Not an error | | `skippedHeld` | A buyer is mid-checkout. Not moved | | `skippedBooked` | Already sold. Not moved | | `notFound` | Not a label in this event | - `requested` counts labels **after** de-duplication. - `applied` = `changedFromPublic.count + movedFromOtherChannel.count`. Only free and blocked inventory is ever written. - **Seats in someone's checkout and seats already sold are never moved.** That is the non-destructive guarantee, and it is why an Apply is safe on a live event. - `movedFromOtherChannel.channels[].name` is `null` when the source channel row is gone. - The three sampled buckets list at most 50 labels; `truncated: true` means the `count` exceeded the sample. - When `applied` is `0`, `assignmentVersion` comes back **unchanged**. An all-skipped Apply does not invalidate the version you are holding. > **Already in the target wins over held and booked** > > Bucket precedence is: not found, already in target, held, booked, from public, > from another channel. So a *sold* seat that is already in the target channel > reports as `alreadyInTarget`, not `skippedBooked`. Read `applied` to know what > changed, not the skip counts. `422 too_many_units` carries `details: {requested, maximum}` when a request exceeds 5,000 units. ## Read the allocation map **GET /v1/events/:key/channels/allocation**. Authentication: Secret key, dashboard session, or event:channels:view manage token Label to channel, paged by label. Carries no hold, booking, price, or buyer data. | Query | Notes | |---|---| | `afterLabel` | Exclusive cursor. Pass the previous page's `nextAfterLabel` | | `limit` | Default 1,000, capped at 5,000 | ```json { "assignmentVersion": 1, "allocations": [ { "label": "A-1", "channelId": "chn_9f1c" }, { "label": "A-4", "channelId": "public" } ], "nextAfterLabel": "A-4" } ``` `nextAfterLabel` is `null` on the last page. ## Preview an audience **GET /v1/events/:key/channels/preview**. Authentication: Secret key, dashboard session, or event:channels:view manage token Returns the seat projection a buyer with that exact scope would receive. It uses the same code path as a real buyer session, is read-only, and mints nothing. | Query | Notes | |---|---| | `channelIds` | Comma-separated. The literal `public` is read as `includePublic`, not as a channel | | `includePublic` | `1`/`true`. When omitted, defaults to true only if you named no real channels | ```json { "ok": true, "audience": { "channelIds": ["chn_9f1c"], "includePublic": false }, "available": true, "seats": { "A-1": "free", "A-3": "blocked" }, "hidden": [], "closed": [], "updatedAt": 1764600000000, "assignmentVersion": 1 } ``` A paused, archived, or unknown channel returns the unavailable landing state rather than its seats, still with `200`: ```json { "ok": true, "audience": { "channelIds": ["chn_ghost"], "includePublic": false }, "available": false, "unavailable": [ { "channelId": "chn_ghost", "state": "not_found" } ], "assignmentVersion": 1 } ``` Naming more than 20 channels, or an empty scope, returns `422 invalid_channel_scope`. Use this before a partner goes live: it is the check that the allocation you applied is the inventory the partner will actually see. ## Pause and resume **POST /v1/events/:key/channels/:channelId/pause**. Authentication: Secret key, dashboard session, or event:channels:manage manage token **POST /v1/events/:key/channels/:channelId/unpause**. Authentication: Secret key, dashboard session, or event:channels:manage manage token Pausing stops new access and new holds. **Existing holds run to their normal expiry**, so pausing does not strand a buyer mid-checkout. Optional `reason` is recorded. Returns `{"ok": true, "channel": {…}}`. Neither moves `assignmentVersion`. ## Archive with a destination **POST /v1/events/:key/channels/:channelId/archive**. Authentication: Secret key, dashboard session, or event:channels:manage manage token Archiving closes a channel for good and says where its inventory goes. | Field | Type | Required | Notes | |---|---|---:|---| | `destination` | `string \| null` | Yes, as a key | `null` or `"public"` means Public sale. Otherwise an active channel that is not this one | | `reason` | `string` | No | Recorded in the audit trail | Archive takes **no** `assignmentVersion`. It is unconditional and returns the new value. ```json { "ok": true, "channel": { "id": "chn_9f1c", "name": "Travel Agency A", "state": "archived", "archiveDestination": "public", "archivedAt": 1764600001000 }, "assignmentVersion": 2, "moved": { "free": 2, "blocked": 0, "booked": 0, "units": 2 }, "revokedSessions": 1 } ``` - `revokedSessions` counts buyer access sessions cascade-revoked in the same operation. An archived channel never leaves live access behind it. - **Sold seats keep the channel that sold them, forever.** Archiving updates only where a seat would return to *if cancelled later*. Nothing is deleted, and the [channel report](/server-api/reports) still attributes those sales correctly. ### Archive refuses to strand a buyer If anyone is mid-checkout on the channel's inventory, archive is blocked and tells you when to try again: ```json { "error": "channel_archive_blocked_by_holds", "code": "channel_archive_blocked_by_holds", "message": "This channel still has active holds; archive is blocked until they release or expire", "details": { "channelId": "chn_9f1c", "activeHolds": 1, "heldUnits": 1, "latestHoldExpiresAt": 1764600180000, "retryAfterMs": 179000 } } ``` `409`. Wait `retryAfterMs` and retry, or release the holds first. ## Hosted access links A hosted access link is a shareable URL a buyer opens to obtain a buyer access session for one channel, without your backend authenticating them individually. Use one for audiences you cannot authenticate individually, such as a sponsor's guest list or press allocation. Use a [buyer access session](/server-api/buyer-access-sessions) when you can authenticate each buyer. Hosted access links are available only for **Hosted Ticketing** events, where the redeemed session continues into SeatLayer checkout and the SeatLayer Order, ticket, and Door lifecycle. A Platform/SDK event should use a trusted-server-created buyer access session instead; in that mode your platform owns checkout, payment, Orders, tickets, refunds, and fulfilment. Link create, list, rotate, and revoke return `404 not_found` if the event is not Hosted Ticketing. A previously issued capability returns `401 access_link_invalid` after the event no longer has that Hosted Ticketing authority. **POST /v1/events/:key/channels/:channelId/access-links**. Authentication: Secret key, dashboard session, or event:channels:manage manage token | Field | Type | Default | Bounds | |---|---|---|---| | `label` | `string` | `null` | 80 characters | | `expiresAt` | `integer` (epoch ms) | The event's start, or 7 days out | 60 s to 180 days from now | | `maxRedemptions` | `integer` | `100` | 1 to 10,000 | | `maxQuantity` | `integer` | `4` | 1 to 100, guest-weighted across the buyer's holds | | `sessionTtlSeconds` | `integer` | `1800` | 60 to 43,200 | | `includePublic` | `boolean` | `false` | None | `expiresAt` is an absolute timestamp, not a duration. ```json { "link": { "id": "alk_5b0e", "channelId": "chn_9f1c", "label": "VIP list Nov 14", "includePublic": false, "expiresAt": 1765200000000, "maxRedemptions": 100, "redemptions": 0, "maxQuantity": 4, "sessionTtlSeconds": 1800, "state": "active", "status": "active", "createdAt": 1764600000000, "createdBy": "user:usr_123", "revokedAt": null, "lastRedeemedAt": null, "rotatedFrom": null, "rotatedTo": null }, "url": "https://app.seatlayer.io/a#alc_ZXZ0X2FiYw_7c1e", "capability": "alc_ZXZ0X2FiYw_7c1e", "revealedOnce": true } ``` > **This response is the only copy** > > The capability is shown once and is never retrievable: **SeatLayer cannot show > it again**, not in the list and not through support. Persist the > `url` at the moment you receive it, or you will be rotating. > > It travels in the URL **fragment**, which browsers never send to a server, so > it stays out of access logs and `Referer` headers. `state` is `active`, `revoked`, or `rotated`; `status` adds the derived `expired` and `exhausted`. ### List, rotate, revoke **GET /v1/events/:key/channels/:channelId/access-links**. Authentication: Secret key, dashboard session, or event:channels:view manage token Status only: `{"links": [{…, "activeSessions": 0}]}`. No capability. There is no route that recovers a lost link. **POST /v1/events/:key/channels/:channelId/access-links/:linkId/rotate**. Authentication: Secret key, dashboard session, or event:channels:manage manage token Rotation is how you recover a lost link. It issues a successor inheriting every policy field and ends the predecessor. `endActiveSessions` is a **required boolean**, and there is no default, because the two answers are very different: | `endActiveSessions` | Effect | |---|---| | `false` | Sessions already minted from the old link keep working until they expire. Use this when the link was merely misplaced | | `true` | Those sessions are cascade-revoked before the rotation is acknowledged. Use this when the link leaked | The cascade is scoped to that link, so a session your backend minted directly for the same channel is untouched. The response adds `previous` and `endedSessions` to the create shape. **DELETE /v1/events/:key/channels/:channelId/access-links/:linkId**. Authentication: Secret key, dashboard session, or event:channels:manage manage token Add `?endActiveSessions=1` to cascade. The default is the gentle branch: the link stops admitting new buyers, existing sessions expire naturally. ## Errors | Status | Code | Meaning | |---:|---|---| | 404 | `not_found` | Unknown event, channel, or link. Also used for cross-tenant references | | 409 | `channel_name_taken` | Name already used in this event | | 409 | `channel_archived` | The channel is archived; nothing may change | | 409 | `channel_assignment_conflict` | Stale `assignmentVersion`. Refresh and review | | 409 | `channel_archive_blocked_by_holds` | Active holds. `details` says how many and until when | | 409 | `channel_assignment_would_drop` | A chart update would remove allocated inventory. Acknowledge and retry | | 409 | `channel_unavailable` | The channel is not active, so a link cannot be created | | 409 | `access_link_not_active` | The link is revoked, rotated, or otherwise not live | | 409 | `too_many_access_links` | 20 live links on this channel | | 409 | `channel_access_intent_forbids` | The channel's `accessIntent` does not open this sale route. `details` names the channel, its intent, and the route | | 409 | `channel_intent_switch_blocked` | The channel has live access. Review `details`, then retry with `acknowledgeLiveAccess` | | 422 | `channel_name_required` | Missing or blank name | | 422 | `invalid_channel_destination` | Archive destination is missing, unknown, archived, or the channel itself | | 422 | `invalid_access_intent` | Not one of the four declared access methods | | 422 | `too_many_units` | More than 5,000 units in one Apply | | 422 | `labels_required` | No usable labels | | 422 | `channel_state_invalid` | The requested state transition is not allowed | | 422 | `invalid_channel_scope` | Empty scope, or more than 20 channels | | 422 | `assignment_version_required` | `assignmentVersion` absent or not a non-negative integer | | 422 | `end_active_sessions_required` | Rotation did not say what to do with live sessions | ## Checklist - [ ] Read `assignmentVersion` immediately before an Apply, and surface conflicts to a human. - [ ] Treat `alreadyInTarget` as success, not as a skip. - [ ] Set `externalRef` on every channel you will reconcile against. - [ ] Preview the audience before a partner goes live. - [ ] Persist a hosted link's `url` at the moment of creation. - [ ] Choose `endActiveSessions` explicitly. A misplaced link and a leaked link require different handling. - [ ] Never treat a channel id from a client as authorization. Next: [buyer access sessions](/server-api/buyer-access-sessions) and the [private and partner sales tutorial](/integrations/private-and-partner-sales). # API errors > The SeatLayer API error body, every public error code grouped by area with its HTTP status, and which errors are safe to retry. Source: https://docs.seatlayer.io/server-api/errors/ · Markdown: https://docs.seatlayer.io/server-api/errors/index.md Every error from the SeatLayer API has the same shape: a JSON object whose first key, `error`, is a stable machine code. Branch on `error` (and `code` when present), never on `message`. ## The error body ```json title="404 response" { "error": "not_found" } ``` ```json title="409 response with detail" { "error": "order_edit_in_progress", "code": "order_edit_in_progress", "retryable": true, "conflicts": [{ "label": "A-12", "status": "blocked" }] } ``` | Field | Always present | Meaning | |---|---|---| | `error` | Yes | Stable machine code, never a sentence | | `code` | No | A more specific reason when `error` is a broad class, such as `error: "forbidden"` with `code: "missing_manage_capability"`. Often equal to `error` | | `message` | No | Human-readable text for logs and operators. It can change; do not parse it | | `retryable` | No | `true` when the same request can succeed later without changes | | `retryAfterSeconds` | No | Seconds to wait, on rate limits and maintenance | | `requestId` | No | Sent in the body on `500` and `503`. Every response also carries an `X-Request-ID` header | Routes add their own detail fields next to these, for example `conflicts`, `issues`, `maxBytes`, or `currentUpdatedAt`. Treat unknown fields as extra information, not as a different error. The [OpenAPI document](/openapi.json) lists the exact error codes each operation can return. This page groups them so you can write one handler. ## Status codes | Status | Meaning | Retry | |---|---|---| | `400` | The request is missing a required field or is malformed | No. Fix the request | | `401` | No valid credential | No. Check the key | | `402` | Not enough credits to create inventory | No. Buy credits first | | `403` | The credential cannot do this, or the resource is in the other mode | No | | `404` | The resource does not exist for this account and mode | No | | `409` | The current state does not allow the change | Only when `retryable: true` | | `413` | Body or upload too large | No | | `415` | Unsupported file type | No | | `422` | The request is well formed but a value is invalid | No | | `429` | [Rate limited](/server-api/rate-limits/) | Yes, after `Retry-After` | | `500` | Unexpected server error | Reads: yes. Writes: see below | | `502` | A payment provider step needs a retry | Yes, with the same `Idempotency-Key` | | `503` | Temporarily unavailable or in maintenance | Yes, after `Retry-After` | ## Codes by area ### Authentication and access | Status | `error` / `code` | When | |---|---|---| | `401` | `unauthorized` / `bad_key` | Missing, revoked, or malformed `sk_…` secret key | | `401` | `unauthorized` / `bad_manage_session`, `manage_session_expired` | Event manage token is invalid or expired | | `403` | `mode_mismatch` | A test key used on a live resource, or the reverse | | `403` | `forbidden` / `missing_manage_capability` | The manage token lacks the capability, such as `event:block` | | `403` | `forbidden` / `event_scope_mismatch` | The manage token belongs to another event | | `403` | `forbidden` | The dashboard role cannot make this change | | `403` | `channel_access_denied` | The seats belong to a sales channel this caller cannot use | | `403` | `module_not_enabled` | The feature is not turned on for your account, for example Seasons or Event Configurations | ### Validation | Status | Examples | |---|---| | `400` | `name_required`, `chartId_required`, `selections_required`, `holdId_required`, `qty_required`, `labels_required`, `labels_and_holdId_required`, `bookingRef_required`, `hold_or_labels_and_bookingRef_required`, `objects_required`, `rules_required`, `releaseAt_required`, `blockRef_required`, `invalid_zone`, `invalid_cursor`, `empty_body`, `nothing_to_update` | | `413` | `chart_too_large`, `image_too_large`, `payload_too_large` | | `415` | `unsupported_image` | | `422` | `invalid_request`, `invalid_selection`, `invalid_chart`, `invalid_currency`, `invalid_timezone`, `invalid_listing_id`, `invalid_availability_rule`, `unknown_zone`, `image_dimensions_unsupported` | ### Events and charts | Status | `error` | When | |---|---|---| | `402` | `insufficient_credits` | Creating or copying an event, or adding Season performances, needs more credits | | `404` | `not_found`, `chart_not_found`, `chart_unavailable` | Unknown event or chart | | `409` | `event_exists` | The workspace already has an event with this slug | | `409` | `event_not_mutable`, `invalid_sales_transition`, `sales_revision_conflict` | The event's sales state does not allow the change | | `409` | `performance_group_locked`, `season_event_locked` | The event belongs to an active Performance Group or Season | | `409` | `seats_would_vanish` | Updating the chart would remove held or booked seats | | `409` | `event_configuration_bound` | Unbind the Event Configuration before changing the chart | | `422` | `chart_not_published`, `invalid_event_inventory`, `inventory_limit_exceeded` | The chart cannot become event inventory | ### Holds, booking, and inventory | Status | `error` | When | |---|---|---| | `404` | `hold_not_found` | The hold id is unknown | | `409` | `conflict` | One or more seats are not available. `conflicts` lists each label and its status. Nothing in the request was applied | | `409` | `event_closed` | Sales are closed or outside the sales window | | `409` | `hold_not_active` | The hold expired or was released before booking | | `409` | `cannot_extend` | The hold is gone, expired, or at its extension limit | | `409` | `booking_mismatch` | `unbook` named a `bookingRef` that does not own the seats | | `409` | `not_booked` | Resale listing named a seat that is not booked | | `409` | `resale_unsupported_on_seatlayer_checkout` | Resale on a Hosted Ticketing event or ticketed seat | | `409` | `order_edit_in_progress` | An order edit is changing these seats. Sent with `retryable: true` | | `409` | `legacy_hold_requires_restart` | The hold predates the current format; start a new hold | | `422` | `hold_quantity_exceeded` | More seats than the hold limit allows | ### Idempotency | Status | `error` | When | |---|---|---| | `400` | `invalid_idempotency_key` | The key is not 1 to 128 characters of `A-Z a-z 0-9 . _ : -` | | `400` | `idempotency_not_supported_for_operation` | This operation does not accept `Idempotency-Key` | | `400` | `idempotency_key_required` | Season order refunds and cancels need a key | | `409` | `idempotency_conflict` | The key was used before with a different request | | `409` | `idempotency_in_progress` | The first call with this key is still running. Retry after `Retry-After` | See [idempotency and conflicts](/server-api/idempotency-and-conflicts/). ### Seasons and Performance Groups | Status | Examples | |---|---| | `409` | `not_a_draft`, `revision_mismatch`, `not_active`, `sales_open`, `sales_not_ended`, `plan_not_published`, `invalid_transition`, `lifecycle_transition_pending`, `amendment_in_flight`, `payment_in_progress`, `book_operation_mismatch`, `booking_not_cancellable`, `hosted_order_use_refund`, `group_not_draft`, `group_not_active`, `member_set_mismatch` | | `502` | `refund_recovery_required`: retry with the same `Idempotency-Key` | | `503` | `channel_state_unavailable`, `participant_unavailable`, `recovery_pending`, `lifecycle_unavailable` | Long Season and Performance Group operations can answer `202 Accepted` with `Location` and `Retry-After`. That is progress, not an error: poll the `Location` until the state is terminal. ### Webhooks | Status | `error` | When | |---|---|---| | `403` | `forbidden` | Only an admin can change subscriptions | | `404` | `not_found` | Unknown subscription | | `422` | `invalid_url`, `invalid_events`, `invalid_mode`, `invalid_environment` | The endpoint must be public HTTPS and events must be [known names](/webhooks/events/) | ### Service-wide | Status | `error` | When | |---|---|---| | `404` | `not_found` | No such route | | `429` | `rate_limited` | See [rate limits](/server-api/rate-limits/) | | `500` | `internal_error` | Unexpected failure. Quote `requestId` to support | | `503` | `temporarily_unavailable` | A temporary server condition; safe to retry. Sent with `retryable: true` and `Retry-After: 1` | | `503` | `maintenance_mode` | Writes are paused. Reads still work. The body has `retryAfterSeconds`, `resumesAt`, and `message` | ## Which errors to retry - **Reads (`GET`)**: retry on connection errors, `408`, `429`, and `5xx`, with backoff. - **Writes with `Idempotency-Key`**: retry on the same failures with the **same** key. The first result is replayed, so nothing happens twice. - **Writes without idempotency**: holds, blocks, and most inventory changes. Retry `429` after `Retry-After`, because it did nothing. A `503` whose `code` is `event_temporarily_unavailable` means the event's inventory did not answer in time, and the change may still have been made. Before you repeat a hold, block, extend, or best-available call, read the hold or the seats. After a timeout or `500`, also read the current state before trying again. - **`409` with `retryable: true`**: wait briefly and send the same request. - **Every other `4xx`**: do not retry unchanged. Fix the request, pick other seats, or show the conflict to the operator. A booking call keyed by one `bookingRef` is safe to repeat: the same reference cannot create a second sale. Releasing a hold is safe to repeat too: it only frees seats still held under that `holdId`. See [idempotency and conflicts](/server-api/idempotency-and-conflicts/#safe-booking-retries). # Event regions > Choose one of 11 provider-neutral regions for low-latency live Event inventory, with workspace defaults and per-Event overrides. Source: https://docs.seatlayer.io/server-api/event-regions/ · Markdown: https://docs.seatlayer.io/server-api/event-regions/index.md SeatLayer supports exactly **11 Event regions**. Choose the region nearest the venue. If buyers are concentrated somewhere else, choose the region containing the largest expected buyer audience. This is a latency preference for live Event inventory. It is not a guarantee that all account, payment, analytics, or backup data stays in that region. | API value | Region | Typical venues or audiences | |---|---|---| | `western-europe` | Western Europe | Netherlands, Belgium, UK, Ireland, France, Germany, Spain, Italy, Nordics | | `eastern-europe` | Eastern Europe | Poland, Czechia, Hungary, Romania, Balkans, Baltics, Ukraine | | `north-america-east` | Eastern North America | Eastern and central US or Canada, Caribbean | | `north-america-west` | Western North America | Western US or Canada, Mexico | | `south-america` | South America | Brazil, Argentina, Chile, Colombia | | `asia-pacific` | Asia Pacific | India, Pakistan, Bangladesh, Sri Lanka, or a broadly distributed Asia-Pacific audience | | `northeast-asia` | Northeast Asia | Japan, South Korea, Taiwan | | `southeast-asia` | Southeast Asia | Singapore, Malaysia, Indonesia, Thailand, Vietnam, Philippines | | `oceania` | Oceania | Australia, New Zealand, Pacific islands | | `africa` | Africa | African venues | | `middle-east` | Middle East | UAE, Saudi Arabia, Qatar, Kuwait, Bahrain, Oman | ## Resolution order For a new Event, SeatLayer resolves the region in this order: 1. the Event request's `region`; 2. the chart workspace's `defaultRegion`; and 3. the platform fallback, `western-europe`. During signup, SeatLayer can recommend a workspace default from approximate request geography. The organizer sees and confirms the choice. A manual choice wins, and later workspace creation asks again. The location estimate is not stored as an Event region unless the organizer confirms it. ```json title="workspace-and-event.json" { "workspace": { "name": "Rotterdam Events", "defaultRegion": "western-europe" }, "event": { "chartId": "chart_abc", "name": "Rotterdam Night", "region": "western-europe", "currency": "EUR" } } ``` Region and currency are independent. A Singapore Event can use `"region": "asia-pacific"` with `"currency": "SGD"`; a Rotterdam Event can use `"region": "western-europe"` with `"currency": "EUR"`. ## Server SDK fields The optional field names in the `0.8.0` SDKs are: | SDK | Workspace default | Event override | |---|---|---| | Node.js | `workspaces.create({ name, defaultRegion })` | `events.create({ chartId, region })` | | Python | `workspaces.create(name, default_region=...)` | `events.create(chart_id, region=...)` | | PHP | `workspaces->create($name, defaultRegion: ...)` | `events->create($chartId, region: ...)` | | Java | `workspaces().create(name, region)` | put `region` in the `events().create(...)` map | | Go | `WorkspaceCreateParams.DefaultRegion` | `EventCreateParams.Region` | | Ruby | `workspaces.create(name:, default_region: ...)` | `events.create(chart_id:, region: ...)` | | .NET | `Workspaces.CreateAsync(..., defaultRegion: ...)` | `EventCreateRequest.Region` | Omit the Event override to inherit the workspace default. In languages with typed region constants, prefer those constants over hand-written strings. The wire values remain the 11 API values in the table above; only these values are accepted. ## Immutability and retries The chosen region is frozen when Event inventory is first created. Changing a workspace default affects only later Events. Event update requests cannot move an existing Event. Reuse the same idempotency key and exact request body when a create response is lost; SeatLayer reuses the originally resolved region. Events created before this field was introduced can return `region: null`. Do not infer or display a region for those legacy Events. An unknown value, including any infrastructure-specific alias, returns `422 invalid_region`. Continue to [Events](/server-api/events/), [Workspaces](/platform/workspaces/), and [Seasons](/server-api/seasons/). # Events > Create events from published charts, read live counts, update metadata and chart snapshots, and manage the event lifecycle. Source: https://docs.seatlayer.io/server-api/events/ · Markdown: https://docs.seatlayer.io/server-api/events/index.md An event is an event-owned seating and pricing snapshot plus live inventory. Create it from a published chart, address that inventory through its event key, and update the map deliberately when the source chart changes. Dashboard users can edit an event's normal prices without changing sibling events on the same chart; existing hold and booking prices remain locked. For a Platform/API integration, this is an infrastructure Event: it does not imply a SeatLayer catalogue entry, Hosted Event Page, Organizer Website, managed checkout, commerce Order, or ticket. Keep those in your platform unless the organizer deliberately uses the separate Hosted Ticketing product. Every Event is created from a published reserved-seat Chart. GA areas may exist inside that Chart, but a pure-GA event without a seating chart is not a supported target. Platform/SDK versus Managed commerce ownership is also fixed when the Event is created; reuse the Chart and create a new Event to change products. > **Chart and event are different resources** > > Map refresh protects booked and held labels. Chart price changes are separate: > linked events retain their prices until an organizer explicitly applies the > new defaults in the dashboard. ## Create an event **POST /v1/events**. Authentication: Secret key or dashboard session ```bash title="Create an event" curl -sX POST "https://api.seatlayer.io/v1/events" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: event_order_8271" \ -d '{ "chartId": "chart_abc", "name": "Opening Night", "slug": "opening-night", "startsAt": 1767204000000, "venue": "Main Hall", "region": "western-europe", "externalRef": "show_8271", "currency": "GBP" }' ``` The chart must be published and belong to an active [workspace](/platform/workspaces/). A live event requires a positive credit balance; test events do not consume credits. | Field | Type | Required | Behavior | |---|---|---|---| | `chartId` | `string` | Yes | Published source chart | | `name` | `string` | No | Defaults to chart name | | `slug` | `string` | No | 3–64 lowercase letters, digits, `_`, or `-`; becomes the event key. Slugs are unique per workspace: when another customer already uses one, a numeric suffix is appended (`opening-night-2`), so always read the key from `meta.key` | | `startsAt` | `number` | No | Epoch milliseconds | | `venue` | `string` | No | Venue label | | `mode` | `"live" \| "test"` | No | Session calls only; a secret key's mode wins | | `externalRef` | `string` | No | Host identifier, at most 128 characters | | `currency` | `string \| null` | No | Three-letter event override; otherwise org currency | | `description` | `string \| null` | No | Event description used by share and hosted surfaces | | `endsAt` | `number \| null` | No | End time in epoch milliseconds; cannot precede `startsAt` | | `timezone` | `string \| null` | No | IANA time zone such as `Asia/Kolkata` | | `region` | `string` | No | One of the 11 [Event regions](/server-api/event-regions/); otherwise the workspace default | | `locale` | `string \| null` | No | BCP-47 language tag such as `hi-IN` | | `posterAssetId` | `string \| null` | No | Previously staged dashboard poster asset | | `async` | `true` | No | Dashboard sessions only. Answers as soon as the event row is durable and copies the chart pin and inventory behind the response; `meta.provisioning` is `true` until they land (typically ~2 s). Ignored for secret-key callers, whose events are always provisioned in the request | A successful response is `201 {"meta": EventMeta}`. Store `meta.key` beside your platform's own event ID; it is the inventory key used by the buyer SDK and server booking calls. A secret-key-created event starts with Platform checkout authority and live inventory immediately; it does not require event-page details or SeatLayer ticket releases. Event creation supports `Idempotency-Key`; retry the same logical create with the same key and body. While `meta.provisioning` is `true` (dashboard `async` creates only), every route that reads the event's chart pin or inventory (buyer bootstrap, chart, availability, holds, and the organizer chart, pricing and report reads) answers `503 event_provisioning` with `Retry-After: 2` rather than a `404`. Poll `GET /v1/events/{key}` until the flag is absent; `seatTotal` is the chart's seat count until then. A secret-key create never returns the flag. Choose `region` when creating the event: an event's live inventory is placed in its region when the event is first used, and an existing event cannot be moved by changing later requests. Pick the broad region nearest the **event venue**, not the developer, API server, or organizer creating it. For Rotterdam use `western-europe`; for India use `asia-pacific`. Omit `region` to inherit the chart workspace's default. See the [11-region reference and resolution rules](/server-api/event-regions/). Placement is a best-effort latency preference, not a legal data-residency guarantee. Ticket releases are optional. A Platform integration can keep its own on-sale windows and pricing policy while using SeatLayer inventory, or manage event-scoped releases through the API described below. > **Choose Public or scoped browser access** > > Secret-key-created Platform events are embed-only. For ordinary Public sale, > mount the Buyer SDK with the event key plus its account's publishable > `pk_test_…` or `pk_live_…` key. SeatLayer validates the exact registered > browser origin and returns one Public-only in-memory session together with the > chart and compact inventory status. Login, presale, partner, and channel > inventory still require an exact-origin > [buyer access session](/server-api/buyer-access-sessions) from your backend. > An explicit buyer token/provider takes precedence over `publicKey`; an event > key by itself still receives `404`. Secret-key hold and booking calls are > unchanged. ## List and retrieve **GET /v1/events**. Authentication: Secret key or dashboard session Use `workspaceId` to select an active workspace and `externalRef` to find the event belonging to a host record: ```bash curl -s \ "https://api.seatlayer.io/v1/events?workspaceId=ws_main&externalRef=show_8271" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` The response includes `events[]`, with cached metadata, `mode`, `sold`, and live `counts` where available. A secret key receives only events matching its own `live` or `test` mode. A dashboard session receives both modes for the selected workspace. Environment tags do not filter event lists or event access. Mode filtering happens before `externalRef`, paging, and counts are returned. When no event in the key's mode matches the other filters, the list returns `200 {"events":[]}`; list calls do not return `mode_mismatch`. Direct access to a same-organization event in the opposite mode remains an explicit `403 {"error":"mode_mismatch"}`. Unknown and other-organization event keys remain non-disclosing `404` responses. ### Paging The list is paginated. A response carries at most `limit` events (default 100, maximum 200) plus a `nextCursor` when more remain; the absence of `nextCursor` means you have reached the end. ```bash curl -s "https://api.seatlayer.io/v1/events?limit=50" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" # → { "events": [ … ], "nextCursor": "MTc1NDA2…" } curl -s "https://api.seatlayer.io/v1/events?limit=50&cursor=MTc1NDA2…" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` Cursors are opaque: pass back exactly what you were given and do not construct one. A cursor we cannot read restarts the list from the beginning rather than erroring, so a stale bookmark degrades instead of breaking. An oversized `limit` clamps to the maximum rather than being rejected. > **Turn off counts when walking the whole catalogue** > > Live `counts` are computed per event, which costs one internal round-trip > each. Pass `counts=0` when you are paging every event and only need metadata: > > ```bash curl -s "https://api.seatlayer.io/v1/events?limit=200&counts=0" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` > > The [server SDKs](/server-sdk/install/) do this for you: their `listAll` > helpers page transparently and drop counts by default. `GET /v1/charts` pages the same way, with `limit` and `cursor`. **GET /v1/events/:key**. Authentication: Secret key or dashboard session The detail response contains: ```json { "meta": { "key": "opening-night", "name": "Opening Night", "chartId": "chart_abc", "status": "active", "seatTotal": 1200, "mode": "live", "workspaceId": "ws_main", "externalRef": "show_8271", "currency": "GBP", "region": "western-europe", "sold": 412, "inventoryModelVersion": 2 }, "counts": { "free": 760, "held": 8, "booked": 412, "blocked": 20 }, "holdTtlMs": null, "chartUpdate": { "behind": false, "canAutoUpdate": false, "chartVersionAt": "2026-09-28T14:05:00.000Z" } } ``` `holdTtlMs: null` means the default 15-minute hold window applies. `chartUpdate.chartVersionAt` is when the chart version this event runs on was saved, or `null` when that isn't known. ## Update metadata and section state **PATCH /v1/events/:key**. Authentication: Secret key or dashboard session Send only the fields to change: ```json { "name": "Opening Night (Rescheduled)", "startsAt": 1767290400000, "venue": "Main Hall", "externalRef": "show_8271", "currency": "GBP", "sectionStates": { "sec-balcony": "hidden", "sec-stalls": "open" } } ``` `startsAt`, `venue`, `externalRef`, and `currency` can be cleared with `null`. `description`, `endsAt`, `timezone`, and `locale` can also be updated or cleared. Section and zone states are `open`, `closed`, or `hidden`; unknown ids return `422`. An empty patch returns `400 nothing_to_update`. For timed and demand-triggered section release, use [inventory and availability](/server-api/inventory). ## Event poster The poster is the image on the event's share card when a link to it is posted in chat or social apps. Without one, SeatLayer draws a card from the event name and chart. **PUT /v1/events/:key/poster**. Authentication: Secret key or dashboard session Send the raw image bytes as the request body, not multipart or base64: ```bash curl -sX PUT "https://api.seatlayer.io/v1/events/opening-night/poster" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: image/jpeg" \ --data-binary @poster.jpg ``` The response is the updated event, `{"meta": {...}}`, with a new `posterPath`. Each upload gets a new URL, so share previews that cached the old image pick up the new one. | Limit | Value | Error | |---|---|---| | Size | Up to 5 MB | `413 image_too_large` | | Format | PNG, JPEG, or WebP, read from the file itself; the `content-type` header is ignored | `415 unsupported_image` | | Dimensions | Up to 8,000 px per side and 40 megapixels | `422 image_dimensions_unsupported` | | Body | Must not be empty | `400 empty_body` | **DELETE /v1/events/:key/poster**. Authentication: Secret key or dashboard session Removes the poster and returns the event to the generated card. An event with no poster answers `404 no_poster`. ## Event Configuration binding An Event Configuration is a versioned production setup for one chart: stage, screens, barriers, camera platforms, and the seats they take off sale. It is authored, reviewed, and published in the dashboard, then bound to an event. Binding to an event is available through this API. > **Enabled per account** > > Event Configurations are a module that SeatLayer turns on per account. > Without it, binding a configuration answers `403 module_not_enabled`. Reading > a binding and detaching one work on every account. **GET /v1/events/:key/event-configuration**. Authentication: Secret key or dashboard session ```json title="200 OK" { "configuration": { "id": "ec_4f1a…", "version": 3 }, "revision": 2, "changedBy": "…", "changedAt": 1767200000000, "audit": [ { "id": "…", "from": null, "to": { "id": "ec_4f1a…", "version": 3 }, "revision": 1, "actor": "…", "createdAt": 1767100000000 } ] } ``` `configuration` is `null` when nothing is bound. `audit` lists every attach, replace, and detach. **PUT /v1/events/:key/event-configuration**. Authentication: Secret key or dashboard admin ```json title="Bind version 4" { "expectedRevision": 2, "configuration": { "id": "ec_4f1a…", "version": 4 } } ``` Send `"configuration": null` to detach. `expectedRevision` must match the current `revision`, so two operators cannot overwrite each other. - Only a `published` version can be bound (`409 version_not_published`). - The version must match the event's chart revision (`409 incompatible_configuration` with `issues`). - Seats the configuration takes off sale must be free. If any is held or booked, nothing changes (`409 configuration_inventory_seat_conflict`). - A stale `expectedRevision` answers `409 configuration_binding_conflict` with the current revision. Newly killed seats send `seat.blocked`; seats freed by a replaced or detached configuration send `seat.released`. While a configuration is bound, [updating the event's chart](#update-an-event-to-the-latest-chart) answers `409 event_configuration_bound`. ## Ticket releases and on-sale waves Ticket releases are ordered, event-scoped price windows. Each release can have a start/end time, category scope, quota, and buyer action. Only `buy` releases price SeatLayer holds and consume quota; `apply` and `invoice` link to the organizer's HTTPS workflow and require `actionUrl`. **GET /v1/events/:key/releases**. Authentication: Secret key or dashboard session The response returns releases in `position` order. Quota-bearing releases also include live `consumed` and `remaining` values. `remaining: null` means the release has no quota. **PUT /v1/events/:key/releases**. Authentication: Secret key or dashboard session `PUT` replaces the entire ordered list; it is not a per-release patch. Send at most 12 releases. Omit `id` for a new release and preserve returned IDs when editing, because booked and held inventory records the release that priced it. List order becomes the dense `position` order returned by the server. ```json title="Replace the release list" { "releases": [ { "name": "Early bird", "price": 18, "previousPrice": 22, "quota": 200, "startsAt": 1786530600000, "endsAt": 1787135400000, "action": "buy" }, { "name": "Release 2", "price": 22, "action": "buy" } ] } ``` Prices are integer major currency units, matching chart category prices. A `categoryKey` must exist in the event chart and cannot name a tiered category; SeatLayer never applies two independent price authorities to one selection. `startsAt` and `endsAt` are epoch milliseconds. An unscoped release applies to every non-tiered category. Whole-list replacement is last-write-wins and does not support automatic retry or optimistic concurrency. Serialize release editors per event, read the current list before editing, and confirm the returned list before accepting a write as complete. **POST /v1/events/:key/releases/:id/close**. Authentication: Secret key or dashboard session Closing sets the release's end time to now and returns the full list. It does not delete the release or erase pricing provenance from existing holds and bookings. Closing an already closed release is safe, but mutations have no automatic-retry contract. ## Update an event to the latest chart Publishing a changed chart never updates an event on its own. Every event keeps the map it was created or last updated with until you update it, and the publish response names each linked event that is still on an older map in `linkedEvents`, with its booked, held and blocked counts and an `updatePath`: `safe` (nothing to review) or `reviewed` (existing bookings, holds and blocks are kept). An event whose `reason` is `configuration_bound` or `group_locked` is updated from its event configuration or season instead. `refreshedEvents` is always `0`. To move an event onto the latest published chart, call the explicit route per event: **POST /v1/events/:key/update-chart**. Authentication: Secret key or dashboard session ```bash curl -sX POST \ "https://api.seatlayer.io/v1/events/opening-night/update-chart" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` If the update would drop private channel assignments, inspect the returned channel summary and repeat deliberately with an audit reason: ```json { "acknowledgeDroppedAssignments": true, "reason": "Venue reconfiguration approved by operations" } ``` If a booked or held label would disappear, the update fails atomically: ```json { "error": "seats_would_vanish", "code": "seats_would_vanish", "missingLabels": ["A-12"], "message": "The update would remove booked or held seats." } ``` Archived and deleted events are not mutable. Never work around a conflict by recreating the event; reconcile the affected chart labels and retry. > **Events in a Season are protected** > > A metadata update, archive, or delete that would break an active Season or > its published Plan answers `409 season_event_locked`. Change the event > through the [Season workflow](/server-api/seasons/) instead. ## Lifecycle | Operation | Route | Buyer sales | Organizer reads/actions | |---|---|---|---| | Close | `POST /:key/close` | Stopped | Available | | Reopen | `POST /:key/reopen` | Resumed | Available | | Archive | `POST /:key/archive` | Stopped | Reports remain available | | Restore | `POST /:key/unarchive` | Stays stopped | Available again | | Soft delete | `DELETE /:key` | Stopped | Removed from normal event list | Use close for a reversible pause. Archive after the event ends; SeatLayer writes a durable inventory snapshot and keeps the live store for reports. Delete is a soft delete rather than immediate physical erasure. Restoring an archive never resumes buyer sales implicitly: it returns the event to the active organizer list with sales paused. Call `reopen` afterwards to run the normal readiness gate and resume sales deliberately. Supported application transitions are: ```text active -> closed -> active active or closed -> archived archived -> active (sales remain paused) active, closed, or archived -> deleted ``` ## Errors to handle | Status | Code | Action | |---|---|---| | `402` | `insufficient_credits` | Top up before creating a new live event | | `404` | `chart_not_found` / `not_found` | Check org, workspace, and resource id | | `409` | `event_exists` | Your workspace already has an event with this slug: use a new slug or replay the original idempotent request | | `409` | `seats_would_vanish` | Reconcile protected labels | | `409` | `season_event_locked` | Amend through the Season workflow or wait until the Season no longer owns the Event | | `422` | `chart_not_published` | Publish the chart first | | `422` | `invalid_event_inventory` | Fix the chart validation issue | | `422` | `workspace_not_found_or_disabled` | Select or enable the workspace | | `503` | `event_provisioning` | The event's pin and inventory are still landing behind a dashboard `async` create; retry after the `Retry-After` seconds | ## Verification - [ ] Event creation has a stable idempotency key. - [ ] Host records store the returned event key and workspace id. - [ ] Test and live resources use matching secret-key modes. - [ ] The event's currency, name, start time, and external reference are checked. - [ ] Chart updates are tested with booked and held inventory. - [ ] Close, archive, and delete are explicit operator actions. Continue to [inventory](/server-api/inventory), [booking](/server-api/booking), and [reports](/server-api/reports). # Hold seats from your server > Reserve seats by label from your backend with a secret key, then book or release them before the hold expires. Source: https://docs.seatlayer.io/server-api/holds/ · Markdown: https://docs.seatlayer.io/server-api/holds/index.md **POST /v1/events/:eventKey/hold**. Authentication: Secret key Most holds come from a buyer picking seats in the browser, through the [Buyer SDK hold and checkout handoff](/buyer-sdk/holds-and-checkout/). This endpoint is for the ones that do not: a phone or box-office order, an invoice reserved while payment authorizes, an allocation held for a partner. Your backend reserves the seats itself, then [books](/server-api/booking/) or releases them. A hold is not a sale, and holding costs nothing: only a confirmed sold seat consumes a credit under [per-sold-seat pricing](https://seatlayer.io/pricing/). New to the model? [How seat holds work](/server-api/how-seat-holds-work) covers the hold window, two-buyers-one-seat concurrency, expiry, and idempotent confirmation before you reach for the endpoint below. > **Which hold do I want?** > > If a buyer is choosing seats on screen, the SDK already holds them and hands > your backend an opaque `holdId`. Use that. Reach for this endpoint when there > is no browser in the loop. ## Request ```http title="HTTP request" POST /v1/events/summer-gala-2026/hold HTTP/1.1 Host: api.seatlayer.io Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "labels": ["STALLS-A-12", "STALLS-A-13"], "ttlMs": 900000 } ``` | Field | Type | Required | Description | |---|---|---:|---| | `labels` | `string[]` | Yes* | Seat labels to reserve. | | `selections` | `object[]` | Yes* | Instead of `labels`, when you need a tier or a quantity: `{ label, tierId?, quantity? }`. Required for a variable-occupancy object such as a shared table or a GA area. | | `ttlMs` | `number` | No | Requested hold window. For this trusted server endpoint it overrides the event duration; when omitted, the event duration and then the 15-minute default apply. Clamped to 60 minutes. | | `replaceHoldId` | `string` | No | Atomically replace an existing hold with this new selection. | > **Prices are ours, not yours** > > Prices, tiers, currency and occupancy limits are resolved from the event's own > published chart. Anything you send about money is ignored. Build your order > total from the `items` in the response. ## Responses ### 201 Held Every requested seat is now held for you until `expiresAt`. ```json title="201 response" { "ok": true, "holdId": "hold_01J8F2A7MRQ4", "expiresAt": 1785312452577, "items": [ { "label": "STALLS-A-12", "categoryKey": "stalls", "tierId": null, "unitPrice": 7500, "currency": "GBP", "quantity": 1 } ] } ``` ### 409 Conflict Holds are all-or-nothing. If any seat is unavailable, nothing is held. ```json title="409 response" { "error": "conflict", "conflicts": [{ "label": "STALLS-A-13", "status": "held" }] } ``` A closed event answers `409` with `"error": "event_closed"`. ### 422 Invalid selection A label that does not exist, is hidden, or carries a quantity outside the object's occupancy rules. Nothing is held. ```json title="422 response" { "error": "invalid_selection" } ``` ## Releasing **POST /v1/events/:eventKey/release**. Authentication: Secret key Release a hold you no longer need immediately rather than waiting for it to expire. Common cases include an abandoned checkout, a declined card, or a cancelled phone order. ```http title="HTTP request" POST /v1/events/summer-gala-2026/release HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "labels": ["STALLS-A-12", "STALLS-A-13"], "holdId": "hold_01J8F2A7MRQ4" } ``` ```json title="200 response" { "ok": true, "released": ["STALLS-A-12", "STALLS-A-13"] } ``` ## Full flow ```js title="server/reserve.js" const base = "https://api.seatlayer.io/v1/events/summer-gala-2026"; const headers = { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", }; // 1. Reserve the seats. const held = await fetch(`${base}/hold`, { method: "POST", headers, body: JSON.stringify({ labels: ["STALLS-A-12", "STALLS-A-13"] }), }); if (held.status === 409) return seatsNoLongerAvailable(); const { holdId, items } = await held.json(); // 2. Charge from OUR prices, never the caller's. const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0); const payment = await charge(total); // 3. Book it, or hand the seats straight back. if (payment.ok) { await fetch(`${base}/book`, { method: "POST", headers, // Pin the seats you charged for: `409 hold_changed` means the buyer // changed seats after this hold was priced. Refund; do not retry. body: JSON.stringify({ holdId, labels: items.map((item) => item.label), bookingRef: payment.orderId }), }); } else { await fetch(`${base}/release`, { method: "POST", headers, body: JSON.stringify({ labels: ["STALLS-A-12", "STALLS-A-13"], holdId }), }); } ``` > **Holds expire on their own** > > If your backend dies mid-checkout, the seats return to sale when the hold > lapses. Releasing is an optimization for the buyer behind you, not a > correctness requirement. ## Let us pick the seats **POST /v1/events/:eventKey/best-available**. Authentication: Secret key When the caller does not care *which* seats, only how many, such as for a phone order or a "best 4 together" request, ask for best available instead of naming labels. The picker is the same one the buyer widget uses, so a phone order and a web order get the same answer for the same inventory. ```http title="HTTP request" POST /v1/events/summer-gala-2026/best-available HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "qty": 4, "categoryKey": "stalls" } ``` | Field | Type | Required | Description | |---|---|---:|---| | `qty` | `number` | Yes | How many to pick. Clamped to the server maximum rather than rejected. | | `categoryKey` | `string` | No | Restrict to one price category. | | `zoneId` | `string` | No | Restrict to one zone. An unknown zone answers `422`, never silently ignored. | | `ttlMs` | `number` | No | Requested hold window, same contract as `/hold`. | | `nearLabels` | `string[]` | No | 1 to 10 labels to pick close to, such as a buyer's original seats. The pick stays in one contiguous group and prefers the same rows. | | `preferAisle` | `boolean` | No | Prefer a group with at least one aisle seat: a row end, or a seat beside a gap. Seats behind the stage still rank last. Off by default. | The response is a hold: `holdId`, `expiresAt`, the chosen `labels`, and priced `items`. A `409` with `reason: "sold_out"` or `"not_enough_together"` means the request could not be satisfied. Treat that as a normal outcome, not an alert. ### Book without holding first **POST /v1/events/:eventKey/best-available-book**. Authentication: Secret key For box-office and phone sales where payment is already taken, pick and book in one call. `bookingRef` is required so the sale can be reconciled against your own order. ```http title="HTTP request" POST /v1/events/summer-gala-2026/best-available-book HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "qty": 2, "bookingRef": "phone-1183" } ``` > **Prefer this over hold-then-book** > > Doing it as two calls leaves inventory stranded until the hold expires > whenever the second call fails. One call has no gap to fail in. ### Set seats aside for a longer offer **POST /v1/events/:eventKey/best-available-block**. Authentication: Secret key A buyer hold lasts at most an hour. When you need to offer replacement seats to a customer who may take a day to answer, for example after a paid order had to move, pick the seats and block them instead. The block ends by itself at `releaseAt`. ```http title="HTTP request" POST /v1/events/summer-gala-2026/best-available-block HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "qty": 2, "nearLabels": ["C-14", "C-15"], "releaseAt": 1767290400000, "blockRef": "reseat-order-8812" } ``` ```json title="200 OK" { "ok": true, "labels": ["C-16", "C-17"], "items": [ { "label": "C-16", "objectId": "seat-c-16", "objectType": "seat", "categoryKey": "stalls", "tierId": null, "unitPrice": 60, "currency": "EUR", "quantity": 1 }, { "label": "C-17", "objectId": "seat-c-17", "objectType": "seat", "categoryKey": "stalls", "tierId": null, "unitPrice": 60, "currency": "EUR", "quantity": 1 } ], "placements": [ { "label": "C-16", "sectionLabel": null, "rowLabel": "C", "seatLabel": "16" }, { "label": "C-17", "sectionLabel": null, "rowLabel": "C", "seatLabel": "17" } ], "releaseAt": 1767290400000 } ``` It takes the same `qty`, `categoryKey`, `zoneId`, and `nearLabels` fields as best available, plus two required fields: | Field | Type | Description | |---|---|---| | `releaseAt` | `number` | Epoch ms when the block ends. At least 1 minute and at most 72 hours from now; otherwise `400 releaseAt_required` | | `blockRef` | `string` | Your reference for the offer, up to 160 characters, recorded in the event log; otherwise `400 blockRef_required` | To confirm, call [`POST /v1/events/:eventKey/book`](/server-api/booking/) with the `labels`, your `bookingRef`, and the same `blockRef` (no `holdId`). To withdraw the offer early, [unblock](/server-api/blocking/) them. It shares the server hold budget. ## Keep a hold alive **POST /v1/events/:eventKey/extend**. Authentication: Secret key When an order takes longer than the checkout window, for example while an invoice awaits approval or a caller finds their card, extend the hold rather than releasing and re-holding. Releasing first hands the seats to whoever is racing for them in between. ```http title="HTTP request" POST /v1/events/summer-gala-2026/extend HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "holdId": "h_9f2c…", "ttlMs": 600000 } ``` A hold that is already gone, expired, or at its renewal cap answers `409` with `error: "cannot_extend"`. The hold cannot be recovered; the buyer must pick again. On this trusted server endpoint, an explicit `ttlMs` takes priority over the event duration. Extensions share the hold budget and the same server-side TTL ceiling, so a hold cannot be renewed indefinitely. ## Rate limits Server holds are budgeted per secret key rather than per IP, because one backend legitimately speaks for every buyer on your platform: 600 requests per minute, shared by hold, extend, and the three Best Available routes. Exceeding the budget answers `429` with `Retry-After` and a `retryAfterSeconds` hint. Releases do not consume the budget. Returning inventory is never rate-limited. See [rate limits](/server-api/rate-limits/) for every budget and the response headers, and [concurrency and performance](/platform/concurrency-performance/) for how simultaneous holds on the same seats are resolved. # How seat holds work > Why reserved-seat ticketing needs temporary holds, how concurrency and expiry are handled, and how SeatLayer implements each part. Source: https://docs.seatlayer.io/server-api/how-seat-holds-work/ · Markdown: https://docs.seatlayer.io/server-api/how-seat-holds-work/index.md A temporary seat hold is a short-lived lock on specific inventory, created the moment a buyer selects seats and released automatically when it expires or when the buyer cancels. While the hold is active nobody else can buy those seats, and the server converts it into a permanent booking once payment succeeds. Every reserved-seating system has this mechanism, named or not, because it is what stops two people paying for the same seat. This page explains the model generally, then points at the [seat map API](https://seatlayer.io/developers/) endpoints that implement each part. ## Why a hold is needed at all Selling assigned seats has a gap between *choosing* and *paying*. Payment can take seconds or minutes because of card authentication, wallet redirects, or a buyer finding their card. Without a hold, the system has two bad options: - **Sell optimistically**, and discover at capture time that the seat went to someone else. The buyer is charged or challenged for a seat they cannot have, and you own the refund. - **Lock at selection with no expiry**, and one abandoned cart removes a seat from sale permanently. A hold resolves both: inventory is protected for a bounded window, and the window ends by itself if the sale does not complete. A hold is explicitly **not** a sale. Nothing is owed and no ticket exists yet. ## The four properties that matter ### 1. A bounded window A hold must carry an expiry, and the server must own it. If the browser decides when a hold ends, a stopped clock or a closed laptop leaks inventory. The buyer UI should count down from a server-supplied timestamp rather than a locally invented duration. In SeatLayer, a hold returns an absolute `expiresAt`. The default window is 15 minutes. For browser buyers, an event-level duration takes priority, followed by a per-hold `ttlMs`, then the default. Trusted server calls may deliberately override the event duration. Both configurable values are clamped server-side: per-hold values are between one second and 60 minutes, and event configuration is between one and 60 minutes. Changes apply to future holds, not to holds already active. See [hold expiry](/buyer-sdk/hold-expiry) for the precedence rules. ### 2. Concurrency control: two buyers, one seat A "check if free, then mark held" sequence is a race: two requests can both read `free` before either writes. Correctness requires that the decision and the write happen as one atomic step, which in practice means either a database transaction with the right isolation and row locks, a compare-and-set on seat state, or handling every transition for an event one at a time. The observable contract you want is simple: when two requests contend for one seat, exactly one succeeds and the other receives a defined failure. The request must not be silently overwritten or left ambiguous. SeatLayer gives you that contract: the [concurrency test](/platform/concurrency-performance/) shows the result with 10,000 buyers on one event. For hold, book, block and release, exactly one competing request wins. Two requests cannot both move the same seat out of `free`; one succeeds and the other sees the new state. The loser receives `409` with a `conflicts` array naming each contested label and its current status, so your interface can remove exactly those seats and keep the rest of the selection intact. ### 3. Expiry and release Holds end three ways: the buyer completes the purchase, the application releases them deliberately (an abandoned cart, a removed seat, a changed selection), or the window elapses. All three must return the inventory to sale, and expiry should be treated as an ordinary checkout state rather than an error. Abandoned carts are common. Design the buyer experience for the boundary: clear the stale selection, explain what happened, and let the buyer choose again. In SeatLayer, releasing is a first-class operation, and releases are never rate-limited. Returning inventory must remain inexpensive. ### 4. Confirming: from hold to booking Booking converts the temporary claim into permanent inventory. Two rules keep this safe. **The client must not be trusted with the price.** Hand off only the hold identity and recompute the amount server-side from the hold's own records. A browser that can name its own total is a browser that can pay less than the seats cost. **Retries must be idempotent.** Networks time out, webhooks repeat, and workers retry. A repeated booking call must not create a second sale. Use one immutable reference per business order, and on an uncertain response reconcile and retry with *the same* reference rather than generating a new one. SeatLayer's booking call takes a `bookingRef` for exactly this purpose. A booking attempted under a *different* reference against already-booked seats returns `409`, as do an expired or invalid hold and missing inventory. Idempotency protects repeated or unknown delivery; it does not make unavailable inventory available, so a genuine `409` should not be retried in a loop. Note that `bookingRef` deduplicates only the SeatLayer booking. Your order-creation and payment operations still need their own idempotency. ## Implementation sequence 1. The buyer selects seats; the system creates a hold and returns an opaque hold id with a server-owned expiry. 2. Your interface counts down from that expiry and keeps the selection editable. 3. At checkout, your server takes the hold id, recomputes the amount from authoritative records, and runs payment. 4. On success it books the hold under a stable reference; on failure or abandonment it releases, or simply lets the window lapse. 5. Every conflict path, including a contested seat, expired hold, or repeated delivery, has a defined answer rather than an exception. ## Where this is documented - [Hold seats from your server](/server-api/holds): the endpoint, request and response shapes, releasing, and keeping a hold alive. - [Holds and checkout handoff](/buyer-sdk/holds-and-checkout): the buyer-side flow, where the SDK creates the hold for you. - [Hold expiry](/buyer-sdk/hold-expiry): TTL precedence, countdowns, and recovery at each boundary. - [Idempotency and conflicts](/server-api/idempotency-and-conflicts): safe retries, the `409` taxonomy, and the payment-safe state machine. - [Booking](/server-api/booking): turning a hold into an inventory booking. # Idempotency and conflicts > Safely retry booking requests and build a deliberate recovery path for atomic inventory conflicts. Source: https://docs.seatlayer.io/server-api/idempotency-and-conflicts/ · Markdown: https://docs.seatlayer.io/server-api/idempotency-and-conflicts/index.md Networks time out, payment webhooks repeat, and workers retry jobs. Use one immutable `bookingRef` for one business order so repeated booking calls cannot create a second sale. ## Safe booking retries ```js title="server/book-order.js" async function bookOrder({ eventKey, holdId, orderId }) { return fetch( `https://api.seatlayer.io/v1/events/${eventKey}/book`, { method: "POST", headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ holdId, bookingRef: orderId, }), }, ); } ``` Calling this again with the same `orderId` is safe. If the first request already booked the seats, an idempotent replay succeeds without a second inventory or credit transition: ```json title="200 replay response" { "ok": true, "booked": [] } ``` > **A timeout is an unknown result** > > Do not generate a new order or `bookingRef` after a timeout. Reconcile your existing order and retry with the same reference. ## The Idempotency-Key header Calls that create something or move money take an `Idempotency-Key` header. Send a new key for each new action, and the same key when you retry it: - Same key, same request: you get the first answer again, and nothing happens twice. Keys are kept for 30 days. - Same key, different request: `409 idempotency_conflict`. - Same key while the first call is still running: `409 idempotency_in_progress` with `Retry-After`. Wait and retry. The [operation support matrix](/server-api/operation-support/) lists which calls replay this way. ### Season orders need a key Three calls on a season order refuse to run without one, with `400 idempotency_key_required`: | Call | Route | |---|---| | Refund | `POST /v1/seasons/:key/orders/:orderId/refund` | | Cancel without a refund | `POST /v1/seasons/:key/orders/:orderId/cancel` | | Cancel one date | `POST /v1/seasons/:key/orders/:orderId/dates/:eventKey/cancel` | Each moves money or takes tickets away, so a lost response must never turn into a second refund. The dashboard runs the same actions under the same names. A refund's id (`rfo_…`) comes from your account and the key, so the same key sent from the dashboard and from your server reaches one refund. A refund or date cancel that is still settling answers `202`; retry with the same key to read the result. See the [Seasons API](/server-api/seasons/#holders-orders-renewal-emails-and-reports). ## What counts as a conflict A booking under a different reference, an expired or invalid hold, missing inventory, or an invalid capacity allocation returns `409`. ```json title="409 response" { "error": "conflict", "conflicts": [ { "label": "A-12", "status": "booked" } ] } ``` SeatLayer applies inventory mutations atomically. If any required item conflicts, nothing in that request is newly booked. ## Recovery by operation | Operation | Likely reason | Correct response | |---|---|---| | Buyer hold | Someone else acquired inventory after render | Refresh and let the buyer choose again | | Booking held items | Hold expired or inventory no longer belongs to it | Void/refund as required, then reselect | | Platform direct booking | Item is not free | Surface the conflicting labels to the operator | | Blocking | Item is already held, booked, or blocked | Skip intentionally or ask the operator | Do not retry an actual `409` in a loop. Idempotency protects unknown/repeated delivery; it does not make unavailable inventory available. ## Payment-safe state machine If your provider captures before booking, replace the void with the appropriate refund or reversal. Persist enough state for a background worker or webhook reconciliation job to finish an uncertain request. ## Why concurrency is safe For hold, book, block, and release, exactly one competing request wins. Two requests cannot both move the same seat out of `free`; one succeeds and the other sees the new state. Your application still needs idempotency for its own order creation and payment operations. SeatLayer’s `bookingRef` does not automatically deduplicate your checkout route. ## Test cases - [ ] Send the same booking request twice; only one sale is recorded. - [ ] Simulate a lost first response, then retry with the same reference. - [ ] Try a different reference against the already booked seats; receive `409`. - [ ] Expire the hold before booking; no partial inventory changes. - [ ] Compete for the same seat from two browser sessions. - [ ] Ensure payment compensation runs only for a real conflict. - [ ] Ensure background retries preserve the original reference. Read the [booking endpoint](/server-api/booking) and the [complete checkout example](/examples/complete-checkout). # Inventory and availability > Control event hold duration, section visibility, scheduled release, and demand-triggered inventory. Source: https://docs.seatlayer.io/server-api/inventory/ · Markdown: https://docs.seatlayer.io/server-api/inventory/index.md Use event availability rules for whole sections and zones. Use blocking for individual production holds. Both update mounted buyer and operator surfaces through SeatLayer's live event stream, so a [mounted seating chart](/buyer-sdk/seating-chart/) redraws without a reload. ## Section states or availability rules? | Need | Use | |---|---| | Open, close, or hide a section immediately | `PATCH /v1/events/:key` with `sectionStates` | | Reveal a section at a fixed time | Availability rule with `mode: "timed"` | | Reveal after the on-sale house reaches a sold percentage | Availability rule with `mode: "threshold"` | | Take specific seats off sale | [Blocking](/server-api/blocking) | ## Read availability rules **GET /v1/events/:key/availability**. Authentication: Secret key, dashboard session, or event:view manage token ```bash curl -s "https://api.seatlayer.io/v1/events//availability" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` The response is `{"rules": {...}}`. Rules already revealed are deleted rather than retained as completed history; use the audit log for their history. ## Replace availability rules **POST /v1/events/:key/availability**. Authentication: Secret key, dashboard session, or event:block manage token This endpoint replaces the event's complete rule set. The `rules` object is required: a missing or malformed object is rejected and never interpreted as a clear. Any section or zone id absent from `rules` is on sale. Send the explicit `{"rules": {}}` to clear every rule. ```bash title="Open the balcony at 80% sold" curl -sX POST "https://api.seatlayer.io/v1/events//availability" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "rules": { "sec-boxes": { "mode": "hidden" }, "sec-lounge": { "mode": "closed" }, "sec-balcony": { "mode": "timed", "revealAt": 1767204000000 }, "zone-upper": { "mode": "threshold", "thresholdPct": 80 } } }' ``` SeatLayer derives member labels from the event's pinned chart for recognized section, zone, and bookable-object ids. Send ids from your chart or section metadata rather than maintaining a separate label list. When an event map is refreshed, active rule memberships are re-derived inside the same inventory update before the refreshed map is exposed. New inventory under a recognized target inherits its rule. If a rule's id no longer resolves, only labels that still exist remain restricted rather than retargeting the rule from a stale label list. | Mode | Buyer result | Ends when | |---|---|---| | `hidden` | Section inventory is not shown | You replace or clear the rule | | `closed` | Visible but unavailable | You replace or clear the rule | | `timed` | Hidden | Epoch-ms `revealAt` is reached | | `threshold` | Hidden | Visible on-sale inventory reaches `thresholdPct` sold | `timed` requires a finite epoch-ms `revealAt`; `threshold` requires a finite `thresholdPct` from 0 through 100. Invalid rules reject the whole replacement, so a malformed window can never silently reveal previously restricted inventory. Hidden and closed inventory is excluded from the sold-percentage denominator, so held-back capacity cannot prevent its own reveal. > **Reveals are one-way** > > When a timed or threshold condition fires, SeatLayer deletes that rule and > records a `reveal` action. Create a new rule if the section must be held back > again. ## Set the buyer hold window **GET /v1/events/:key/hold-ttl**. Authentication: Secret key, dashboard session, or event:view manage token **POST /v1/events/:key/hold-ttl**. Authentication: Secret key, dashboard session, or event:block manage token ```json { "holdTtlMs": 600000 } ``` The per-event override is clamped to 1–60 minutes, takes priority over a browser widget's `ttlMs`, and applies to future holds. Trusted server hold calls may still request a deliberate per-hold override. Send `{"holdTtlMs": null}` to use the caller's requested duration, or the 15-minute default when none is sent. Existing holds keep their current expiry unless extended separately. ## Immediate section states Use event metadata for a direct state change: ```bash curl -sX PATCH "https://api.seatlayer.io/v1/events/" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "sectionStates": { "sec-stalls": "open", "sec-balcony": "closed", "sec-boxes": "hidden" } }' ``` `open` removes the holdback for that id. `closed` keeps the section visible but off sale. `hidden` removes its buyer-visible inventory. ## Resale Resale lets a booked seat be sold again on the same event. It moves seats, not money: SeatLayer records which booking holds each seat, and your system decides the resale price, takes the new buyer's payment, and refunds or pays the original holder. How a listed seat behaves: - Only booked seats can be listed. A free seat is already on sale, and a held or blocked seat cannot be listed. - The original booking stays in place while the seat is listed. Booked counts, reports, and sold-out checks still count it as booked. - Buyer and operator seat maps show it with the status `resale`. Buyers can select it; Best Available never picks it. - A listing is bought whole. Seats listed under one `listingId` must be held together, so a buyer cannot take half of a pair. - If a buyer's hold on a listed seat is released or expires, the seat goes back on the resale market, not to free. - When the new buyer's booking succeeds, the seat moves to the new `bookingRef` and leaves the market. It never passes through `free`, so no third buyer can take it in between. > **Not available for SeatLayer checkout** > > Resale is refused with `409 resale_unsupported_on_seatlayer_checkout` on > Hosted Ticketing events and for seats that carry a live SeatLayer ticket. A > resale there would leave the first ticket valid at the door. Use resale on > Platform events where your system issues tickets. ### List seats for resale **PUT /v1/events/:key/actions/put-up-for-resale**. Authentication: Secret key, dashboard session, or event:block manage token ```bash curl -sX PUT "https://api.seatlayer.io/v1/events//actions/put-up-for-resale" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "objects": ["A-11", "A-12"], "listingId": "resale-order-42" }' ``` ```json title="200 OK" { "ok": true, "listed": ["A-11", "A-12"], "listingId": "resale-order-42" } ``` `objects` takes 1 to 500 object labels. `listingId` is optional, up to 120 characters. Leave it out and SeatLayer creates an `rsl_…` id for the call. Listing a seat that is already listed keeps its existing listing id, so a retry cannot regroup seats a buyer may already be holding. | Status | `error` | Meaning | |---|---|---| | `400` | `objects_required` | No usable labels in `objects` | | `404` | `not_found` | Unknown event, or it belongs to another account or mode | | `409` | `not_booked` | At least one label is not booked; `conflicts` lists each one | | `409` | `order_edit_in_progress` | An order edit is changing one of these seats; retry shortly | | `409` | `resale_unsupported_on_seatlayer_checkout` | See the caution above | | `422` | `invalid_listing_id` | `listingId` is empty or longer than 120 characters | ### Take seats off the market **PUT /v1/events/:key/actions/remove-from-resale**. Authentication: Secret key, dashboard session, or event:block manage token ```bash curl -sX PUT "https://api.seatlayer.io/v1/events//actions/remove-from-resale" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "objects": ["A-11", "A-12"] }' ``` ```json title="200 OK" { "ok": true, "unlisted": ["A-11", "A-12"], "listingIds": ["resale-order-42"] } ``` The original booking is not touched. A seat that a buyer is holding right now stays listed until that hold ends, and is left out of `unlisted`. Call again after the hold is released or expires. ### Read current listings **GET /v1/events/:key/resale-listings**. Authentication: Secret key, dashboard session, or event:view manage token ```json title="200 OK" { "listings": [ { "label": "A-11", "listingId": "resale-order-42", "bookingRef": "order_42", "held": false }, { "label": "A-12", "listingId": "resale-order-42", "bookingRef": "order_42", "held": true } ] } ``` `bookingRef` is the booking the seat will leave when it sells. `held: true` means a buyer is holding it and nobody else can select it. ### Count the market **GET /v1/events/:key/resale-summary**. Authentication: Secret key, dashboard session, or event:view manage token ```json title="200 OK" { "listed": 12, "listings": 9, "held": 2, "resold": 3, "since": 1790121600000 } ``` `listed` is the seats on the market now, `listings` how many listings they form, and `held` how many of them a buyer is holding. `resold` counts the seats that changed hands through resale at or after `since`, in milliseconds since the epoch. Leave `since` out for the last 24 hours, or send your own midnight to count today. A `since` in the future, negative or not a whole number is refused with `400 invalid_since`. ### Resale webhooks and credits | Webhook | Sent when | Payload | |---|---|---| | `object.resale.listed` | A put-up-for-resale call lists seats | `eventId`, `labels`, `listingId` | | `object.resale.unlisted` | A remove call takes at least one seat off | `eventId`, `labels`, `listingIds` | | `object.resale.sold` | A booking includes listed seats | `eventId`, `bookingRef`, `at`, `sold[]` with `label`, `listingId`, `previousBookingRef` | The new buyer's booking also sends the usual `seat.booked`. Use `previousBookingRef` from `object.resale.sold` to find the original order in your system and settle with its holder. Payloads are in the [webhook event reference](/webhooks/events/#resale). Listing and delisting use no credits. Reselling a seat does not use a second credit, because a seat is metered only the first time it is sold. See [credits](/billing/credits/). ## Operational safety - Fetch the current rules before replacing them. - Use one writer or optimistic application coordination to avoid overwriting a concurrent operator change. - Use epoch milliseconds in UTC and show the intended local time in your UI. - Verify the real chart id or zone id; do not invent ids from visible labels. - Test threshold behavior with hidden capacity excluded. - Keep payment and booking independent from visibility controls. Continue to [blocking](/server-api/blocking), [best available](/buyer-sdk/best-available), and [embedded control room](/platform/embedded-control-room). # Operation support matrix > Inspect every registered public operation, its schemas, auth, reliability, side effects, and release evidence across REST and seven server SDKs. Source: https://docs.seatlayer.io/server-api/operation-support/ · Markdown: https://docs.seatlayer.io/server-api/operation-support/index.md This is the support view for every public server operation, including preview operations. Every row is generated from the API contract itself, so a new operation cannot appear in the API without appearing here exactly once. > **Live release evidence** > > `live` means the stable operation has a verified public package release. The REST lane was checked > against the deployed production API. `RC` is reserved for a future candidate > that has not yet completed those public-artifact checks. Use the [machine-readable JSON](/operation-support-matrix.json) for release automation. Use [OpenAPI 3.1](/openapi.json) for client generation. Each operation links to its workflow guide. When a row changes support state, the [changelog](/changelog/) records the release that moved it. ## Reading the matrix - **Stable** classifies the operation identity and schema as a first-class, generated source contract. It does not by itself claim a deployment or package release. Use each REST/SDK lane's `live` or `RC` evidence for artifact availability; an operation with no live lane remains a candidate and must not be inferred from an older released version. Evidence is contract-revision specific: finding the same operation ID in an older package does not mean that package implements the request, response, enum, or error contract displayed in the current row. Routes absent from the matrix are not registered public server contracts. - **Operation support is not field-level backporting.** A `live` SDK lane says that operation exists in the verified artifact; compare the pinned package signature with the current schema for fields added later. Specifically, every `0.8.0` SDK has `createPerformanceGroup`, but its typed create request omits the newer REST `inclusionMode` field. Use REST/raw request for `flexible_dates` until a typed release adds it. - **Auth** states secret-key access and the exact `mse_` capability alternatives for the 26 operations that deliberately accept a browser manage token. - **Schemas** are named request and primary success schemas. `no body` is an explicit contract, not missing documentation. - **Reliability** distinguishes transient read/header-replay retries from single-attempt mutations and shows only the rate budgets runtime enforces. - **Side effects** name documented webhook events and durable audit surfaces. “No public side effect declared” does not exclude metrics, traces, caches, or internal diagnostic logging. - Expand **Error contract** for the exact operation status/code set. # Performance Groups API > Create fixed or flexible-date performance runs, mint browser access, inspect an atomic group hold, and commit it from a trusted backend. Source: https://docs.seatlayer.io/server-api/performance-groups/ · Markdown: https://docs.seatlayer.io/server-api/performance-groups/index.md Use this page for exact trusted Performance Group operations. Start with the [inventory model comparison](/start/inventory-models/) if you are deciding between an Event, Performance Group, and Season. Follow the [end-to-end integration](/integrations/performance-groups/) for the full transaction and the [buyer picker](/buyer-sdk/performance-groups/) for UI configuration. The Performance Groups API coordinates assigned-seat inventory for a run of two to eight compatible Events. Your existing checkout stays in control: you decide the package and price, take payment, create the order, and issue tickets. Use Performance Groups for one multi-date purchase, not a recurring subscription or flexible season membership. A renewable season catalogue with Plan publication and same-seat renewal is handled by the Seasons API instead. Every route on this page uses a matching `sk_test_…` or `sk_live_…` secret key. Keep that key on your server. Browser access uses the short-lived group token minted below and is consumed only by `PerformanceGroupPicker`. ## Choose REST or a server SDK The REST examples on this page are the canonical wire contract. Each official server SDK exposes the same trusted workflow through one resource; choose the package that matches your backend. Before adopting a package release, check the [operation support matrix](/server-api/operation-support/): **Live** means the published package was verified, while **RC** means the matching source has passed its release checks but a public package has not yet been verified. | Backend | Resource | |---|---| | Node.js | `seatlayer.performanceGroups` | | Python | `seatlayer.performance_groups` | | PHP | `$seatlayer->performanceGroups` | | Java | `seatlayer.performanceGroups()` | | Go | `client.PerformanceGroups` | | Ruby | `client.performance_groups` | | .NET | `client.PerformanceGroups` | The resource creates and manages the group, mints the buyer token, reads the trusted hold, and confirms the booking. It never gives a server SDK access to the browser-only `/pub/performance-groups/*` routes. > **REST field newer than the 0.8.0 typed create shapes** > > All seven `0.8.0` server SDKs contain all 13 Performance Group operations, > including create. Their typed create parameters support `name`, `eventKeys`, > and `externalRef`, but do not expose `inclusionMode`. Use this REST request or > the SDK's raw-request escape hatch for `flexible_dates` until a later typed > package adds the field. Omitting it still creates the default `fixed` group. ## API map | Task | Method and route | |---|---| | List or filter groups | `GET /v1/performance-groups` | | Create a run | `POST /v1/performance-groups` | | Read one run | `GET /v1/performance-groups/:groupKey` | | Discard an unused draft | `DELETE /v1/performance-groups/:groupKey` | | Activate / close it | `POST /:groupKey/activate` · `POST /:groupKey/close` | | Recover lifecycle progress | `GET /:groupKey/lifecycle/:operationId` | | Mint / list / revoke browser sessions | `/buyer-access-sessions` | | Inspect the opaque group hold | `GET /:groupKey/holds/:operationId` | | Commit inventory and recover booking | `POST /holds/:operationId/book` · `GET /bookings/:bookActionId` | The browser-only descriptor, chart, availability, hold, release, extension, and operation routes are SDK implementation details. Use `PerformanceGroupPicker`; do not call `/pub/performance-groups/*` from your application. To list groups, use `workspaceId`, `externalRef`, `state`, `limit`, and `cursor` as needed. The response is `{ performanceGroups, nextCursor }`; pass the opaque `nextCursor` back as `cursor` until it is `null`. `DELETE` succeeds only for a `draft`; once a group is active, close it and let existing operations drain instead of trying to replace its dates. ## Create a run ```http title="HTTP request" POST /v1/performance-groups HTTP/1.1 Authorization: Bearer sk_test_•••••••• Idempotency-Key: production-opening-weekend Content-Type: application/json { "name": "Opening weekend", "externalRef": "run_2026_opening_weekend", "eventKeys": ["ev_friday", "ev_saturday", "ev_sunday"], "inclusionMode": "flexible_dates" } ``` `name` is required and limited to 120 characters. `externalRef` is optional, stable within the organization, and limited to 128 characters. `eventKeys` must contain two to eight distinct compatible Events. `inclusionMode` is optional: | Value | Hold rule | |---|---| | `fixed` | Default. Every activated member must be included in each group hold. | | `flexible_dates` | The buyer SDK may hold one or more activated members when a partial seat is unavailable on other dates. | This policy is stored on the group and returned as `inclusionMode` in group, descriptor, operation, and trusted hold projections. It cannot be changed by a buyer request. Omitting it preserves the behavior of existing integrations. The server validates organization, workspace, mode, chart snapshot, inventory model, venue, timezone, currency, sale ownership, start times, and assigned-seat compatibility before it creates a `draft` group. `201` returns `{ performanceGroup }`, including chronological `performances` and `revision`. Use the same [idempotency key](/server-api/idempotency-and-conflicts/) when retrying a lost create response. `409` can mean an Event already belongs to another group or the external reference is already in use. Every compatibility refusal answers `error: "incompatible_performances"` and names the specific reason in `code`: `currency_mismatch`, `chart_mismatch`, `invalid_event_count`, and so on. It is the one create failure in this API where the two fields differ, so branch on `error` to recognise the umbrella and read `code` for what to correct. The status follows the reason: `404` when a performance is unknown, `422` for a malformed set, `409` for a field the performances cannot safely share. Refusals raised by the route itself (`invalid_name`, `external_ref_exists`) keep the usual `error === code` shape. ## Activate and close ```http title="activate" POST /v1/performance-groups/pg_opening_weekend/activate Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "expectedRevision": 1 } ``` Activation revalidates compatibility and locks the member Events against unsafe chart or lifecycle changes. A final response contains the active group. A recoverable `202` includes `Location`, `Retry-After`, the current group, and a `lifecycleOperation`; poll that location until it is terminal. Close uses the same body and recovery behavior: ```http title="close" POST /v1/performance-groups/pg_opening_weekend/close Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "expectedRevision": 2 } ``` Closing stops new group holds, lets accepted holds finish or expire, then releases the member locks only after drain proof. There is no reopen. Do not use close as a shortcut to change the dates in a buyer's existing offer. ## Mint browser access ```http title="mint group buyer access" POST /v1/performance-groups/pg_opening_weekend/buyer-access-sessions Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "allowedOrigin": "https://tickets.example.com", "includePublic": true, "expiresInSeconds": 1800, "maxQuantity": 4, "buyerRef": "buyer_8372" } ``` `allowedOrigin` and `includePublic` are required. The token applies to the whole active group and is checked on every browser request. `expiresInSeconds` is 60 to 43,200; `maxQuantity` is one to 100 or omitted; buyer and partner references are opaque values up to 120 characters. For private sales, pass `channelIdsByEvent` with an array for each member Event: ```json title="private-allocation-scope.json" { "allowedOrigin": "https://members.example.com", "includePublic": false, "channelIdsByEvent": { "ev_friday": ["chn_members"], "ev_saturday": ["chn_members"], "ev_sunday": ["chn_members"] } } ``` The `201` response reveals `token` only once, alongside `sessionId`, `expiresAt`, and `performanceGroupKey`. Store the session ID for audit/revocation, return only token and expiry to the browser, and never automatically retry a response whose token delivery is uncertain. Use `GET /v1/performance-groups/:groupKey/buyer-access-sessions?limit=50` to list non-secret session metadata and `DELETE /v1/performance-groups/:groupKey/buyer-access-sessions/:sessionId` to revoke a session immediately. ## Inspect the group hold The browser handoff supplies `operationId` and `holdId`. The server-inspection route is addressed by the operation identity: ```http title="inspect group hold" GET /v1/performance-groups/pg_opening_weekend/holds/pghop_01J8… Authorization: Bearer sk_test_•••••••• ``` The response contains `{ hold }` with one group hold identity, its common expiry, selection mode, inclusion mode, group revision, currency, and chronological `allocations`. For `fixed`, allocations contain every group member. For `flexible_dates`, they contain only the dates selected and held by the buyer. Each allocation includes its Event key/name/time and authoritative inventory line items. Child hold IDs never appear. Calculate the commercial order from this trusted allocation list, not from the browser's `performanceKeys`. Booking commits all selected allocations together. Inspect immediately before your payment decision. `404` means the group, operation, or hold is not available to this tenant/mode; `409 performance_group_integrity_error` is a support escalation, not a reason to charge or retry with altered IDs. ## Book one group hold ```http title="commit inventory" POST /v1/performance-groups/pg_opening_weekend/holds/pghop_01J8…/book Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "bookActionId": "pg-book:order_1842", "bookingRef": "order_1842" } ``` `bookActionId` and `bookingRef` are both required stable identifiers. Reuse the same pair for every retry of this commercial order. `200` with `booking.state: "booked"` is final. `202` with `booking.state: "book_pending"` includes a `Location` and `Retry-After`; poll `GET /v1/performance-groups/:groupKey/bookings/:bookActionId` until terminal. `409 book_failed` is terminal and may require payment compensation or support because a child performance can already have settled. Do not create a new action ID to force another booking attempt. The terminal booking response or booking poll is checkout authority. The current public operation registry declares no group-specific completion webhook, so do not delay fulfilment waiting for one. Reconcile underlying Event inventory through the documented [Event webhook catalog](/webhooks/events/) and deduplicate every occurrence. ## Error and recovery rules | Situation | Correct response | |---|---| | `403 buyer_access_mode_mismatch` | Use a test key for a test group and a live key for a live group. | | `404 not_found` | Treat as unknown/cross-tenant/mode-mismatched; never reveal more detail to a buyer. | | `409` lifecycle pending or revision conflict | Reload the group and follow the existing lifecycle operation; do not overwrite a revision. | | `incompatible_performances` (404/409/422) | Read `code` for the reason and correct the Event setup before creating a new group. | | `202` lifecycle or booking | Poll the supplied Location using `Retry-After`. | | Lost browser hold response | Let `PerformanceGroupPicker` recover the same operation. | | Lost server booking response | Retry only with the same `bookActionId` and `bookingRef`. | Next: [end-to-end integration](/integrations/performance-groups) and [Performance Group picker](/buyer-sdk/performance-groups). # Rate limits > Request budgets for the SeatLayer API, how each one is counted, the 429 response and headers, and how to back off safely. Source: https://docs.seatlayer.io/server-api/rate-limits/ · Markdown: https://docs.seatlayer.io/server-api/rate-limits/index.md SeatLayer limits a few high-traffic routes so one caller cannot starve others of inventory. Most Server API routes have no request budget. Each limit below is a fixed window: the count starts with the first request and resets when the window ends. ## Server API budgets | Budget | Limit | Counted per | Applies to | |---|---|---|---| | Server holds | 600 per minute | Secret key | `POST /v1/events/:key/hold`, `/extend`, `/best-available`, `/best-available-block`, `/best-available-book` | | Seasons API | 600 per minute | Secret key | Every `/v1/seasons/*` route, plus `POST /v1/webhooks/:id/rotate-secret` | | Manage sessions | 300 per minute | `mse_…` token | Every route called with an event manage token | | Panorama manifests | 10 per day | Account and chart | `POST /v1/charts/:chartId/panorama-manifests` | | Readiness | 30 per minute | IP address | `GET /health/ready` | The five hold routes share one budget per key. Releasing a hold does not count against it, so returning inventory is never refused for rate. Season routes called from a signed-in dashboard session skip the Seasons API budget. The readiness limit is approximate, so treat it as a rough guard for health checks, not an exact number. ## Buyer and public budgets These routes are called from browsers, so they are counted per IP address. | Budget | Limit | Counted per | Applies to | |---|---|---|---| | Buyer holds | 120 per minute | IP address | Hold and Best Available calls on `/pub/*` | | Buyer reads | 3,000 per minute | IP address | Seat map and availability reads on `/pub/*` | | Season buyer sessions | 30 per minute | IP address and Season | `POST /pub/seasons/:key/sessions` | | Performance Group buyer sessions | 30 per minute | IP address and group | `POST /pub/performance-groups/:key/sessions` | | Season renewal links | 60 per minute | IP address | `/pub/season-renewals/*` | A buyer session is counted per IP and per Season or group, so many buyers behind one office or venue network do not lock each other out of different events. ## The 429 response A refused request answers `429 Too Many Requests`: ```json title="429 response" { "error": "rate_limited", "code": "rate_limited", "retryAfterSeconds": 12 } ``` ```http title="Headers" Retry-After: 12 RateLimit-Limit: 600 RateLimit-Remaining: 0 RateLimit-Reset: 12 RateLimit-Policy: 600;w=60 ``` Some details differ by route: - The server hold routes send `error` and `retryAfterSeconds` but no `code`. Match on `error: "rate_limited"`. - Manage-session and panorama responses send `Retry-After` only, without the `RateLimit-*` headers. - The readiness check sends `{"error": "rate_limited"}` with no headers. - Browsers can read `Retry-After` across origins. The `RateLimit-*` headers are visible to server callers only. ## How to back off - Wait for `Retry-After` seconds, or `retryAfterSeconds` when the header is missing, before trying again. Do not retry in a tight loop. - A 429 means the request did nothing: no hold was created and no seat changed. Retrying after the wait is safe. - Spread bursts. An on-sale that sends every buyer's hold through one secret key uses the same 600 per minute budget. Let the buyer's browser hold seats through the [seating chart](/buyer-sdk/seating-chart/), which uses per-IP budgets, and keep server holds for phone, box office, and headless sales. - Use separate keys for separate systems. A reporting job on the same key as checkout competes for the Seasons budget. - Cache reads. Event lists, charts, and reports have no budget, but polling them every second adds load without new data. Use [webhooks](/webhooks/events/) to learn about changes. For how SeatLayer keeps inventory consistent when many buyers race for the same seats, see [concurrency and performance](/platform/concurrency-performance/). For every error code, see [API errors](/server-api/errors/). # Inventory reports and audit log > Retrieve live inventory, configured booked-value snapshots, channel attribution, accessibility breakdowns, CSV exports, and event history. Source: https://docs.seatlayer.io/server-api/reports/ · Markdown: https://docs.seatlayer.io/server-api/reports/index.md Reports are private organizer data. They describe the event's durable inventory record, including immutable configured-price snapshots captured when inventory was booked. They are not a payment-provider or commercial-order ledger. > **Booked value is not collected revenue** > > SDK/platform reports can show seat/category/section mix, booked percentage, > sell-through, and the configured value of booked inventory. Your platform is > authoritative for the amount charged, discounts, fees, tax, settlement, and > refunds. If those differ from SeatLayer's configured prices, use your own > commerce reports for money. ## JSON report **GET /v1/events/:key/report**. Authentication: Secret key, dashboard session, or event:reports manage token ```bash curl -s "https://api.seatlayer.io/v1/events//report" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` The response is `{report, event, categories}`. ```json { "report": { "byStatus": { "free": 40, "held": 3, "booked": 55, "not_for_sale": 2 }, "byCategory": [ { "category": "vip", "total": 80, "free": 35, "held": 2, "booked": 41, "not_for_sale": 2, "bookedValue": 3075, "bookedRevenue": 3075 } ], "bySection": [ { "sectionId": "sec-stalls", "sectionLabel": "Stalls", "zoneId": "floor-main", "total": 400, "free": 180, "held": 4, "booked": 206, "not_for_sale": 10, "bookedValue": 15450, "bookedRevenue": 15450 } ], "byAccessibility": [], "byWheelchairProvision": [] }, "event": { "key": "ev_9f3a", "name": "Opening Night", "status": "active", "seatTotal": 1200 }, "categories": [ { "key": "vip", "label": "VIP", "color": "#d97706", "price": 75 } ] } ``` ## Interpret each breakdown | Field | Meaning | |---|---| | `byStatus` | Guest-weighted totals for the entire event | | `byCategory` | Status and configured booked value by category key | | `bySection` | Status and configured booked value by event section, with zone id | | `byAccessibility` | One row per accommodation type | | `byWheelchairProvision` | Physical accessible seat versus empty wheelchair space | A grouped table or variable-occupancy object is guest-weighted, so its `quantity` contributes to capacity rather than counting the object once. Accessibility rows are not mutually exclusive: one seat with multiple accommodations contributes to each matching row. Do not sum those rows to derive whole-event inventory. `bookedValue` is the sum of stored configured-price snapshots in major currency units for inventory that is currently booked. It changes when bookings are cancelled and does not prove that a payment was charged, settled, or refunded. `bookedRevenue` returns the same number as a deprecated compatibility alias. > **Host price overrides are separate** > > `SeatPicker.pricing` changes buyer presentation and the SDK handoff. It does > not rewrite the price stored with the SeatLayer hold. If your server charges > a host price book, its order and payment ledger remains the source of truth. ## CSV export CSV downloads are dashboard/Control Room exports in this launch: they require a dashboard session and reject secret keys. They are not published OpenAPI operations or server-SDK methods. Server integrations should call the typed JSON reports above and serialize the fields they need; do not depend on an internal `.csv` route. The response is a downloadable `text/csv` file with: ```text category,label,price,total,free,held,booked,not_for_sale,booked_value ``` A `TOTALS` row follows the category rows. Accessibility, wheelchair-provision, and section breakdowns remain available in JSON because they are not one mutually exclusive category table. ## Sales channel report **GET /v1/events/:key/channels/report**. Authentication: Secret key, dashboard session, or event:channels:view manage token One row per [sales channel](/platform/sales-channels), plus Public sale. Each row carries two things that are easy to confuse and that move independently. | | | |---|---| | **`allocation`** | Where inventory is assigned **right now**, and its physical status. Changes when you move seats. | | **`attribution`** | Which channel each unit was **sold from**, recorded at the moment of booking and never rewritten. | ```json { "report": { "assignmentVersion": 7, "includesBookedValue": true, "includesRevenue": true, "methodology": { "allocation": "Current allocation: where inventory is assigned right now…", "attribution": "Sale attribution: the channel each unit was sold from…", "sellThrough": "Sell-through = attributed sold / current allocated…" }, "rows": [ { "channelId": "public", "name": "Public sale", "externalRef": null, "state": "active", "allocation": { "allocated": 800, "free": 612, "held": 4, "booked": 180, "blocked": 4, "units": 800 }, "attribution": { "sold": 180, "units": 180, "bookedValue": 8100, "revenue": 8100 }, "sellThrough": 0.225 }, { "channelId": "chn_7f2a", "name": "Travel Agency A", "externalRef": "travel-agency-a", "state": "active", "allocation": { "allocated": 120, "free": 78, "held": 2, "booked": 40, "blocked": 0, "units": 120 }, "attribution": { "sold": 40, "units": 40, "bookedValue": 3000, "revenue": 3000 }, "sellThrough": 0.3333333333333333 } ], "totals": { "allocated": 1000, "free": 690, "held": 6, "booked": 220, "blocked": 4, "sold": 220, "bookedValue": 11100, "revenue": 11100 } }, "event": { "key": "ev_9f3a", "name": "Opening Night", "status": "active", "seatTotal": 1000 } } ``` `bookedValue` is the canonical configured-value field. `revenue` and `includesRevenue` return the same values as deprecated compatibility aliases. These are inventory attribution snapshots, not proof of a charge or settlement. When creating a channel report link, send `includesBookedValue: true` to include these configured values. The older `includesRevenue` request field remains an additive compatibility alias; if both fields are sent, they must agree. ### Sell-through, and why it can exceed 100% `sellThrough` is **attributed sold divided by current allocated**. The numerator is frozen at sale time; the denominator is live. So a channel whose remaining inventory is moved out after it sold can report more than `1.0`, and a channel with no allocation left reports `null` rather than `0`. There is genuinely nothing to divide by, which is not the same as "sold nothing". The same three sentences ship inside every response as `methodology`, so a report you export or forward explains itself. > **A sale never follows the seat** > > Moving or archiving a channel changes where its inventory sits, and where a > cancelled seat would return to. It does not move the sales that channel > already made. That is why archived channels stay in this report. Drop them > and the attributed totals stop adding up to what the event actually sold. ### Who sees what | Capability | Result | |---|---| | `event:channels:view` | Every allocation and attribution figure, with configured booked value hidden as `bookedValue: null` and `includesBookedValue: false` | | `event:channels:view` + `event:reports` | The same report, with configured booked value in `bookedValue` | | `event:reports` alone | `403 missing_manage_capability` | Configured booked value is `null`, never `0`, when you may not see it, because a zero would read as a channel that sold nothing. ### Channel CSV export Channel CSV is likewise a dashboard export rather than a stable server-SDK operation. Integrations should retrieve the typed channel report and generate their own file. ```text channel_id,channel,external_ref,state,allocated,free,held,booked,blocked,sold_by_attribution,sell_through,booked_value_by_attribution ``` A `TOTALS` row follows. `sell_through` is a ratio, not a percentage string, so a spreadsheet can format it; an empty cell means there is no current allocation to divide by. The `booked_value_by_attribution` column is absent entirely when the caller lacks `event:reports`. ### Partner-facing channel reports An organizer can share one channel without granting dashboard or API access. In **Performance → Channel**, choose **Share partner report** for a private channel. The generated URL opens a live, read-only projection containing one channel row and the exact inventory currently assigned to it. The projection deliberately excludes event totals, sibling channel names, buyer identities, holds, and booking references. Configured booked value is excluded unless the organizer explicitly enables it for that link. The partner can search and filter the allocation or export the scoped inventory as CSV. The URL capability appears after `#`, is removed from browser history before the application starts, and is shown once, never retrievable. The organizer must copy it when it is created; later listings contain status, expiry, view count, and last-viewed time, never the URL. Revoke the link to stop access immediately. > **Treat the URL like a password** > > Anyone who receives the full URL can open the report until it expires or is > revoked. Use Partner access and server-minted buyer sessions > when the recipient must be identified individually or needs to transact. ## Audit log **GET /v1/events/:key/log**. Authentication: Secret key, dashboard session, or event:reports manage token Entries are newest first. `limit` defaults to 50 and is capped at 200. Pass the returned `nextBefore` as `before` to retrieve older entries. ```bash curl -s \ "https://api.seatlayer.io/v1/events//log?limit=50&before=405" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` ```json { "entries": [ { "id": 404, "at": 1736897900000, "action": "book", "labels": ["A-1", "A-2"], "ref": "order_5567" } ], "nextBefore": 354 } ``` Actions include hold, replace, extend, book, release, expiry, block, unblock, unbook, reveal, and availability changes as those operations occur. `labels` are inventory labels. When some of a page's labels read differently to people (a general admission area's places, a seat or table with a display name), the page also carries `displayLabels`, an object from each such label to its name, the same name Orders and tickets use. It is absent when no label on the page has a different name. ## Incremental booking reconciliation **GET /v1/events/:key/booking-changes**. Authentication: Secret key, dashboard session, or event:view/event:reports manage token Platform events expose an oldest-first, append-only change feed for inventory bookings. Use it for a background reconciliation job that must discover later cancellations to older booking references without rescanning every booking. ```bash curl -s \ "https://api.seatlayer.io/v1/events//booking-changes?after=418&limit=100" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" ``` ```json { "changes": [ { "id": 419, "action": "partial_cancel", "at": 1786897900000, "labels": ["A-2"], "actor": "refund-worker", "source": "server_api", "booking": { "eventKey": "ev_9f3a", "bookingRef": "order_5567", "state": "partially_cancelled", "activeQuantity": 1, "activeConfiguredValue": 75, "currency": "USD" } } ], "nextAfter": null, "checkpoint": 419 } ``` The numeric activity id is the cursor, not `bookedAt` or `updatedAt`. That matters because cancelling a week-old booking changes its current record while its original booking time stays the same, and multiple changes may share one millisecond. Drain pages while `nextAfter` is non-null. After the final page, persist `checkpoint` and send it as the next `after`. An empty poll echoes the same checkpoint. Actions are `book`, `replay`, `partial_cancel`, `cancel`, and `reconcile`; each change includes the current inventory-only booking record. The feed contains no buyer, payment, refund, ticket, email, or door data. ## Reporting checklist - [ ] Keep configured booked value grouped by event currency. - [ ] Do not label SeatLayer snapshots as payment-provider settlement. - [ ] Do not sum overlapping accessibility rows. - [ ] Export until `nextBefore` is null when a complete audit is required. - [ ] Persist the final booking-change `checkpoint` only after your reconciliation write commits. - [ ] Restrict browser manage tokens to the event and `event:reports`. - [ ] Store your own order, refund, tax, fee, and payment identifiers. - [ ] Retain the event key and booking reference in every reconciliation record. - [ ] Reconcile channel booked value by attribution, never by current allocation. - [ ] State the sell-through denominator anywhere you republish the figure. Archived events continue to serve reports. See [event lifecycle](/server-api/events) and [webhook delivery](/webhooks/delivery-and-retries). # Seasons API > Create a Fixed Renewable Season catalogue, publish immutable Plans, run buyer rehearsal, and import incumbent Seat Rights for exact-Plan renewal. Source: https://docs.seatlayer.io/server-api/seasons/ · Markdown: https://docs.seatlayer.io/server-api/seasons/index.md The Seasons API is the organizer catalogue for a **Platform/SDK Fixed Renewable Season**. Your platform still owns identity, pricing, tax, checkout, payment, orders, tickets or passes, and refunds. SeatLayer owns seating and inventory: occurrence membership, Plan publication, sales control, same-seat holds, incumbent import, and renewal offers. Start with the [end-to-end Season integration](/integrations/seasons/) for the complete lifecycle and responsibility boundary. Use the [Season picker](/buyer-sdk/seasons/) for buyer UI; this page owns exact trusted operations and recovery behavior. Buyer access sessions, trusted hold inspection, booking, cancellation, holder import, and renewal offers are on this page. Browser `/pub/seasons/*` routes stay picker-only and are not server-SDK operations. A Seat Right is a durable fence, not a [checkout hold](/server-api/how-seat-holds-work/). Opening sales still requires a recorded buyer rehearsal. Every released secret-key route on this page uses a matching `sk_test_…` or `sk_live_…` key. Keep that key on your server. Dashboard availability is a separate release boundary and does not change the public SDK contract. > **Current production surface** > > Seasons are public through the browser picker, REST API, and all seven server > SDKs, and organizers can manage them from the dashboard's **Seasons** page. > Native Season pickers are not supported. [Season Best > Available](#season-best-available) picks the same seats on every date with a > buyer session token. > **48 operations in every server SDK, 80 in REST** > > All seven official server SDKs (Node.js, Python, PHP, Java, Go, Ruby, and > .NET) expose the same frozen 48-operation Season contract at `0.8.0`. That > includes buyer-session creation and listing, rehearsal validation, > renewal-offer creation, occurrence listing, and amendment creation and > listing. Language method names differ; operation coverage does not. The > REST API has 80 Season operations: those 48 plus the > [32 listed below](#rest-operations-beyond-the-48), which no server SDK > release includes yet. > **Source preview is not a released package contract** > > Current source work adds newer request or response shapes to seven existing > operations, multi-Plan targeting, one rehearsal per Plan, safe token > adoption, and Event-local side effects. These deltas are not part of the > frozen 48-operation baseline or any named package version. React Native, > Flutter, iOS, and Android do not expose a native Season v1 picker. | Released baseline surface | Package or coordinate | |---|---| | REST | `https://api.seatlayer.io/v1/seasons` | | Node.js | `npm install @seatlayer/server@0.8.0` | | Python | `pip install seatlayer==0.8.0` | | PHP | `composer require seatlayer/seatlayer-php:0.8.0` | | Java | `io.seatlayer:seatlayer-java:0.8.0` | | Go | `go get github.com/seatlayer/seatlayer-go@v0.8.0` | | Ruby | `gem install seatlayer -v 0.8.0` | | .NET | `dotnet add package SeatLayer --version 0.8.0` | Season secret-key routes have a durable per-key budget of 600 requests per minute and return `429 rate_limited`, `Retry-After`, and standard rate-limit headers when exhausted. Browser and dashboard routes use separate budgets. ## Released 0.8.0 API map | Task | Method and route | |---|---| | List Seasons | `GET /v1/seasons` | | Validate Event / Performance Group compatibility | `POST /v1/seasons/validate` | | Create a draft | `POST /v1/seasons` | | Read one Season | `GET /v1/seasons/:key` | | Rename a draft, or set checkout mode and audience | `PATCH /v1/seasons/:key` | | Discard a draft | `DELETE /v1/seasons/:key` | | Activate structure | `POST /v1/seasons/:key/activate` | | Close structure | `POST /v1/seasons/:key/close` | | Archive a closed Season | `POST /v1/seasons/:key/archive` | | Recover lifecycle | `GET /v1/seasons/:key/lifecycle/:operationId` | | Add a draft Plan | `POST /v1/seasons/:key/plans` | | Read a Plan | `GET /v1/seasons/:key/plans/:planKey` | | Publish a Plan | `POST /v1/seasons/:key/plans/:planKey/publish` | | Supersede a Plan | `POST /v1/seasons/:key/plans/:planKey/supersede` | | Open / pause / resume / end sales | `/v1/seasons/:key/sales/open` · `pause` · `resume` · `end` | | Duplicate test configuration to live | `POST /v1/seasons/:key/duplicate-to-live` | | Mint / list / revoke browser sessions | `/buyer-access-sessions` | | Inspect the opaque Season hold | `GET /:key/holds/:operationId` | | Book the hold | `POST /:key/holds/:operationId/book` | | Retrieve / cancel a booking | `GET /:key/bookings/:actionId` · `POST /:key/bookings/:actionId/cancel` | | Import incumbent holders | `POST /v1/seasons/:key/imports` · `GET /imports/:importId` | | Generate / list renewal offers | `POST /v1/seasons/:key/renewal-offers` · `GET /renewal-offers` | | Read / inspect / extend an offer | `GET /renewal-offers/:offerId` · `GET /inspect` · `POST /extend` | | Commit / decline / release | `POST /commit` · `POST /decline` · `POST /release` | | List occurrences | `GET /v1/seasons/:key/occurrences` | | Amend / list amendments | `POST /amendments` · `GET /amendments` | | Reports / operations / support | `GET /reports` · `GET /operations` · `GET /support-lookups` | | Outbox / replay | `GET /outbox` · `POST /outbox/:occurrenceId/replay` | | Redacted audit / support export | `GET /audit` · `GET /export` | Activation does not publish a Plan. Publication does not open sales. Audience stays `embed_only`. ### REST operations beyond the 48 These 32 operations are in REST. No `0.8.0` server SDK includes them; call them over HTTP with your secret key. The last 15 rows are the [holder API](#holders-orders-renewal-emails-and-reports). | Task | Route | |---|---| | Bring holders from a previous season | `POST /v1/seasons/:key/imports/from-season` | | List performances (works on a draft) | `GET /v1/seasons/:key/performances` | | Create or attach performances in a draft | `POST /performances` · `POST /performances/attach` | | Remove a performance from a draft | `DELETE /performances/:eventKey` | | Rename, re-date, or delete a draft Plan | `PATCH /plans/:planKey` · `DELETE /plans/:planKey` | | Publish in one call | `POST /v1/seasons/:key/publish` | | Run and list checkout tests | `POST /checkout-tests` · `GET /checkout-tests` | | Set and list package prices | `PUT /plans/:planKey/prices` · `GET /prices` | | Add a performance to a published Plan | `POST /plans/:planKey/performances` | | Remove a performance from a published Plan | `DELETE /plans/:planKey/performances/:eventKey` | | Mark a performance as taken place | `POST /performances/:eventKey/past` | | Return one booked occurrence to ordinary Event inventory | `POST /occurrences/:eventKey/returns` | | Reclaim a still-free occurrence return | `POST /occurrences/:eventKey/reclaims` | | List holders | `GET /v1/seasons/:key/holders` | | Refund, cancel, or cancel one date of a holder's order | `POST /orders/:orderId/refund` · `POST /orders/:orderId/cancel` · `POST /orders/:orderId/dates/:eventKey/cancel` | | Email an order's tickets again | `POST /orders/:orderId/resend` | | Renewal links, payments and emails per offer | `GET /renewal-hosted` | | New renewal link, holder contact, email again | `POST /renewal-offers/:offerId/link` · `POST /contact` · `POST /resend` | | Email renewal invitations and reminders | `POST /renewal-notices` | | Sales and free-seat reports | `GET /reports/sales` · `GET /reports/seats` | | Holder and order downloads | `GET /reports/holders.csv` · `GET /reports/orders.csv` | | Season seats sold per package and date | `GET /seat-counts` | Seven released operation IDs also have newer shapes in source. Keep these boundaries explicit when writing code against a `0.8.0` package: | Existing operation | Copy-safe `0.8.0` boundary | Source-preview delta | |---|---|---| | Create buyer session | `allowedOrigin`, `includePublic`, optional `expiresInSeconds`, `maxQuantity`, `buyerRef`; the sole published Plan is inferred | Request may add `planActivationId` and `purpose` | | List buyer sessions | Optional `limit`; returns the released session metadata | Rows may add purpose, multi-Plan, and adoption metadata | | Validate buyer rehearsal | No request body; returns one retained rehearsal result | Response may add `planActivationIds` and per-activation evidence | | Create renewal offers | `successorPlanActivationId?`, `deadlineAt`, `contractIds?` | Adds multi-Plan selection semantics and newer response detail | | List occurrences | No query in the SDK method | May accept `planActivationId` | | Create amendment | `eventKey`, `kind`, optional `startsAt` and `name` | May accept `planActivationId` and multi-Plan guards | | List amendments | No arguments; returns `amendments` and `truncated` | Rows may include newer multi-Plan outcomes | The [version-pinned language references](/server-sdk/) are authoritative for the exact method spelling and request shape. The current OpenAPI document is not a backport into `0.8.0`. > **Unpublished multi-Plan candidate** > > The candidate permits multiple immutable Plans to remain published in one > Season. Publishing a new Plan is allowed only while sales are `closed` or > `ended`; otherwise it returns `409 sales_not_ended`. A browser session is > always bound to one exact Plan activation. `planActivationId` is required when > more than one Plan is published, while omission remains compatible for a > single published Plan. An unknown or superseded activation returns `409 > plan_not_published`. Every successful new publication clears rehearsal > readiness, and opening sales requires one complete retained rehearsal journey > for every currently published Plan activation. This behavior is available in > a future release. ## Validate before creation Preflight is read-only and returns exact incompatible Event identity, field, expected/actual values, and remediation. It accepts direct Event keys, active Performance Group keys, or both: ```http title="compatibility preflight" POST /v1/seasons/validate HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "sourcePerformanceGroupKeys": ["pg_spring_series"], "eventKeys": ["ev_bonus_night"] } ``` Performance Group sources must be active. Creation freezes the group's activation identity and exact Event membership on the Plan. Publication refuses a source that has since changed or closed; it never silently adopts new membership. ## Create a draft Season ```http title="HTTP request" POST /v1/seasons HTTP/1.1 Authorization: Bearer sk_test_•••••••• Idempotency-Key: 2026-series-draft Content-Type: application/json { "name": "2026 series", "edition": "2026–27", "eventKeys": ["ev_night_1", "ev_night_2"] } ``` `name` is required (1–120 characters). `edition` is optional. Selection may use `eventKeys`, `sourcePerformanceGroupKeys`, or both. Each fixed Plan must contain 2–20 distinct Events that share organization, workspace, mode, chart, currency, venue, and timezone. Channel-restricted inventory is excluded from v1. The seasons module must be enabled. `currency` is a child-Event compatibility constraint, not the Season package currency. SeatLayer never derives the package price from Event category or tier amounts. `201` returns `{ season }` with `structureState: "draft"`, `salesState: "closed"`, `audience: "embed_only"`, and at least one draft Plan. Retry create with the same [idempotency key](/server-api/idempotency-and-conflicts/) to replay the original body. `DELETE /v1/seasons/:key` succeeds only for a draft. ### Create new performances in a draft Season When the Season should create ordinary Events rather than attach existing ones, pass the venue region with the performance request: ```http title="create performances near the venue" POST /v1/seasons/sea_123/performances HTTP/1.1 Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "titlePattern": "Autumn night {n}", "firstStartsAt": 1792346400000, "timezone": "Europe/Amsterdam", "repeat": "weekly", "count": 3, "region": "western-europe" } ``` `region` follows the same [11-region table and first-placement rules](/server-api/event-regions/) as ordinary Event creation. Omit it to use the workspace default; pass `asia-pacific` for venues in India. Additional immutable fixed Plans may be added only while the Season is still a draft. Every successful addition atomically advances the Season revision and its distinct-Event union identity. Activation fences catalogue membership. After activation, a performance joins or leaves a published Plan only through the amendment routes (`POST /plans/:planKey/performances` and `DELETE /plans/:planKey/performances/:eventKey`), and each change is recorded as an amendment. ## Node.js quickstart Install the current public server SDK: ```bash npm install @seatlayer/server@0.8.0 ``` ```ts title="test-mode catalogue and lifecycle" import { SeatLayer } from '@seatlayer/server'; const seatlayer = new SeatLayer({ secretKey: process.env.SEATLAYER_TEST_SECRET_KEY!, }); const compatibility = await seatlayer.seasons.validateSeason({ sourcePerformanceGroupKeys: ['pg_spring_series'], }); if (!compatibility.valid) throw new Error(compatibility.issues[0]?.remediation); const { season: draft } = await seatlayer.seasons.createSeason({ name: '2026 series', edition: '2026–27', sourcePerformanceGroupKeys: ['pg_spring_series'], }, { idempotencyKey: '2026-series-draft' }); const active = await seatlayer.seasons.waitForSeasonLifecycle( await seatlayer.seasons.activateSeason(draft.key, draft.revision), { timeoutMs: 30_000 }, ); const firstPlan = active.season.plans[0]!; await seatlayer.seasons.waitForSeasonLifecycle( await seatlayer.seasons.publishSeasonPlan( active.season.key, firstPlan.key, active.season.revision, ), { timeoutMs: 30_000 }, ); ``` This quickstart exercises the released baseline. The multi-Plan and occurrence inventory examples below are a preview, not yet released, and do not identify a published package version. The SDK polls only the retained `Location` / lifecycle operation identity returned by `202 Accepted`, honors `Retry-After`, and stops at the caller's timeout. Use `sk_test_…` credentials and test Events while integrating. ## Activate, publish, and sales ```http title="activate" POST /v1/seasons/sea_…/activate Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "expectedRevision": 1 } ``` Activation is structural. The published Plan is a separate machine: ```http title="publish" POST /v1/seasons/sea_…/plans/spl_…/publish Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "expectedRevision": 2 } ``` After publication, `salesState` is still `closed`. In the multi-Plan candidate, publishing any additional Plan also resets rehearsal readiness. Opening sales: ```http title="open sales" POST /v1/seasons/sea_…/sales/open Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "expectedRevision": 3 } ``` `409 coupled_lifecycle` is the expected answer until buyer rehearsal readiness is recorded. The released baseline requires one coherent journey. The candidate requires one coherent hold → book → cancel webhook-delivery journey for each currently published Plan activation. Pause, resume, and end use the same `expectedRevision` body on `/sales/pause`, `/sales/resume`, and `/sales/end`. Close structure only when sales are not open: ```http title="close" POST /v1/seasons/sea_…/close Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "expectedRevision": 4 } ``` Close starts a durable close/drain decision: already accepted operations remain retrievable and recoverable, while fresh sale work is refused. The returned operation becomes terminal when the structure reaches `closed`. `POST /v1/seasons/:key/archive` accepts only a closed Season and preserves sales as closed. `GET /v1/seasons/:key/lifecycle/:operationId` returns the activation, close, archive, or Plan-publication operation recorded on the Season. Activation, close, archive, Plan publication, and Plan supersession retain one audit checkpoint and one semantic webhook occurrence per immutable action. ## Occurrence amendments and operational support `POST /v1/seasons/:key/amendments` records either an identity-preserving reschedule or an explicit replacement/cancellation exception. It never changes activated Plan membership. Each successful mutation gets an immutable `revision` and retains deterministic `contractOutcomes` and `allocationOutcomes`. The outcomes are durable: list and retrieve always return them. `GET /reports` combines live Season state with allocation totals and receiver-attempt health. An occurrence is counted as delivered only after at least one subscribed receiver returns `2xx`. This is an inventory/operations report, not revenue, payment, refund, attendance, or utilization reporting. `POST /outbox/:occurrenceId/replay` resends the retained payload with the same business `occurrenceId`. It increments replay metadata once the resend is accepted and does not mark the occurrence delivered. Receiver deduplication remains required. `GET /support-lookups` accepts either `bookingRef` or `holderRef`, returns the correlated inventory records plus the request ID, and makes no refund claim. `GET /audit` returns bounded redacted activity. `GET /export` returns `season-support-export.v1`, a bounded JSON snapshot with catalogue identity, Plans, amendments, allocations, report, and audit; `truncated` flags disclose whether either bounded collection has more rows. Top-level Season listing is cursor-paginated. Occurrences are complete within the 20-occurrence public contract. Buyer-session history is latest 100. Renewal-offer and amendment lists return latest 100 plus `truncated`; operations return latest 50 plus `truncated`; outbox returns latest 100 plus `truncated`. Report and outbox totals use the full retained aggregate, and an older omitted outbox occurrence remains replayable by its exact ID. ## Buyer session, inspect, book, cancel ```http title="mint" POST /v1/seasons/sea_…/buyer-access-sessions Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "allowedOrigin": "https://tickets.example.com", "includePublic": true } ``` This is the released `0.8.0` request. `201` reveals the `bss_…` token once and returns the server-inferred `planActivationId` for the single published Plan, plus Origin, mode, environment, quantity limit, and buyer reference metadata. The token is not a refresh token. v1 requires `includePublic: true`; a false value does not create a private scope. > **Preview-only session fields** > > Only the source preview accepts `planActivationId` and `purpose` in this > request. In that preview, multiple published Plans require an exact > activation and `purpose` distinguishes sale from rehearsal. Do not add either > field to a `0.8.0` typed call or to a production request that follows the > frozen baseline. Use the [JavaScript, React, Vue, or Angular Season picker](/buyer-sdk/seasons) with that token against `/pub/seasons/:key`. After the picker returns an opaque handoff, inspect then book from your server: All four web `SeasonPicker` packages expose the released browser baseline. All seven server SDKs at `0.8.0` expose the same frozen 48 server operations. Seven of those operation IDs have newer request or response shapes in the current REST/Node source preview; that does not mean the released Python, PHP, Java, Go, Ruby, or .NET methods are absent. Multi-Plan targeting and safe session adoption are a preview, not yet released. Occurrence return and reclaim are deployed in REST; only the unreleased Node.js preview wraps them. A backend integration remains necessary for a Platform checkout: the secret-key server must inspect the opaque hold, apply the host's trusted price and payment decision, and book it. `GET /v1/seasons/:key/holds/:operationId` returns the trusted Event, label, object, category, optional tier, and quantity allocation. It deliberately contains no `unitPrice`, `currency`, subtotal, or total. The response fixes `pricingAuthority` to `"host"` and `authoritativeAmountIncluded` to `false` so the allocation cannot be mistaken for a commercial quote. Calculate the package amount in your own trusted system, take payment there, then book the same operation. The booking body accepts only `bookActionId` and `bookingRef`; commercial fields are rejected instead of silently ignored. ```http title="book" POST /v1/seasons/sea_…/holds/sop_…/book Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "bookActionId": "sba_1", "bookingRef": "order_1" } ``` Booking may return `202 Accepted` with `Location` and `Retry-After`. Poll that booking URL until `state` is terminal; replaying the same action is exact. Cancel the booking with a new caller-stable action while repeating the original booking and published Plan identities: ```http title="cancel" POST /v1/seasons/sea_…/bookings/sba_1/cancel Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "cancelActionId": "sca_1", "bookingRef": "order_1", "planActivationId": "spa_…", "rightDisposition": "preserve" } ``` ### Season Best Available When the buyer wants "the best 2 seats for the whole season", let SeatLayer pick. It chooses seats that are free on every date in the Plan and holds the same labels on each date in one operation. ```http title="best available" POST /pub/seasons/sea_…/best-available Authorization: Bearer bss_•••••••• Content-Type: application/json { "operationId": "sop_7d21", "qty": 2, "categoryKey": "stalls", "requireContiguous": true } ``` This is a buyer route: it takes the `bss_…` token from the session you minted above, not a secret key, and counts against the per-IP buyer hold budget in [rate limits](/server-api/rate-limits/). It is not a server SDK operation. | Field | Required | Meaning | |---|---:|---| | `operationId` | Yes | Your stable id for this hold, starting with `sop_`. Resending the same id and body returns the first result | | `qty` | Yes | 1 to 10 seats. More answers `422 best_available_quantity_exceeded` | | `categoryKey` | No | Pick from one category | | `zoneId` | No | Pick from one zone | | `accessibility` | No | Accessibility needs the seats must meet | | `requireContiguous` | No | `true` to fail instead of splitting the group | The response is `{operation, labels, items, holdId, expiresAt}`. `201` means the hold is in place on every date; `202` means it is still being applied, so poll the `Location` header after `Retry-After`. Then inspect and book the operation from your server exactly as for a buyer-chosen hold. Performances already marked past are skipped. `409 conflict` with `reason` means no set of seats is free on every date; `409 operation_mismatch` means the same `operationId` was sent with a different body. Cancellation requires an explicit `preserve` or `release` future-right disposition. There is no implicit default and no automatic refund. It may also return `202` with the booking `Location` and `Retry-After`; the retrieved booking includes nested per-occurrence cancellation outcomes. A mixed nonretryable result is terminal `partial_terminal` and requires reconciliation, not a new action or blind rollback. After the released-baseline journey, or one journey per published Plan in the candidate, ask SeatLayer to discover the retained evidence: ```http title="verify retained rehearsal" POST /v1/seasons/sea_…/buyer-rehearsals/validate Authorization: Bearer sk_test_•••••••• ``` The released request has no body. SeatLayer joins coherent hold, booking, and cancellation occurrences by their retained operation identities, then requires one active in-scope webhook subscription to have returned `2xx` for all three. The `0.8.0` response contains one `rehearsal` result. In the multi-Plan source preview, proof is required independently for every published activation and the response adds `planActivationIds` plus `activationEvidence`; those fields are not part of the frozen baseline. A later preview Plan publication invalidates aggregate readiness until the new Plan has its own journey. Operators never paste `sop_…`, `sba_…`, `sca_…`, or `wh_…` values into the dashboard. A successful result records an immutable audit checkpoint; later checks still succeed after delivery-attempt retention pruning. `409 rehearsal_incomplete` means the SDK journey or its matching webhook delivery is not complete yet. ## Incumbent import and renewal offers **POST /v1/seasons/:key/imports**. Authentication: Secret key **GET /v1/seasons/:key/imports/:importId**. Authentication: Secret key **POST /v1/seasons/:key/renewal-offers**. Authentication: Secret key **GET /v1/seasons/:key/renewal-offers**. Authentication: Secret key **GET /v1/seasons/:key/renewal-offers/:offerId**. Authentication: Secret key **POST /v1/seasons/:key/renewal-offers/:offerId/extend**. Authentication: Secret key **GET /v1/seasons/:key/renewal-offers/:offerId/inspect**. Authentication: Secret key **POST /v1/seasons/:key/renewal-offers/:offerId/commit**. Authentication: Secret key **POST /v1/seasons/:key/renewal-offers/:offerId/decline**. Authentication: Secret key **POST /v1/seasons/:key/renewal-offers/:offerId/release**. Authentication: Secret key Dry-run an incumbent file, then commit the same rows. Invalid or conflicting rows stay explicit and do not create rights. Each accepted row becomes one Contract and one reserved Seat Right, fenced on every included Event. ```http title="import" POST /v1/seasons/sea_…/imports Authorization: Bearer sk_test_•••••••• Idempotency-Key: 2026-incumbents Content-Type: application/json { "dryRun": false, "successorPlanActivationId": "spa_…", "rows": [ { "rowId": "row_1", "holderRef": "holder_ada", "priorPlanActivationId": "spa_prior", "priorContractRef": "legacy_ada", "labels": ["A-1"], "existingBookingRef": "legacy_booking_ada" } ] } ``` Generate **one** time-bounded offer per prior Contract. The offer carries that Contract's complete seat set and one immutable successor Plan activation. ```http title="generate offers" POST /v1/seasons/sea_…/renewal-offers Authorization: Bearer sk_test_•••••••• Idempotency-Key: 2026-offers Content-Type: application/json { "successorPlanActivationId": "spa_…", "deadlineAt": 1790000000000 } ``` Browser `POST /pub/seasons/:key/renewal-intents` records holder intent only. It requires a buyer-access session bound to the offer's opaque holder reference; an unbound or different holder is refused. It is not proof of purchase. After you inspect the offer, commit with a stable action id, order/booking references, and the exact Plan activation. A wrong Plan is `409 plan_activation_mismatch`. Commit may return `202` with `Location` and `Retry-After`; poll the offer until terminal. `commitOutcomes` retains each Event as `pending`, `committed`, or `failed`. A mixed nonretryable result is terminal `partial_terminal`, emits `season.operation.partial_terminal`, and requires reconciliation rather than a new commit action or blind rollback. Decline releases the right set immediately. Release is only valid after terminal lapse. Extension is explicit, pre-lapse, and must move the deadline forward; this release does not invent a default extension. Account-specific organizer bounds and permission policy still apply. ## Bring holders from last season **POST /v1/seasons/:key/imports/from-season**. Authentication: Secret key Instead of building an import file, point at a previous season of the same account and mode. SeatLayer takes every holder who still has a seat on at least one date of that season and runs them through the same checks as `POST /imports`. Test bookings, refunded or cancelled orders, holders with more seats than one right can hold, and holders without a usable reference are left out and counted in `excluded`. `dryRun` is `true` unless you send `false`. Look at the dry run first, then send the same body with `"dryRun": false` and a new `Idempotency-Key`. ```http title="dry run" POST /v1/seasons/sea_2027…/imports/from-season Authorization: Bearer sk_live_•••••••• Idempotency-Key: bring-2026-holders-check Content-Type: application/json { "sourceSeasonId": "sea_2026…", "successorPlanActivationId": "spa_…", "dryRun": true } ``` ```json title="200 dry run" { "import": { "sourceSeasonId": "sea_2026…", "successorPlanActivationId": "spa_…", "dryRun": true, "holders": 3, "acceptedCount": 2, "alreadyImported": 0, "rejectedCount": 1, "withEmail": 1, "hosted": 1, "integrator": 2, "excluded": { "refunded": 1, "rehearsal": 1, "tooManySeats": 0, "unusableRef": 0 }, "importIds": ["sim_…"], "rejected": [{ "holderRef": "booking_pia_2026", "labels": ["B-4"], "errorCode": "unknown_seat" }], "preview": [ { "holderRef": "ord_…", "holderName": "Hanna Holder", "hasEmail": true, "labels": ["A-2", "A-3"] }, { "holderRef": "booking_ann_2026", "holderName": null, "hasEmail": false, "labels": ["A-1"] } ] } } ``` The commit answers `201` with the same shape. A holder is refused with `unknown_seat` when a seat is not on the new plan and `seat_conflict` when someone else already holds it. Running it again is safe: a holder already brought across counts under `alreadyImported`, never twice. `holderRef` is the order id for a SeatLayer checkout sale and the `bookingRef` your system booked with otherwise. Holders who bought through SeatLayer checkout come with their name and email, so SeatLayer can email their renewal link; holders your own system booked come without an email. Read each import with `GET /v1/seasons/:key/imports/:importId`, using the ids in `importIds`. | Error | Meaning | |---|---| | `404 source_season_not_found` | No season with that key in this account and mode | | `409 source_too_large` | Too many holders to bring across in one go | | `409 season_not_active` · `409 plan_not_published` | Activate this season and publish the package first | | `422 invalid_source_season` · `422 invalid_successor_plan` | A key is missing, or the source is this season | ## Return or reclaim one occurrence > **Deployed in REST, not in a released SDK** > > These two secret-key operations are deployed in the REST API. They are not > in `0.8.0` or any other named server SDK package on this page. The Node.js > example below uses an unreleased preview; from any other language, > call the REST routes directly. Return the exact labels from one active booked Season allocation to ordinary Event inventory without cancelling the holder's complete Season contract: ```http title="return one occurrence" POST /v1/seasons/sea_…/occurrences/ev_night_2/returns Authorization: Bearer sk_test_•••••••• Content-Type: application/json { "actionId": "sor_order_1_night_2", "bookingRef": "order_1", "planActivationId": "spa_…", "labels": ["A-1"], "source": { "bookingActionId": "sba_1" } } ``` For a renewed right, use `{ "renewalOfferId": "sro_…" }` as `source` instead. SeatLayer resolves the allocation for you; callers never send a child hold, internal allocation, or Seat Right ID. `actionId` owns exact replay. Reusing it with any changed Event, Plan, source, booking reference, or label set returns `409 occurrence_action_mismatch`. A successful return emits `season.occurrence.returned` and makes only that Event's labels available for ordinary sale. Reclaim succeeds only while the same return remains active and every label is still free, unheld, unbooked, and unclaimed by another Season allocation: ```ts title="unreleased Node.js preview" const returned = await seatlayer.seasons.returnSeasonOccurrenceInventory( "sea_…", "ev_night_2", { actionId: "sor_order_1_night_2", bookingRef: "order_1", planActivationId: "spa_…", labels: ["A-1"], source: { bookingActionId: "sba_1" }, }, ); await seatlayer.seasons.reclaimSeasonOccurrenceInventory( "sea_…", "ev_night_2", { actionId: "src_order_1_night_2", returnActionId: returned.occurrenceInventoryAction.returnActionId, bookingRef: "order_1", planActivationId: "spa_…", labels: ["A-1"], source: { bookingActionId: "sba_1" }, }, ); ``` Reclaim emits `season.occurrence.reclaimed`. A `preserve` cancellation is refused while that booking has an active occurrence return; a full `release` cancellation does not seize independently held or resold Event inventory. Neither operation prices a resale, moves money, refunds a buyer, creates a listing, or issues a ticket. Those commerce decisions remain entirely in the host platform. ## Holders, orders, renewal emails, and reports These are the dashboard's Holders and Reports tabs for your own server. Each call does the same work as the matching dashboard button. The order calls act on orders SeatLayer took: SeatLayer checkout and the box office. A season sold through your own checkout has no SeatLayer orders; your system refunds those. - REST today; server SDK methods arrive in the next SDK release. No released server SDK has these calls yet, so call the routes over HTTP. The two CSV downloads stay HTTP-only. - Use a secret key (`sk_live_…` or `sk_test_…`). It reaches the seasons of its own account and mode. Another account's season, or a live season from a test key, is `404 not_found`. - A publishable key gets `403 publishable_key_not_allowed`. A dashboard login cookie gets `401`. - These calls share the 600-a-minute budget of every Season route. - Each answer carries only the fields shown here. Internal ids, the payment provider's refund id, and staff notes are never in them. A package is named by its plan key (`spl_…`), the `plans[].key` of `GET /v1/seasons/:key`. ### List holders **GET /v1/seasons/:key/holders**. Authentication: Secret key Everyone who holds this season: SeatLayer checkout and box office buyers (`hosted`), bookings your system made (`integrator`), and holders brought over from last season (`imported`). | Query | Meaning | |---|---| | `q` | Search name, email, reference, order number, or seat | | `status` | `active`, `cancelled`, or `renewal` | | `limit` | 1–100, default 50 | | `cursor` | `nextCursor` from the page before | ```json title="200" { "holders": [ { "id": "hld_6c0edf602c0a948627fec31675ee011e", "kind": "hosted", "bookingRef": "ord_ea11…", "orderId": "ord_ea11…", "orderNumber": "#1098 5206", "holderName": "Hanna Holder", "holderEmail": "hanna@example.com", "holderRef": null, "labels": ["A-2", "A-3"], "packageName": "Full season", "packageId": "spl_97d1…", "dates": [ { "eventKey": "ev_3709…", "state": "active" }, { "eventKey": "ev_1eff…", "state": "active" }, { "eventKey": "ev_80b7…", "state": "active" } ], "datesTotal": 3, "datesActive": 3, "datesCancelled": 0, "paid": { "amountMinor": 18000, "currency": "GBP", "refundedMinor": 0 }, "soldAtCounter": false, "orderStatus": "confirmed", "status": "active", "renewal": null, "boughtAt": 1790220682247 } ], "nextCursor": "eyJwIjoiYiIs…", "totals": { "holders": 3, "active": 2, "cancelled": 0, "imported": 1 } } ``` `totals` comes with the first page only. A page can hold fewer rows than `limit` and still have a `nextCursor`; keep reading until it is `null`. `id` is stable but opaque: use `orderId`, `bookingRef`, or `holderRef` to match your records. `status` is one of `active`, `partly_cancelled`, `cancelled`, `needs_review`, `renewal_open`, `renewed`, `declined`, and `lapsed`. ### Refund an order **POST /v1/seasons/:key/orders/:orderId/refund**. Authentication: Secret key Pays money back through the payment provider that took it. Leave out `amountMinor` to refund what is left; a full refund also cancels every date, so the seats go back on sale and the tickets stop working. A partial refund keeps the seats and tickets. ```http title="refund part of an order" POST /v1/seasons/sea_a245…/orders/ord_2596…/refund Authorization: Bearer sk_live_•••••••• Idempotency-Key: refund-ord_2596-1 Content-Type: application/json { "amountMinor": 5000 } ``` ```json title="200" { "orderId": "ord_2596…", "refundId": "rfo_1979…", "amountMinor": 5000, "currency": "GBP", "state": "succeeded" } ``` `Idempotency-Key` is required: without it the call is `400 idempotency_key_required` and nothing moves. `refundId` is SeatLayer's refund (`rfo_…`), built from your account and the key. Send the same key again and you get the same answer; the dashboard sending that key reaches the same refund. A `202` with `"state": "pending"` means the provider has not confirmed yet: retry with the same key to read it. | Error | Meaning | |---|---| | `409 not_refundable` | A box office sale (`code: box_office_sale`) or an order with no payment to reverse | | `409 already_refunded` · `409 refund_failed` · `409 gateway_not_connected` | Nothing left, the provider said no (`detail` says why), or payments are disconnected | | `422 invalid_amount` | Not a whole number above zero, or more than `outstanding` | | `502 refund_recovery_required` | The provider may have taken it; retry with the same key | ### Cancel an order without a refund **POST /v1/seasons/:key/orders/:orderId/cancel**. Authentication: Secret key For money handled outside SeatLayer: a lost payment dispute, or a refund made another way. Every ticket stops working and every date goes back on sale. `Idempotency-Key` is required. ```http title="cancel" POST /v1/seasons/sea_a245…/orders/ord_ea11…/cancel Authorization: Bearer sk_live_•••••••• Idempotency-Key: cancel-ord_ea11 Content-Type: application/json { "reason": "refunded_elsewhere", "refundConfirmed": true, "notifyBuyer": false } ``` ```json title="200" { "receipt": { "orderId": "ord_ea11…", "displayRef": "#1098 5206", "status": "refunded", "currency": "GBP", "settledOutsideMinor": 18000, "released": ["A-2", "A-3"], "seatsReleased": true, "emailQueued": false, "cancelledAt": 1790220682345 } } ``` `reason` is `lost_dispute`, `refunded_elsewhere`, or `other`; `note` is kept for your team and never returned. Send `usedTicketsAcknowledged: true` when a ticket was already scanned. A box office order is `409 box_office_order_use_dashboard`: staff cancel it at the counter after giving the money back. ### Cancel one date **POST /v1/seasons/:key/orders/:orderId/dates/:eventKey/cancel**. Authentication: Secret key Takes back one date of an order. That date's tickets stop working first, then its seats go back on sale; every other date stays. No money moves: refund the date separately if you need to. `Idempotency-Key` is required; the body is optional. ```http title="cancel one date" POST /v1/seasons/sea_a245…/orders/ord_ea11…/dates/ev_3709…/cancel Authorization: Bearer sk_live_•••••••• Idempotency-Key: cancel-ord_ea11-night-1 ``` ```json title="200" { "dateCancel": { "eventKey": "ev_3709…", "ticketsVoided": 2, "labels": ["A-2", "A-3"], "state": "returned" } } ``` A `202` with `"state": "pending"` means the tickets are void and the seats are on their way back; retry with the same key. `409 date_already_used` means someone was admitted with a ticket for that date. `409 date_not_held` or `409 conflict` means the date was already taken back. ### Email the tickets again **POST /v1/seasons/:key/orders/:orderId/resend**. Authentication: Secret key Sends the season confirmation with every live ticket. Pass `email` to send it to a new address, which also replaces the one on the order. ```json title="200" { "sentTo": "hanna@example.com" } ``` One order can be resent 3 times an hour (`429 rate_limited`). `409 no_tickets` means the order has no live tickets, and `409 no_buyer_email` means a walk-up buyer gave no address: send one in `email`. ### Renewal links and emails **GET /v1/seasons/:key/renewal-hosted**. Authentication: Secret key **POST /v1/seasons/:key/renewal-offers/:offerId/link**. Authentication: Secret key **POST /v1/seasons/:key/renewal-offers/:offerId/contact**. Authentication: Secret key **POST /v1/seasons/:key/renewal-notices**. Authentication: Secret key **POST /v1/seasons/:key/renewal-offers/:offerId/resend**. Authentication: Secret key With SeatLayer checkout, holders renew and pay on a SeatLayer page, from a link SeatLayer emails them. `GET /renewal-hosted` shows where each offer stands and whether payments can be taken: ```json title="200" { "checkoutMode": "seatlayer", "readiness": { "ready": true, "problem": null }, "holdersWithoutEmail": 1, "offers": [ { "offerId": "sro_be78…", "holderName": "Ana Silva", "holderEmail": "ana@example.com", "linkActiveSince": null, "paymentActive": false, "notices": { "invitedAt": null, "lastReminderAt": null, "nextNoticeAt": null, "nextNoticeKind": null, "problem": null } } ] } ``` `readiness.problem` is `own_checkout`, `prices_missing`, or a payments problem when holders cannot pay yet. - `POST /renewal-offers/:offerId/link` answers `201` with `{ "url": "…/r/…" }`. The link is shown once, and making a new one turns the previous link off, so do not retry this call blindly: each call makes another link. - `POST /renewal-offers/:offerId/contact` with `{ "email", "name" }` answers `{ "contact": { "name": "Ana Silva", "email": "ana@example.com" } }`. A new email turns the current link off. - `POST /renewal-notices` emails the invitation now, then reminders 7 days and 1 day before the deadline. Send `{ "offerIds": […] }` to pick holders, or no body for every open offer. It answers `{ "scheduled": 2, "withoutEmail": 1 }`. - `POST /renewal-offers/:offerId/resend` sends one holder a new invitation and answers `202` with `{ "ok": true }`. The old link stops working. A holder with no email is `422 holder_email_missing`. Emails need SeatLayer checkout: on your own checkout, notices and resend are `409 checkout_mode_not_seatlayer`, and your system tells holders. ### Reports and downloads **GET /v1/seasons/:key/reports/sales**. Authentication: Secret key **GET /v1/seasons/:key/reports/seats**. Authentication: Secret key **GET /v1/seasons/:key/reports/holders.csv**. Authentication: Secret key **GET /v1/seasons/:key/reports/orders.csv**. Authentication: Secret key **GET /v1/seasons/:key/seat-counts**. Authentication: Secret key `GET /reports/sales` is the Reports tab in figures: money per package, channel (`online` or `box_office`) and currency, seats per package and per date, holders, and renewals. It holds totals only, never a buyer. ```json title="200 (trimmed)" { "report": { "season": { "key": "sea_a245…", "name": "Holders", "currency": "GBP", "mode": "live", "checkoutMode": "seatlayer", "timezone": null }, "generatedAt": 1790220682369, "ownCheckout": false, "money": { "rows": [ { "packageId": "spl_97d1…", "packageName": "Full season", "channel": "online", "currency": "GBP", "orders": 1, "refundedOrders": 0, "seats": 2, "grossMinor": 18000, "discountMinor": 0, "refundedMinor": 0, "netMinor": 18000 } ] }, "seats": { "sold": 3, "byPackage": [{ "packageId": "spl_97d1…", "packageName": "Full season", "seats": 3, "holders": 2 }], "byDate": [] }, "holders": { "total": 3, "active": 2, "cancelled": 0, "imported": 1, "importedRenewed": 0, "soldThroughSeatLayer": 1, "soldThroughYourCheckout": 1, "byStatus": { "all": 3, "active": 2, "cancelled": 0, "renewal": 1 } }, "renewals": { "offered": 0, "renewed": 0, "declined": 0, "lapsed": 0, "open": 0, "needsReview": 0, "rate": null }, "downloadLimit": 50000, "unavailable": [] } } ``` A section that could not be read right now is `null` and named in `unavailable`; it is never a wrong zero. `GET /seat-counts` returns the same `seats` block on its own, and `null` when it cannot be read. `GET /reports/seats` counts free seats, per package on every one of its dates still to come, and per date: ```json title="200" { "seats": { "packages": [ { "packageId": "spl_97d1…", "packageName": "Full season", "datesLeft": 3, "state": "ok", "freeEveryDate": 7, "notEveryDate": 0, "renewalHeld": 1 } ], "dates": [{ "eventKey": "ev_3709…", "free": 8 }] } } ``` The two downloads answer `text/csv`, the same files as the dashboard. They are plain HTTP calls, not SDK methods. - `holders.csv` has 17 columns: Name, Email, Reference, Your reference, Seats, Package, Dates active, Paid, Refunded, Currency, Method, Sold through, Order status, Holder status, Renewal state, Renewal deadline, Bought. It takes the same `q` and `status` as the holder list. Past 50,000 holders it is `413 download_too_large`: filter by status. - `orders.csv` has every order SeatLayer took for the season. A season on your own checkout has none: `409 season_sold_elsewhere`. ### Webhooks for these calls | Call | Webhook | |---|---| | A SeatLayer checkout or box office sale is paid | `season.order.paid` | | Refund (full or partial) | `season.order.refunded`; a full refund also sends `season.booking.cancelled` | | Cancel an order without a refund | `season.booking.cancelled` | | Cancel one date | `season.occurrence.returned` | There is no webhook yet for a ticket resend, a renewal email being sent, or a contact change. See [Webhook events](/webhooks/events/#season-orders) for the payloads. ## Amendments, reports, and support **GET /v1/seasons/:key/occurrences**. Authentication: Secret key **POST /v1/seasons/:key/amendments**. Authentication: Secret key **GET /v1/seasons/:key/amendments**. Authentication: Secret key **GET /v1/seasons/:key/amendments/:amendmentId**. Authentication: Secret key **GET /v1/seasons/:key/reports**. Authentication: Secret key **GET /v1/seasons/:key/operations**. Authentication: Secret key **GET /v1/seasons/:key/support-lookups**. Authentication: Secret key **GET /v1/seasons/:key/outbox**. Authentication: Secret key **POST /v1/seasons/:key/outbox/:occurrenceId/replay**. Authentication: Secret key A `reschedule` may change Event date-time or name. It does **not** change Event keys, Plan activation, or `occurrence_set_sha256`. Passing `eventKeys` that would add or remove occurrences is `409 occurrence_membership_immutable`. In the multi-Plan source preview, list occurrences with the exact `planActivationId` query and include that activation in amendment requests when more than one Plan is published; omission is `409 plan_selection_required`. Because Event schedule metadata is global, an Event shared by multiple published Plans fails closed with `409 occurrence_shared_across_plans` until every affected activation can be retained in one amendment audit record. Do not infer this multi-Plan request shape from a released `0.8.0` package. Occurrence rows include nullable `latestAmendment` identity, kind, and classification. Amendment list rows retain the full activation, occurrence set, before/after state, contract outcomes, and allocation outcomes; they are not reduced summaries. `replace` and `cancel_exception` record operator-visible exceptions. They do not silently rewrite the published Plan and they do not refund money. ```http title="reschedule" POST /v1/seasons/sea_…/amendments Authorization: Bearer sk_test_•••••••• Idempotency-Key: night-1-rain Content-Type: application/json { "eventKey": "ev_night_1", "kind": "reschedule", "startsAt": 1790000000000 } ``` That is the `0.8.0` request. Only the multi-Plan source preview adds `planActivationId` to amendment creation. `GET /reports` totals live inventory and catalogue counts. `GET /support-lookups?bookingRef=` returns bookings, offers, and contracts. It never includes a refund. `GET /outbox` is the missed-event feed; `POST /outbox/:occurrenceId/replay` resends the same payload. ## Duplicate to live A test Season can be copied onto new live identities. Sales stay closed. Pass the live Event keys that should replace the test occurrences: ```http title="duplicate" POST /v1/seasons/sea_…/duplicate-to-live Authorization: Bearer sk_test_•••••••• Idempotency-Key: 2026-series-live Content-Type: application/json { "name": "2026 series", "eventKeys": ["ev_live_1", "ev_live_2"] } ``` # Server SDKs > Choose an official SeatLayer server SDK to inspect holds, book inventory, manage Events, Performance Groups, and Seasons, and verify webhooks securely. Source: https://docs.seatlayer.io/server-sdk/ · Markdown: https://docs.seatlayer.io/server-sdk/index.md The server SDK is the trusted half of a Platform integration. It holds the secret key, reads authoritative inventory, and commits booking after your own commerce system has made its payment and order decision. SeatLayer owns chart geometry, live seat state, holds, booking concurrency, inventory records, allocation reporting, and inventory webhooks. Your platform owns buyer identity, tax, payment, the commercial order, tickets or passes, refunds, fulfilment, and customer support. To compare server SDKs with client SDKs and REST, start at [Choose an SDK or API](/sdks/). ## Choose your runtime | Language | Package | Released guide | |---|---|---| | Node.js | `@seatlayer/server@0.8.0` | [Use the Node.js SDK](/server-sdk/node/) | | Python | `seatlayer==0.8.0` | [Use the Python SDK](/server-sdk/python/) | | PHP | `seatlayer/seatlayer-php:0.8.0` | [Use the PHP SDK](/server-sdk/php/) | | Java | `io.seatlayer:seatlayer-java:0.8.0` | [Use the Java SDK](/server-sdk/java/) | | Go | `github.com/seatlayer/seatlayer-go@v0.8.0` | [Use the Go SDK](/server-sdk/go/) | | Ruby | `seatlayer` gem `0.8.0` | [Use the Ruby SDK](/server-sdk/ruby/) | | .NET | `SeatLayer` NuGet `0.8.0` | [Use the .NET SDK](/server-sdk/dotnet/) | Start with the [pinned installation and shared setup](/server-sdk/install/). Each language guide links to its exact public registry package and tagged source. Start there when a package or repository search is how you found SeatLayer; the installed package and its tagged README define the released language surface, while this documentation explains the common booking boundary. ## One contract, three inventory models | Model | Server responsibility | Released server coverage | |---|---|---| | Event | Inspect a temporary hold and book one dated inventory occurrence | Available in all seven SDKs | | Performance Group | Provision and operate one atomic fixed run of 2–8 compatible Events | All 13 secret-key operations in all seven SDKs | | Fixed Renewable Season | Publish a 2–20-Event Plan, book the same seat, manage Seat Rights and renewals | All 48 frozen `0.8.0` operations in all seven SDKs | Read [Choose an inventory model](/start/inventory-models/) before modelling a multi-date product. A Performance Group is not a Season, and neither replaces your commercial order system. ## What belongs on the server - Create and publish charts, templates, Events, Workspaces, and private sales channels. - Mint short-lived buyer or staff sessions after authenticating your own user. - Retrieve an opaque buyer hold and derive the inventory truth from the server response. - Book once with a stable `bookingRef`, then reconcile an unknown outcome. - Coordinate Performance Group and Season catalogue/lifecycle operations. - Verify webhook signatures and deduplicate delivered occurrences. Never send the secret key to a buyer surface. Never trust a browser-supplied price or total. Browser and native SDKs select and hold; the trusted server inspects and books. ## Reliability contract Every official `0.8.0` SDK retries reads and the same 14 mutations backed by exact HTTP response replay: five core provisioning operations, Performance Group create, and eight Season create/change operations. Holds, bookings, lifecycle changes, show-once secrets, and raw mutations remain single-attempt. Supplying an idempotency key does not turn an unsupported mutation into an automatically retryable one. Use the shared [errors, retries, and idempotency guide](/server-sdk/reliability/) and the language page's exact error names. Use the [webhook verification guide](/server-sdk/webhooks/) for the receiver boundary. ## Reference and escape hatch Use three references together: 1. This language guide gives the exact resource namespace and common methods. 2. The language page's version-pinned resource source gives every `0.8.0` method name and signature, including all 48 Season operations. 3. The [operation support matrix](/server-api/operation-support/) maps those methods to wire operation IDs; [OpenAPI 3.1](/openapi.json) describes the current HTTP schemas. The installed package's tagged source remains authoritative for that package. Operation-level support does not promise that an older typed request exposes every later additive field. In particular, all seven `0.8.0` SDKs expose Performance Group create, but their typed create shape does not expose the newer REST `inclusionMode` field. Use the REST/raw-request path for `flexible_dates` until a typed package release adds it. A source-only candidate is never a published package feature. # .NET server SDK > Install SeatLayer 0.8.0 from NuGet, inspect an authoritative hold, book with a stable reference, and use the exact .NET service namespaces. Source: https://docs.seatlayer.io/server-sdk/dotnet/ · Markdown: https://docs.seatlayer.io/server-sdk/dotnet/index.md Use the `SeatLayer` package from a trusted .NET backend. Version `0.8.0` requires .NET 8 or newer and has no package dependencies. ## Install and create the client ```bash dotnet add package SeatLayer --version 0.8.0 ``` ```csharp using SeatLayer; var client = new SeatLayerClient( Environment.GetEnvironmentVariable("SEATLAYER_SECRET_KEY")! ); if (client.Mode != "test") { throw new InvalidOperationException("Use a test key while integrating."); } ``` `SeatLayerClient` is thread-safe and its `HttpClient` is designed to be long-lived. Register one singleton instead of constructing a client per request. Keep the secret key in server configuration. ## Exact service surface The client exposes `Charts`, `Channels`, `Events`, `Inventory`, `PerformanceGroups`, `Sessions`, `Seasons`, `Templates`, `Webhooks`, and `Workspaces`. `client.PerformanceGroups` contains all 13 released operations: `ListAsync`, `CreateAsync`, `RetrieveAsync`, `DeleteAsync`, `ActivateAsync`, `CloseAsync`, `RetrieveLifecycleAsync`, `CreateBuyerAccessSessionAsync`, `ListBuyerAccessSessionsAsync`, `RevokeBuyerAccessSessionAsync`, `RetrieveHoldAsync`, `BookHoldAsync`, and `RetrieveBookingAsync`. `client.Seasons` contains all 48 frozen `0.8.0` operations. .NET retains the Season prefix and adds `Async`, for example `CreateSeasonAsync`, `PublishSeasonPlanAsync`, `BookSeasonHoldAsync`, `CommitSeasonRenewalOfferAsync`, and `ExportSeasonSupportSnapshotAsync`. The service covers catalogue, lifecycle, Plans and sales, buyer handoff, holder import, renewals, amendments, reports, recovery, outbox, audit, and support export. Source-only occurrence return/reclaim operations are not part of `0.8.0`. The same 13 Performance Group and 48 Season wire operations are released in all seven official server SDKs; only language naming differs. The `0.8.0` typed `PerformanceGroups.CreateAsync` request covers the default `fixed` policy but does not expose the newer REST `inclusionMode` field. Use the [REST/raw-request path](/server-api/performance-groups/#create-a-run) for `flexible_dates`; the other typed create fields and all 13 operations remain available. ## Inspect and book an Event hold ```csharp using System.Collections.Generic; using System.Linq; var eventKey = Environment.GetEnvironmentVariable("SEATLAYER_EVENT_KEY")!; var holdId = Environment.GetEnvironmentVariable("SEATLAYER_HOLD_ID")!; var bookingRef = Environment.GetEnvironmentVariable("ORDER_ID")!; var hold = await client.Inventory.RetrieveHoldAsync(eventKey, holdId); var labels = ((List)hold["items"]!) .OfType>() .Select(item => (string)item["label"]!) .ToArray(); // Price from hold["items"] and authorize payment in your commerce system here. var booking = await client.Inventory.BookAsync(eventKey, new BookRequest { HoldId = holdId, Labels = labels, BookingRef = bookingRef, }); ``` Do not calculate a charge from browser-submitted totals. `bookingRef` is your commerce system's immutable join to SeatLayer inventory. If the response is lost, call `client.Inventory.RetrieveBookingAsync(eventKey, bookingRef)` before deciding whether to repeat the exact same hold, labels, and reference. ## Errors, retries, and pagination Branch on `SeatLayerAuthException`, `SeatLayerConflictException`, and `SeatLayerRateLimitException`. Use `IsModeMismatch`, `IsSoldOut`, and `RetryAfterSeconds` for those common branches. Reads retry `408`, `429`, and `5xx` with exponential backoff and full jitter. These 14 methods also retry with one exact key: `Charts.CreateAsync`, `Charts.CopyAsync`, `Templates.InstantiateTemplateAsync`, `Events.CreateAsync`, `Workspaces.CreateAsync`, `PerformanceGroups.CreateAsync`, and `Seasons.CreateSeasonAsync`, `UpdateSeasonAsync`, `DeleteSeasonAsync`, `CreateSeasonPlanAsync`, `DuplicateSeasonToLiveAsync`, `CreateSeasonHolderImportAsync`, `CreateSeasonRenewalOffersAsync`, and `CreateSeasonAmendmentAsync`. Other mutations and raw `SendAsync` mutations are single-attempt. A cancelled `CancellationToken` stops retry work immediately. Large lists use `IAsyncEnumerable`: ```csharp await foreach (var seatEvent in client.Events.ListAllAsync()) { await SyncEventAsync(seatEvent); } ``` That async stream is Event-specific. Performance Group and top-level Season list methods return one `Page`; pass its opaque `NextCursor` into the next list request until it is null. See [errors, retries, and idempotency](/server-sdk/reliability/) for the shared contract. ## Verify and continue - [NuGet package `0.8.0`](https://www.nuget.org/packages/SeatLayer/0.8.0) - [tagged source and package README](https://github.com/seatlayer/seatlayer-dotnet/tree/v0.8.0) - [`PerformanceGroups` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-dotnet/blob/v0.8.0/src/SeatLayer/PerformanceGroupServices.cs) - [all `Seasons` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-dotnet/blob/v0.8.0/src/SeatLayer/SeasonServices.cs) - [server API operation support](/server-api/operation-support/) - [OpenAPI 3.1 reference](/openapi.json) Verify the flow with a test key, a test Event, and a real test hold. Then add [webhook verification](/server-sdk/webhooks/), choose the correct [inventory model](/start/inventory-models/), and complete the [going-live checklist](/start/going-live/). # Go server SDK > Install seatlayer-go v0.8.0, inspect an authoritative hold, book with a stable reference, and use the exact Go service namespaces. Source: https://docs.seatlayer.io/server-sdk/go/ · Markdown: https://docs.seatlayer.io/server-sdk/go/index.md Use `github.com/seatlayer/seatlayer-go` from a trusted Go backend. Version `v0.8.0` requires Go 1.23 or newer and uses only the standard library. ## Install and create the client ```bash go get github.com/seatlayer/seatlayer-go@v0.8.0 ``` ```go title="inside application startup" import ( "errors" "os" seatlayer "github.com/seatlayer/seatlayer-go" ) client, err := seatlayer.New(os.Getenv("SEATLAYER_SECRET_KEY")) if err != nil { return err } if client.Mode() != "test" { return errors.New("use a test key while integrating") } ``` Import the module as `seatlayer`; the package name intentionally differs from the final repository segment. Keep the secret key in the server environment. ## Exact service surface The client exposes `Charts`, `Channels`, `Events`, `Inventory`, `PerformanceGroups`, `Sessions`, `Seasons`, `Templates`, `Webhooks`, and `Workspaces`. `client.PerformanceGroups` contains all 13 released operations: `List`, `Create`, `Retrieve`, `Delete`, `Activate`, `Close`, `RetrieveLifecycle`, `CreateBuyerAccessSession`, `ListBuyerAccessSessions`, `RevokeBuyerAccessSession`, `RetrieveHold`, `BookHold`, and `RetrieveBooking`. `client.Seasons` contains all 48 frozen `v0.8.0` operations. Go drops the redundant Season prefix inside the service, for example `Create`, `PublishPlan`, `BookHold`, `CommitRenewalOffer`, and `ExportSupportSnapshot`. The service covers catalogue, lifecycle, Plans and sales, buyer handoff, holder import, renewals, amendments, reports, recovery, outbox, audit, and support export. Source-only occurrence return/reclaim operations are not part of `v0.8.0`. The same 13 Performance Group and 48 Season wire operations are released in all seven official server SDKs; only language naming differs. The `v0.8.0` typed `PerformanceGroups.Create` parameters cover the default `fixed` policy but do not expose the newer REST `inclusionMode` field. Use the [REST/raw-request path](/server-api/performance-groups/#create-a-run) for `flexible_dates`; the other typed create fields and all 13 operations remain available. ## Inspect and book an Event hold Every API method accepts a `context.Context`. Cancelling it stops retry work immediately. ```go eventKey := os.Getenv("SEATLAYER_EVENT_KEY") holdID := os.Getenv("SEATLAYER_HOLD_ID") bookingRef := os.Getenv("ORDER_ID") hold, err := client.Inventory.RetrieveHold(ctx, eventKey, holdID) if err != nil { return err } if len(hold.Items) == 0 { return errors.New("the hold contains no inventory") } labels := make([]string, 0, len(hold.Items)) for _, item := range hold.Items { labels = append(labels, item.Label) } // Price from hold.Items and authorize payment in your commerce system here. _, err = client.Inventory.Book(ctx, eventKey, seatlayer.BookParams{ HoldID: holdID, Labels: labels, BookingRef: bookingRef, }) return err ``` Do not calculate a charge from browser-submitted totals. `BookingRef` is your commerce system's immutable join to SeatLayer inventory. If the response is lost, call `client.Inventory.RetrieveBooking(ctx, eventKey, bookingRef)` before deciding whether to repeat the exact same hold, labels, and reference. ## Errors, retries, and iteration Errors are values. Use `errors.As` with `*seatlayer.AuthError`, `*seatlayer.ConflictError`, and `*seatlayer.RateLimitError`; `SoldOut()` is the explicit sold-out business branch. Reads retry `408`, `429`, and `5xx` with exponential backoff and full jitter. These 14 methods also retry with one exact key: `Charts.Create`, `Charts.Copy`, `Templates.InstantiateTemplate`, `Events.Create`, `Workspaces.Create`, `PerformanceGroups.Create`, and `Seasons.Create`, `Update`, `Delete`, `CreatePlan`, `DuplicateToLive`, `CreateHolderImport`, `CreateRenewalOffers`, and `CreateAmendment`. Other mutations and raw `Do` mutations are single-attempt. `Retry-After` takes precedence when present. Large lists use Go 1.23 range-over-function iteration: ```go for event, err := range client.Events.All(ctx, nil) { if err != nil { return err } syncEvent(event) } ``` That iterator is Event-specific. Performance Group and top-level Season list methods return one cursor page; pass the response's opaque `NextCursor` in the next list options until it is empty. See [errors, retries, and idempotency](/server-sdk/reliability/) for the shared contract. ## Verify and continue - [Go package reference `v0.8.0`](https://pkg.go.dev/github.com/seatlayer/seatlayer-go@v0.8.0) - [tagged source and package README](https://github.com/seatlayer/seatlayer-go/tree/v0.8.0) - [`PerformanceGroups` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-go/blob/v0.8.0/performance_groups.go) - [all `Seasons` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-go/blob/v0.8.0/seasons.go) - [current API operation support](/server-api/operation-support/) - [OpenAPI 3.1 reference](/openapi.json) Verify the flow with a test key, a test Event, and a real test hold. Then add [webhook verification](/server-sdk/webhooks/), choose the correct [inventory model](/start/inventory-models/), and complete the [going-live checklist](/start/going-live/). # Install a server SDK > Install a pinned SeatLayer 0.8.0 server SDK, keep the secret key on your backend, and choose the exact language guide for your stack. Source: https://docs.seatlayer.io/server-sdk/install/ · Markdown: https://docs.seatlayer.io/server-sdk/install/index.md SeatLayer publishes official server SDKs for seven languages. Every package on this page is pinned to the released `0.8.0` contract so an install command, a method example, and the linked source all describe the same artifact. > **Server-side only** > > These packages authenticate with an `sk_test_…` or `sk_live_…` secret key. > Never bundle one into browser code, a mobile application, or anything a > ticket buyer can inspect. Buyer surfaces receive short-lived, scoped tokens > minted by your trusted backend. ## Choose and install | Runtime | Pinned install | Language guide | |---|---|---| | Node.js | `npm install @seatlayer/server@0.8.0` | [Node.js](/server-sdk/node/) | | Python | `pip install seatlayer==0.8.0` | [Python](/server-sdk/python/) | | PHP | `composer require seatlayer/seatlayer-php:0.8.0` | [PHP](/server-sdk/php/) | | Java | `io.seatlayer:seatlayer-java:0.8.0` | [Java](/server-sdk/java/) | | Go | `go get github.com/seatlayer/seatlayer-go@v0.8.0` | [Go](/server-sdk/go/) | | Ruby | `gem install seatlayer -v 0.8.0` | [Ruby](/server-sdk/ruby/) | | .NET | `dotnet add package SeatLayer --version 0.8.0` | [.NET](/server-sdk/dotnet/) | The [server SDK overview](/server-sdk/) explains which work belongs in these trusted packages. Each language guide includes its runtime requirement, exact namespace spelling, error types, retry behavior, source, registry, and a minimal inspect-and-book flow. ## Set the secret key Store the key in your server's secret manager and expose it to the application as an environment variable. The placeholder below is deliberately not a usable credential: ```bash export SEATLAYER_SECRET_KEY=sk_test_replace_with_your_key ``` Use a test key while integrating. A test key can act only on test resources; a live key can act only on live resources. Crossing those modes returns `403 mode_mismatch`. A publishable `pk_…` key is not accepted by a server client. ## Create a client ### Node.js ```ts import { SeatLayer } from '@seatlayer/server'; const seatlayer = new SeatLayer(process.env.SEATLAYER_SECRET_KEY!); ``` ### Python ```python import os from seatlayer import SeatLayer seatlayer = SeatLayer(os.environ["SEATLAYER_SECRET_KEY"]) ``` ### PHP ```php use SeatLayer\SeatLayer; $seatlayer = new SeatLayer(getenv('SEATLAYER_SECRET_KEY')); ``` ### Java ```java import io.seatlayer.SeatLayer; SeatLayer seatlayer = new SeatLayer(System.getenv("SEATLAYER_SECRET_KEY")); ``` ### Go ```go client, err := seatlayer.New(os.Getenv("SEATLAYER_SECRET_KEY")) if err != nil { return err } ``` ### Ruby ```ruby require "seatlayer" client = SeatLayer::Client.new(ENV.fetch("SEATLAYER_SECRET_KEY")) ``` ### .NET ```csharp using SeatLayer; var client = new SeatLayerClient( Environment.GetEnvironmentVariable("SEATLAYER_SECRET_KEY")! ); ``` ## Resource namespace map The capability is shared; the spelling follows each language. These are the exact top-level resources in `0.8.0`: | Language | Exact client resources | |---|---| | Node.js | `charts`, `channels`, `events`, `inventory`, `performanceGroups`, `sessions`, `seasons`, `templates`, `webhooks`, `workspaces` | | Python | `charts`, `channels`, `events`, `inventory`, `performance_groups`, `sessions`, `seasons`, `templates`, `webhooks`, `workspaces` | | PHP | `charts`, `channels`, `events`, `inventory`, `performanceGroups`, `sessions`, `seasons`, `templates`, `webhooks`, `workspaces` | | Java | `charts()`, `channels()`, `events()`, `inventory()`, `performanceGroups()`, `sessions()`, `seasons()`, `templates()`, `webhooks()`, `workspaces()` | | Go | `Charts`, `Channels`, `Events`, `Inventory`, `PerformanceGroups`, `Sessions`, `Seasons`, `Templates`, `Webhooks`, `Workspaces` | | Ruby | `charts`, `channels`, `events`, `inventory`, `performance_groups`, `sessions`, `seasons`, `templates`, `webhooks`, `workspaces` | | .NET | `Charts`, `Channels`, `Events`, `Inventory`, `PerformanceGroups`, `Sessions`, `Seasons`, `Templates`, `Webhooks`, `Workspaces` | > **The multi-event resources are released in every server SDK** > > All seven `0.8.0` packages expose the same frozen 13-operation Performance > Group contract and the same frozen 48-operation Season contract. This server > parity does not imply a native mobile `SeasonPicker`; React Native, Flutter, > iOS, and Android do not currently expose Season v1 buyer support. ## Verify the setup Before handling a real checkout: 1. Start with `sk_test_…` and confirm the client reports `test` mode. 2. Choose your language guide above and run its inspect-and-book example against a test Event and a hold created by a buyer surface. 3. Store your own stable commercial order ID as `bookingRef`. 4. Add [typed error handling and operation-safe retries](/server-sdk/reliability/). 5. Verify signed events with the [webhook SDK helpers](/server-sdk/webhooks/). 6. Complete the [going-live checklist](/start/going-live/) before changing to a live key. Do not calculate a charge from browser-submitted labels, quantities, or totals. For an Event checkout, retrieve the hold on your server and use its retained items as the inventory authority. Fixed Renewable Season holds intentionally contain inventory identity rather than a package price; your trusted commerce system owns that price. ## Continue - Choose [Event, Performance Group, or Fixed Renewable Season](/start/inventory-models/). - Follow the [booking flow](/server-api/booking/) for a single Event. - Integrate a [Performance Group](/integrations/performance-groups/). - Build a [Fixed Renewable Season](/server-api/seasons/). - Use the tagged package source for installed-method truth. The generated [operation support matrix](/server-api/operation-support/) separately reports current source, verification, and published-artifact status. # Java server SDK > Install io.seatlayer:seatlayer-java 0.8.0, inspect an authoritative hold, book with a stable reference, and use the exact Java resource namespaces. Source: https://docs.seatlayer.io/server-sdk/java/ · Markdown: https://docs.seatlayer.io/server-sdk/java/index.md Use `io.seatlayer:seatlayer-java` from a trusted Java or Kotlin backend. Version `0.8.0` requires Java 17 or newer and uses JDK HTTP and crypto without runtime dependencies such as Jackson or OkHttp. ## Install and create the client ```xml io.seatlayer seatlayer-java 0.8.0 ``` Gradle Kotlin DSL: ```kotlin implementation("io.seatlayer:seatlayer-java:0.8.0") ``` ```java import io.seatlayer.SeatLayer; SeatLayer seatlayer = new SeatLayer(System.getenv("SEATLAYER_SECRET_KEY")); if (!"test".equals(seatlayer.mode())) { throw new IllegalStateException("Use a test key while integrating."); } ``` Keep `SEATLAYER_SECRET_KEY` in the server environment. Never expose the client or its `sk_…` credential to browser or mobile code. ## Exact resource surface The client exposes `charts()`, `channels()`, `events()`, `inventory()`, `performanceGroups()`, `sessions()`, `seasons()`, `templates()`, `webhooks()`, and `workspaces()`. `seatlayer.performanceGroups()` contains all 13 released operations: `list`, `create`, `retrieve`, `delete`, `activate`, `close`, `retrieveLifecycle`, `createBuyerAccessSession`, `listBuyerAccessSessions`, `revokeBuyerAccessSession`, `retrieveHold`, `bookHold`, and `retrieveBooking`. `seatlayer.seasons()` contains all 48 frozen `0.8.0` operations. Java retains the Season prefix, for example `createSeason`, `publishSeasonPlan`, `bookSeasonHold`, `commitSeasonRenewalOffer`, and `exportSeasonSupportSnapshot`. The resource covers catalogue, lifecycle, Plans and sales, buyer handoff, holder import, renewals, amendments, reports, recovery, outbox, audit, and support export. Source-only occurrence return/reclaim operations are not part of `0.8.0`. The same 13 Performance Group and 48 Season wire operations are released in all seven official server SDKs; only language naming differs. The `0.8.0` typed `performanceGroups().create(...)` overload covers the default `fixed` policy but does not expose the newer REST `inclusionMode` field. Use the [REST/raw-request path](/server-api/performance-groups/#create-a-run) for `flexible_dates`; the other typed create fields and all 13 operations remain available. ## Inspect and book an Event hold ```java import java.util.List; import java.util.Map; String eventKey = System.getenv("SEATLAYER_EVENT_KEY"); String holdId = System.getenv("SEATLAYER_HOLD_ID"); String bookingRef = System.getenv("ORDER_ID"); Map hold = seatlayer.inventory().retrieveHold(eventKey, holdId); @SuppressWarnings("unchecked") List> items = (List>) hold.get("items"); List labels = items.stream().map(item -> (String) item.get("label")).toList(); // Price from items and authorize payment in your commerce system here. Map booking = seatlayer.inventory().book(eventKey, Map.of( "holdId", holdId, "labels", labels, "bookingRef", bookingRef )); ``` Do not calculate a charge from browser-submitted totals. `bookingRef` is your commerce system's immutable join to SeatLayer inventory. If the response is lost, call `seatlayer.inventory().retrieveBooking(eventKey, bookingRef)` before deciding whether to repeat the exact same hold, labels, and reference. ## Errors, retries, and pagination Branch on `SeatLayerAuthException`, `SeatLayerConflictException`, and `SeatLayerRateLimitException`. Use `isModeMismatch()`, `isSoldOut()`, and `retryAfterSeconds()` for those common branches. Reads retry connection failures, `408`, `429`, and `5xx` with exponential backoff and full jitter. These 14 methods also retry with one exact key: `charts().create`, `charts().copy`, `templates().instantiateTemplate`, `events().create`, `workspaces().create`, `performanceGroups().create`, and `seasons().createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`, `duplicateSeasonToLive`, `createSeasonHolderImport`, `createSeasonRenewalOffers`, and `createSeasonAmendment`. Other mutations are single-attempt, even when an overload forwards a caller-supplied key. Large lists are lazy `Iterable` values: ```java for (Map event : seatlayer.events().listAll()) { syncEvent(event); } ``` That `Iterable` is Event-specific. Performance Group and top-level Season list methods return one cursor page; pass the opaque `nextCursor` into the next list call until it is absent. See [errors, retries, and idempotency](/server-sdk/reliability/) for the shared contract. ## Verify and continue - [Maven Central artifact `0.8.0`](https://central.sonatype.com/artifact/io.seatlayer/seatlayer-java/0.8.0) - [tagged source and package README](https://github.com/seatlayer/seatlayer-java/tree/v0.8.0) - [`performanceGroups()` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-java/blob/v0.8.0/src/main/java/io/seatlayer/PerformanceGroups.java) - [all `seasons()` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-java/blob/v0.8.0/src/main/java/io/seatlayer/Seasons.java) - [server API operation support](/server-api/operation-support/) - [OpenAPI 3.1 reference](/openapi.json) Verify the flow with a test key, a test Event, and a real test hold. Then add [webhook verification](/server-sdk/webhooks/), choose the correct [inventory model](/start/inventory-models/), and complete the [going-live checklist](/start/going-live/). # Node.js server SDK > Install @seatlayer/server 0.8.0, inspect an authoritative hold, book with a stable reference, and use the exact Node.js resource namespaces. Source: https://docs.seatlayer.io/server-sdk/node/ · Markdown: https://docs.seatlayer.io/server-sdk/node/index.md Use `@seatlayer/server` from Node.js or TypeScript code running in a trusted backend. Version `0.8.0` requires Node.js 20.19.4 or newer and has no runtime dependencies. ## Install and create the client ```bash npm install @seatlayer/server@0.8.0 ``` ```ts import { SeatLayer } from '@seatlayer/server'; const seatlayer = new SeatLayer(process.env.SEATLAYER_SECRET_KEY!); if (seatlayer.mode !== 'test') { throw new Error('Use a test key while integrating.'); } ``` Keep `SEATLAYER_SECRET_KEY` in the server environment. Never expose the client or its `sk_…` credential to browser or mobile code. ## Exact resource surface The client exposes `charts`, `channels`, `events`, `inventory`, `performanceGroups`, `sessions`, `seasons`, `templates`, `webhooks`, and `workspaces`. `seatlayer.performanceGroups` contains all 13 released operations: `listPerformanceGroups`, `createPerformanceGroup`, `retrievePerformanceGroup`, `deletePerformanceGroup`, `activatePerformanceGroup`, `closePerformanceGroup`, `retrievePerformanceGroupLifecycle`, `createPerformanceGroupBuyerAccessSession`, `listPerformanceGroupBuyerAccessSessions`, `revokePerformanceGroupBuyerAccessSession`, `retrievePerformanceGroupHold`, `bookPerformanceGroupHold`, and `retrievePerformanceGroupBooking`. `seatlayer.seasons` contains all 48 frozen `0.8.0` operations across catalogue, lifecycle, Plans and sales, buyer handoff, holder import, renewals, amendments, reports, recovery, outbox, audit, and support export. Exact method names retain the domain prefix, for example `createSeason`, `publishSeasonPlan`, `bookSeasonHold`, `commitSeasonRenewalOffer`, and `exportSeasonSupportSnapshot`. Source-only occurrence return/reclaim methods are not part of the `0.8.0` package. The same 13 Performance Group and 48 Season wire operations are released in all seven official server SDKs; only language naming differs. The `0.8.0` typed `createPerformanceGroup` parameters cover the default `fixed` policy but do not expose the newer REST `inclusionMode` field. Use the [REST/raw-request path](/server-api/performance-groups/#create-a-run) when you need `flexible_dates`; this field-level caveat does not reduce the SDK's 13 operation coverage. ## Inspect and book an Event hold The buyer surface gives your backend an opaque `holdId`. Retrieve it from SeatLayer before calculating the order in your trusted commerce system, then book once with your own stable order identifier: ```ts const eventKey = process.env.SEATLAYER_EVENT_KEY!; const holdId = process.env.SEATLAYER_HOLD_ID!; const bookingRef = process.env.ORDER_ID!; const hold = await seatlayer.inventory.retrieveHold(eventKey, holdId); const labels = hold.items.map((item) => item.label); // Price from hold.items and authorize payment in your commerce system here. const booking = await seatlayer.inventory.book(eventKey, { holdId, labels, bookingRef, }); ``` Do not calculate a charge from browser-submitted totals. A `bookingRef` is your commerce system's immutable join to SeatLayer inventory, not a random retry ID. If the booking response is lost, call `seatlayer.inventory.retrieveBooking(eventKey, bookingRef)` before deciding whether to repeat the exact same hold, labels, and reference. ## Errors, retries, and pagination Branch on `SeatLayerAuthError`, `SeatLayerConflictError`, and `SeatLayerRateLimitError`. Mode mismatch is available as `SeatLayerAuthError.isModeMismatch`; sold out is available as `SeatLayerConflictError.isSoldOut`. Reads retry `408`, `429`, and `5xx` responses with backoff. These 14 methods also retry with one exact `Idempotency-Key`: `charts.create`, `charts.copy`, `templates.instantiateTemplate`, `events.create`, `workspaces.create`, `performanceGroups.createPerformanceGroup`, and the Season methods `createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`, `duplicateSeasonToLive`, `createSeasonHolderImport`, `createSeasonRenewalOffers`, and `createSeasonAmendment`. Holds, bookings, lifecycle changes, show-once secrets, and raw mutations are single-attempt. Large lists use async iteration: ```ts for await (const event of seatlayer.events.listAll()) { await syncEvent(event); } ``` That helper is Event-specific. Performance Group and top-level Season list methods return one cursor page; pass the response's opaque `nextCursor` back as `cursor` to the same list method until it is absent. See [errors, retries, and idempotency](/server-sdk/reliability/) for the full contract. ## Verify and continue - [npm package `0.8.0`](https://www.npmjs.com/package/@seatlayer/server/v/0.8.0) - [tagged source and package README](https://github.com/seatlayer/seatlayer-node/tree/v0.8.0) - [`performanceGroups` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-node/blob/v0.8.0/src/resources/performance-groups.ts) - [all `seasons` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-node/blob/v0.8.0/src/resources/seasons.ts) - [server API operation support](/server-api/operation-support/) - [OpenAPI 3.1 reference](/openapi.json) Verify the flow with a test key, a test Event, and a real test hold. Then add [webhook verification](/server-sdk/webhooks/), choose the correct [inventory model](/start/inventory-models/), and complete the [going-live checklist](/start/going-live/). # PHP server SDK > Install seatlayer/seatlayer-php 0.8.0, inspect an authoritative hold, book with a stable reference, and use the exact PHP resource namespaces. Source: https://docs.seatlayer.io/server-sdk/php/ · Markdown: https://docs.seatlayer.io/server-sdk/php/index.md Use `seatlayer/seatlayer-php` from a trusted PHP backend. Version `0.8.0` requires PHP 8.1 or newer with `ext-curl`, `ext-json`, and `ext-hash`, and has no Composer dependencies. ## Install and create the client ```bash composer require seatlayer/seatlayer-php:0.8.0 ``` ```php use SeatLayer\SeatLayer; $seatlayer = new SeatLayer(getenv('SEATLAYER_SECRET_KEY')); if ($seatlayer->mode !== 'test') { throw new RuntimeException('Use a test key while integrating.'); } ``` Keep `SEATLAYER_SECRET_KEY` in the server environment. Never expose the client or its `sk_…` credential to browser or mobile code. ## Exact resource surface The client exposes `charts`, `channels`, `events`, `inventory`, `performanceGroups`, `sessions`, `seasons`, `templates`, `webhooks`, and `workspaces`. `$seatlayer->performanceGroups` contains all 13 released operations: `list`, `create`, `retrieve`, `delete`, `activate`, `close`, `retrieveLifecycle`, `createBuyerAccessSession`, `listBuyerAccessSessions`, `revokeBuyerAccessSession`, `retrieveHold`, `bookHold`, and `retrieveBooking`. `$seatlayer->seasons` contains all 48 frozen `0.8.0` operations. PHP retains the Season prefix, for example `createSeason`, `publishSeasonPlan`, `bookSeasonHold`, `commitSeasonRenewalOffer`, and `exportSeasonSupportSnapshot`. The resource covers catalogue, lifecycle, Plans and sales, buyer handoff, holder import, renewals, amendments, reports, recovery, outbox, audit, and support export. Source-only occurrence return/reclaim operations are not part of `0.8.0`. The same 13 Performance Group and 48 Season wire operations are released in all seven official server SDKs; only language naming differs. The `0.8.0` typed `$seatlayer->performanceGroups->create(...)` signature covers the default `fixed` policy but does not expose the newer REST `inclusionMode` field. Use the [REST/raw-request path](/server-api/performance-groups/#create-a-run) for `flexible_dates`; the other typed create fields and all 13 operations remain available. ## Inspect and book an Event hold ```php $eventKey = getenv('SEATLAYER_EVENT_KEY'); $holdId = getenv('SEATLAYER_HOLD_ID'); $bookingRef = getenv('ORDER_ID'); $hold = $seatlayer->inventory->retrieveHold($eventKey, $holdId); $labels = array_column($hold['items'], 'label'); // Price from $hold['items'] and authorize payment in your commerce system here. $booking = $seatlayer->inventory->book( $eventKey, holdId: $holdId, labels: $labels, bookingRef: $bookingRef, ); ``` Do not calculate a charge from browser-submitted totals. `bookingRef` is your commerce system's immutable join to SeatLayer inventory. If the booking response is lost, call `$seatlayer->inventory->retrieveBooking($eventKey, $bookingRef)` before deciding whether to repeat the exact same hold, labels, and reference. ## Errors, retries, and pagination Branch on `SeatLayer\AuthException`, `SeatLayer\ConflictException`, and `SeatLayer\RateLimitException`. Use `isSoldOut()` for the sold-out business outcome and `retryAfterSeconds` for rate-limit timing. Reads retry connection failures, `408`, `429`, and `5xx` with exponential backoff and full jitter. These 14 methods also retry with one exact `Idempotency-Key`: `charts->create`, `charts->copy`, `templates->instantiateTemplate`, `events->create`, `workspaces->create`, `performanceGroups->create`, and `seasons->createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`, `duplicateSeasonToLive`, `createSeasonHolderImport`, `createSeasonRenewalOffers`, and `createSeasonAmendment`. Holds, bookings, lifecycle changes, show-once credentials, and raw mutations are single-attempt. Large lists use a generator: ```php foreach ($seatlayer->events->listAll() as $event) { syncEvent($event); } ``` That generator is Event-specific. Performance Group and top-level Season list methods return one cursor page; pass the response's opaque `nextCursor` back as `cursor` until it is absent. See [errors, retries, and idempotency](/server-sdk/reliability/) for the shared contract. ## Verify and continue - [Packagist package](https://packagist.org/packages/seatlayer/seatlayer-php#0.8.0) - [tagged source and package README](https://github.com/seatlayer/seatlayer-php/tree/v0.8.0) - [`performanceGroups` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-php/blob/v0.8.0/src/Resources/PerformanceGroups.php) - [all `seasons` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-php/blob/v0.8.0/src/Resources/Seasons.php) - [server API operation support](/server-api/operation-support/) - [OpenAPI 3.1 reference](/openapi.json) Verify the flow with a test key, a test Event, and a real test hold. Then add [webhook verification](/server-sdk/webhooks/), choose the correct [inventory model](/start/inventory-models/), and complete the [going-live checklist](/start/going-live/). # Python server SDK > Install seatlayer 0.8.0, inspect an authoritative hold, book with a stable reference, and use the exact Python resource namespaces. Source: https://docs.seatlayer.io/server-sdk/python/ · Markdown: https://docs.seatlayer.io/server-sdk/python/index.md Use the `seatlayer` package from a trusted Python backend. Version `0.8.0` requires Python 3.10 or newer and has no runtime dependencies. ## Install and create the client ```bash pip install seatlayer==0.8.0 ``` ```python import os from seatlayer import SeatLayer seatlayer = SeatLayer(os.environ["SEATLAYER_SECRET_KEY"]) if seatlayer.mode != "test": raise RuntimeError("Use a test key while integrating.") ``` Keep `SEATLAYER_SECRET_KEY` in the server environment. Never expose the client or its `sk_…` credential to browser or mobile code. ## Exact resource surface The client exposes `charts`, `channels`, `events`, `inventory`, `performance_groups`, `sessions`, `seasons`, `templates`, `webhooks`, and `workspaces`. `seatlayer.performance_groups` contains all 13 released operations: `list`, `create`, `retrieve`, `delete`, `activate`, `close`, `retrieve_lifecycle`, `create_buyer_access_session`, `list_buyer_access_sessions`, `revoke_buyer_access_session`, `retrieve_hold`, `book_hold`, and `retrieve_booking`. `seatlayer.seasons` contains all 48 frozen `0.8.0` operations. Python keeps the Season prefix and uses snake case, for example `create_season`, `publish_season_plan`, `book_season_hold`, `commit_season_renewal_offer`, and `export_season_support_snapshot`. The resource covers catalogue, lifecycle, Plans and sales, buyer handoff, holder import, renewals, amendments, reports, recovery, outbox, audit, and support export. Source-only occurrence return/reclaim operations are not part of `0.8.0`. The same 13 Performance Group and 48 Season wire operations are released in all seven official server SDKs; only language naming differs. The `0.8.0` typed `performance_groups.create` parameters cover the default `fixed` policy but do not expose the newer REST `inclusionMode` field. Use the [REST/raw-request path](/server-api/performance-groups/#create-a-run) for `flexible_dates`; the other typed create fields and all 13 operations remain available. ## Inspect and book an Event hold ```python event_key = os.environ["SEATLAYER_EVENT_KEY"] hold_id = os.environ["SEATLAYER_HOLD_ID"] booking_ref = os.environ["ORDER_ID"] hold = seatlayer.inventory.retrieve_hold(event_key, hold_id) labels = [item["label"] for item in hold["items"]] # Price from hold["items"] and authorize payment in your commerce system here. booking = seatlayer.inventory.book( event_key, hold_id=hold_id, labels=labels, booking_ref=booking_ref, ) ``` Do not calculate a charge from browser-submitted totals. A `booking_ref` is your commerce system's immutable join to SeatLayer inventory. If the booking response is lost, call `seatlayer.inventory.retrieve_booking(event_key, booking_ref)` before deciding whether to repeat the exact same hold, labels, and reference. ## Errors, retries, and pagination Branch on `SeatLayerAuthError`, `SeatLayerConflictError`, and `SeatLayerRateLimitError`. Mode mismatch is `is_mode_mismatch`; sold out is `is_sold_out`; rate-limit timing is `retry_after_seconds`. Reads retry `408`, `429`, and `5xx` responses with backoff. These 14 methods also retry with one exact idempotency key: `charts.create`, `charts.copy`, `templates.instantiate_template`, `events.create`, `workspaces.create`, `performance_groups.create`, and the Season methods `create_season`, `update_season`, `delete_season`, `create_season_plan`, `duplicate_season_to_live`, `create_season_holder_import`, `create_season_renewal_offers`, and `create_season_amendment`. Holds, bookings, lifecycle changes, show-once credentials, and raw mutations are single-attempt. Large lists use an iterator rather than an unbounded array: ```python for event in seatlayer.events.list_all(): sync_event(event) ``` That helper is Event-specific. Performance Group and top-level Season list methods return one cursor page; pass the opaque `nextCursor` value back through the method's `cursor` argument until it is absent. Nullable Event fields distinguish omission from explicit `None`; consult the package README before sending a reset. See [errors, retries, and idempotency](/server-sdk/reliability/) for the shared contract. ## Verify and continue - [PyPI package `0.8.0`](https://pypi.org/project/seatlayer/0.8.0/) - [tagged source and package README](https://github.com/seatlayer/seatlayer-python/tree/v0.8.0) - [`performance_groups` and `seasons` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-python/blob/v0.8.0/src/seatlayer/resources.py) - [server API operation support](/server-api/operation-support/) - [OpenAPI 3.1 reference](/openapi.json) Verify the flow with a test key, a test Event, and a real test hold. Then add [webhook verification](/server-sdk/webhooks/), choose the correct [inventory model](/start/inventory-models/), and complete the [going-live checklist](/start/going-live/). # Errors, retries and idempotency > How the server SDKs classify failures, which operations retry, and how create keys differ from booking references. Source: https://docs.seatlayer.io/server-sdk/reliability/ · Markdown: https://docs.seatlayer.io/server-sdk/reliability/index.md Selling seats is a competition. Two buyers want the same seat, networks drop mid-booking, and a retry that books twice is worse than one that fails. This page covers what the SDKs do about that so you do not have to. ## Errors you branch on A sold-out seat is a business outcome, not an exception to log and forget. The SDKs type failures by what an integration actually does about them. ### Node.js ```ts import { SeatLayerAuthError, SeatLayerConflictError, SeatLayerRateLimitError, } from '@seatlayer/server'; try { await seatlayer.inventory.holdBestAvailable(eventKey, { qty: 6 }); } catch (error) { if (error instanceof SeatLayerConflictError && error.isSoldOut) { return showAlternativeDates(); } if (error instanceof SeatLayerRateLimitError) { return retryAfter(error.retryAfterSeconds); } if (error instanceof SeatLayerAuthError && error.isModeMismatch) { throw new Error('Test key pointed at a live event, or the reverse.'); } throw error; } ``` ### Python ```python from seatlayer import ( SeatLayerAuthError, SeatLayerConflictError, SeatLayerRateLimitError, ) try: seatlayer.inventory.hold_best_available(event_key, qty=6) except SeatLayerConflictError as error: if error.is_sold_out: return show_alternative_dates() raise except SeatLayerRateLimitError as error: return retry_after(error.retry_after_seconds) except SeatLayerAuthError as error: if error.is_mode_mismatch: raise RuntimeError("Test key pointed at a live event, or the reverse.") from error raise ``` ### PHP ```php use SeatLayer\AuthException; use SeatLayer\ConflictException; use SeatLayer\RateLimitException; try { $seatlayer->inventory->holdBestAvailable($eventKey, qty: 6); } catch (ConflictException $error) { if ($error->isSoldOut()) { return showAlternativeDates(); } throw $error; } catch (RateLimitException $error) { return retryAfter($error->retryAfterSeconds); } catch (AuthException $error) { if ($error->isModeMismatch()) { throw new RuntimeException('Test key pointed at a live event, or the reverse.'); } throw $error; } ``` ### Java ```java try { seatlayer.inventory().holdBestAvailable(eventKey, 6); } catch (SeatLayerConflictException e) { if (e.isSoldOut()) { return showAlternativeDates(); } throw e; } catch (SeatLayerRateLimitException e) { return retryAfter(e.retryAfterSeconds()); } catch (SeatLayerAuthException e) { if (e.isModeMismatch()) { throw new IllegalStateException("Test key pointed at a live event, or the reverse."); } throw e; } ``` ### Go ```go _, err := client.Inventory.HoldBestAvailable(ctx, eventKey, seatlayer.BestAvailableParams{Qty: 6}) var conflict *seatlayer.ConflictError var rateLimit *seatlayer.RateLimitError var auth *seatlayer.AuthError switch { case errors.As(err, &conflict) && conflict.SoldOut(): return offerAlternativeDates() // a business outcome, not a bug case errors.As(err, &rateLimit): return retryAfter(rateLimit.RetryAfter) case errors.As(err, &auth) && auth.ModeMismatch(): return errors.New("test key pointed at a live event, or the reverse") case err != nil: return err } ``` ### Ruby ```ruby begin client.inventory.hold_best_available(event_key, qty: 6) rescue SeatLayer::ConflictError => e return show_alternative_dates if e.sold_out? # a business outcome, not a bug raise rescue SeatLayer::RateLimitError => e return retry_after(e.retry_after) rescue SeatLayer::AuthError => e raise "Test key pointed at a live event, or the reverse." if e.mode_mismatch? raise end ``` ### .NET ```csharp try { await client.Inventory.HoldBestAvailableAsync(eventKey, new BestAvailableRequest { Qty = 6 }); } catch (SeatLayerConflictException e) when (e.IsSoldOut) { return OfferAlternativeDates(); // a business outcome, not a bug } catch (SeatLayerRateLimitException e) { return RetryAfter(e.RetryAfterSeconds); } catch (SeatLayerAuthException e) when (e.IsModeMismatch) { throw new InvalidOperationException("Test key pointed at a live event, or the reverse."); } ``` The names in this table are error **families**, not copyable class names. Use the tab for your language above or its language guide for the exact spelling; property casing also follows that language. | Error family | Status | Means | |---|---|---| | Authentication / authorization | 401, 403 | Bad, revoked, forbidden, or wrong-mode key | | Not found | 404 | No such resource *for this organisation* | | Conflict | 409 | Inventory moved, or a guard rejected the change | | Validation | 422 | Understood and rejected | | Rate limit | 429 | Over budget; carries a retry delay when supplied | | Connection | None | Never got an answer: DNS, TLS, socket, timeout | API exceptions expose the HTTP status, machine error slug, parsed body, and request ID using language-appropriate names. The request ID comes from `X-Request-ID`; quote it in support requests. On the wire, `error` is the required operation discriminator. Some legacy responses also include a `code` alias; SDKs resolve the machine slug from `body.code ?? body.error`. Optional `message` and detail fields remain operation-specific, and additive fields are forward-compatible. > **PHP names the slug errorCode** > > In the PHP SDK the machine-readable slug is `$e->errorCode`, not `$e->code`. > PHP's base `Exception` already owns `$code` as an int and a property cannot be > redeclared. Same value, forced rename. > **404 is deliberate on another org's resource** > > Asking for a resource that belongs to someone else answers `404`, never `403`. > A `403` would confirm the thing exists, which is not something one customer > should be able to learn about another. ## Retries Retry policy is operation-specific. Reads retry connection failures, `408`, `429`, and `5xx` with exponential backoff and full jitter. A mutation retries automatically only when the API can replay its original HTTP status and body. | Mutation policy | Operations | SDK behavior | |---|---|---| | Exact header replay | 14 operations listed below | Retries transient failures with one reused `Idempotency-Key` | | Exact booking reference | Book objects, box-office book objects | One automatic attempt; you may reconcile and repeat the exact event + selection + `bookingRef` | | One-time secret | Buyer/manage/designer sessions, access-link create/rotate, webhook create | One attempt; never automatically retried | | Unsupported | Every other mutation | One attempt; reconcile current state before deciding what to do next | The 14 exact-header-replay wire operations in every `0.8.0` SDK are: - core: `createChart`, `copyChart`, `instantiateTemplate`, `createEvent`, and `createWorkspace`; - Performance Groups: `createPerformanceGroup`; and - Seasons: `createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`, `duplicateSeasonToLive`, `createSeasonHolderImport`, `createSeasonRenewalOffers`, and `createSeasonAmendment`. Those are wire operation IDs. Use the language page for the exact method spelling. No other mutation inherits this policy merely because it accepts an `Idempotency-Key`. Best-available hold and booking are deliberately single-attempt. Retrying them can select a different set of seats after the first response is lost. `4xx` responses are never retried. `maxRetries` is the total-attempt ceiling for eligible reads and these 14 exact header-replay mutations; it does not opt unsafe mutations into retries. ### Node.js ```ts new SeatLayer({ secretKey: process.env.SEATLAYER_SECRET_KEY!, maxRetries: 3, // total attempts timeoutMs: 30_000, // per attempt }); ``` ### Python ```python SeatLayer( os.environ["SEATLAYER_SECRET_KEY"], max_retries=3, # total attempts timeout=30.0, # seconds, per attempt ) ``` ### PHP ```php new SeatLayer( getenv('SEATLAYER_SECRET_KEY'), maxRetries: 3, // total attempts timeout: 30.0, // seconds, per attempt ); ``` ### Java ```java SeatLayer.builder() .secretKey(System.getenv("SEATLAYER_SECRET_KEY")) .maxRetries(3) // total attempts .timeout(Duration.ofSeconds(30)) // per attempt .build(); ``` ### Go ```go client, err := seatlayer.New( os.Getenv("SEATLAYER_SECRET_KEY"), seatlayer.WithMaxRetries(3), // total attempts seatlayer.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), // per attempt ) ``` ### Ruby ```ruby SeatLayer::Client.new( ENV.fetch("SEATLAYER_SECRET_KEY"), max_retries: 3, # total attempts timeout: 30.0 # seconds, per attempt ) ``` ### .NET ```csharp new SeatLayerClient(secretKey, new SeatLayerClientOptions { MaxRetries = 3, // total attempts Timeout = TimeSpan.FromSeconds(30), // per attempt }); ``` ## Idempotency There are two different safety contracts. Do not treat them as interchangeable. The wire-level rules behind both are documented in [idempotency and conflicts](/server-api/idempotency-and-conflicts/). ### Exact HTTP replay for creates The 14 operations above accept `Idempotency-Key`. The SDK generates one when absent and reuses it across eligible retries. For 30 days, the same logical request replays the original status and JSON body; a changed body, path, or request scope returns `409 idempotency_conflict`. Keys match `^[A-Za-z0-9._:-]{1,128}$`. Their namespace includes the account, live/test mode, and routing environment, but not a particular key id, so key rotation does not strand an in-flight retry. ### Booking safety uses `bookingRef` Booking does not use generic header replay. Put your immutable order id in the required `bookingRef` body field: ### Node.js ```ts await seatlayer.inventory.book( eventKey, { holdId, bookingRef: `order-${orderId}` }, ); ``` ### Python ```python seatlayer.inventory.book( event_key, hold_id=hold_id, booking_ref=f"order-{order_id}" ) ``` ### PHP ```php $seatlayer->inventory->book($eventKey, holdId: $holdId, bookingRef: "order-{$orderId}"); ``` ### Java ```java seatlayer.inventory().book(eventKey, holdId, "order-" + orderId); ``` ### Go ```go client.Inventory.Book(ctx, eventKey, seatlayer.BookParams{ HoldID: holdID, BookingRef: "order-" + orderID, }) ``` ### Ruby ```ruby client.inventory.book(event_key, hold_id: hold_id, booking_ref: "order-#{order_id}") ``` ### .NET ```csharp await client.Inventory.BookAsync(eventKey, holdId, bookingRef: $"order-{orderId}"); ``` Repeat only the exact same event and hold/label set with that reference. Seats already booked under it are not sold again, but the replay response may contain an empty `booked` array. Reusing one `bookingRef` with additional labels can add those seats; one business order must therefore keep one immutable selection. > **One-time credentials cannot be replayed** > > If a session, access-link, or [webhook secret](/server-sdk/webhooks/) response > is lost, do not repeat > the call blindly. Reconcile or revoke what was created, then mint/rotate once. > The SDK intentionally makes only one attempt because a lost browser token > response cannot be replayed. ## Rate limits The server hold, extend, best-available hold, and best-available booking routes share one budget of 600 requests per minute per secret key. Rotating the key does not reset it; a different key has its own budget. A `429` carries `Retry-After` and `RateLimit-*` headers, and the SDK exposes the delay as `retryAfterSeconds`. These inventory mutations remain single-attempt because blindly repeating a hold or best-available selection can reserve different inventory. Your order workflow, not the HTTP transport, must decide whether it is safe to wait and submit a new logical request. Other secret-key resource CRUD does not claim a universal account budget. The OpenAPI contract separately records a limit of 30 per minute per IP, approximate, for readiness probes (no standard rate-limit headers), and a limit of 300 per minute per session for browser manage tokens (`Retry-After` only). Those are not secret-key SDK budgets. ## Escape hatch For a surface the SDK does not wrap yet, the raw client keeps the same auth and error mapping. Raw reads retain transient retry behavior. Raw mutations are single-attempt and do not generate an idempotency key; consult the operation's OpenAPI policy before building your own recovery flow: ### Node.js ```ts await seatlayer.request('POST', '/v1/events/ev_1/some-new-route', { body: {} }); ``` ### Python ```python seatlayer.request("POST", "/v1/events/ev_1/some-new-route", body={}) ``` ### PHP ```php $seatlayer->request('POST', '/v1/events/ev_1/some-new-route', body: []); ``` ### Java ```java seatlayer.request("POST", "/v1/events/ev_1/some-new-route", null, Map.of("qty", 2)); ``` ### Go ```go client.Do(ctx, http.MethodPost, "/v1/events/ev_1/some-new-route", nil, map[string]any{"qty": 2}, "") ``` ### Ruby ```ruby client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 }) ``` ### .NET ```csharp await client.SendAsync(HttpMethod.Post, "/v1/events/ev_1/some-new-route", body: new Dictionary { ["qty"] = 2 }); ``` # Ruby server SDK > Install the seatlayer 0.8.0 gem, inspect an authoritative hold, book with a stable reference, and use the exact Ruby resource namespaces. Source: https://docs.seatlayer.io/server-sdk/ruby/ · Markdown: https://docs.seatlayer.io/server-sdk/ruby/index.md Use the `seatlayer` gem from a trusted Ruby backend. Version `0.8.0` requires Ruby 3.0 or newer and uses `net/http`, `json`, and `openssl` from the standard library without runtime gem dependencies. ## Install and create the client ```bash gem install seatlayer -v 0.8.0 ``` With Bundler, pin the same version in your `Gemfile`, then run `bundle install`: ```ruby gem "seatlayer", "0.8.0" ``` ```ruby require "seatlayer" client = SeatLayer::Client.new(ENV.fetch("SEATLAYER_SECRET_KEY")) raise "Use a test key while integrating." unless client.mode == "test" ``` Keep `SEATLAYER_SECRET_KEY` in the server environment. Never expose the client or its `sk_…` credential to browser or mobile code. ## Exact resource surface The client exposes `charts`, `channels`, `events`, `inventory`, `performance_groups`, `sessions`, `seasons`, `templates`, `webhooks`, and `workspaces`. `client.performance_groups` contains all 13 released operations: `list`, `create`, `retrieve`, `delete`, `activate`, `close`, `retrieve_lifecycle`, `create_buyer_access_session`, `list_buyer_access_sessions`, `revoke_buyer_access_session`, `retrieve_hold`, `book_hold`, and `retrieve_booking`. `client.seasons` contains all 48 frozen `0.8.0` operations. Ruby keeps the Season prefix and uses snake case, for example `create_season`, `publish_season_plan`, `book_season_hold`, `commit_season_renewal_offer`, and `export_season_support_snapshot`. The resource covers catalogue, lifecycle, Plans and sales, buyer handoff, holder import, renewals, amendments, reports, recovery, outbox, audit, and support export. Source-only occurrence return/reclaim operations are not part of `0.8.0`. The same 13 Performance Group and 48 Season wire operations are released in all seven official server SDKs; only language naming differs. The `0.8.0` typed `performance_groups.create` keywords cover the default `fixed` policy but do not expose the newer REST `inclusionMode` field. Use the [REST/raw-request path](/server-api/performance-groups/#create-a-run) for `flexible_dates`; the other typed create fields and all 13 operations remain available. ## Inspect and book an Event hold ```ruby event_key = ENV.fetch("SEATLAYER_EVENT_KEY") hold_id = ENV.fetch("SEATLAYER_HOLD_ID") booking_ref = ENV.fetch("ORDER_ID") hold = client.inventory.retrieve_hold(event_key, hold_id) labels = hold.fetch("items").map { |item| item.fetch("label") } # Price from hold["items"] and authorize payment in your commerce system here. booking = client.inventory.book( event_key, hold_id: hold_id, labels: labels, booking_ref: booking_ref ) ``` Do not calculate a charge from browser-submitted totals. `booking_ref` is your commerce system's immutable join to SeatLayer inventory. If the response is lost, call `client.inventory.retrieve_booking(event_key, booking_ref)` before deciding whether to repeat the exact same hold, labels, and reference. ## Errors, retries, and pagination Branch on `SeatLayer::AuthError`, `SeatLayer::ConflictError`, and `SeatLayer::RateLimitError`. Use `mode_mismatch?`, `sold_out?`, and `retry_after` for those common branches. Reads retry `408`, `429`, and `5xx` with exponential backoff and full jitter. These 14 methods also retry with one exact key: `charts.create`, `charts.copy`, `templates.instantiate_template`, `events.create`, `workspaces.create`, `performance_groups.create`, and `seasons.create_season`, `update_season`, `delete_season`, `create_season_plan`, `duplicate_season_to_live`, `create_season_holder_import`, `create_season_renewal_offers`, and `create_season_amendment`. Other mutations and raw `request` mutations are single-attempt. Large lists can stream through a block: ```ruby client.events.list_all do |event| sync_event(event) end ``` That block iterator is Event-specific. Performance Group and top-level Season list methods return one cursor page; pass the response's opaque `nextCursor` back as `cursor` until it is absent. Nullable Event fields distinguish omission from an explicit `nil`; consult the package README before sending a reset. See [errors, retries, and idempotency](/server-sdk/reliability/) for the shared contract. ## Verify and continue - [RubyGems package `0.8.0`](https://rubygems.org/gems/seatlayer/versions/0.8.0) - [tagged source and package README](https://github.com/seatlayer/seatlayer-ruby/tree/v0.8.0) - [`performance_groups` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-ruby/blob/v0.8.0/lib/seatlayer/performance_groups.rb) - [all `seasons` methods at `v0.8.0`](https://github.com/seatlayer/seatlayer-ruby/blob/v0.8.0/lib/seatlayer/seasons.rb) - [server API operation support](/server-api/operation-support/) - [OpenAPI 3.1 reference](/openapi.json) Verify the flow with a test key, a test Event, and a real test hold. Then add [webhook verification](/server-sdk/webhooks/), choose the correct [inventory model](/start/inventory-models/), and complete the [going-live checklist](/start/going-live/). # Verify webhooks with the SDK > Verify SeatLayer webhook signatures against the raw body in constant time, and deduplicate deliveries so a replay cannot double-process an order. Source: https://docs.seatlayer.io/server-sdk/webhooks/ · Markdown: https://docs.seatlayer.io/server-sdk/webhooks/index.md Webhook verification is the most security-sensitive code in a typical integration, and the two classic mistakes are both silent. The SDKs do it for you. ## Verify ### Node.js ```ts title="Express" import express from 'express'; import { verifyWebhook, WebhookVerificationError } from '@seatlayer/server'; app.post('/webhooks/seatlayer', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { event = verifyWebhook({ payload: req.body, // Buffer, not req.body parsed signature: req.header('X-SeatLayer-Signature'), secret: process.env.SEATLAYER_WEBHOOK_SECRET!, }); } catch (error) { if (error instanceof WebhookVerificationError) return res.sendStatus(400); throw error; } if (await alreadyProcessed(event.occurrenceId)) return res.sendStatus(200); await handle(event); res.sendStatus(200); }); ``` ### Python ```python title="Flask" from flask import request from seatlayer import verify_webhook, WebhookVerificationError @app.post("/webhooks/seatlayer") def seatlayer_webhook(): try: event = verify_webhook( request.get_data(), # raw bytes, not request.json request.headers.get("X-SeatLayer-Signature"), os.environ["SEATLAYER_WEBHOOK_SECRET"], ) except WebhookVerificationError: return "", 400 if already_processed(event["occurrenceId"]): return "", 200 handle(event) return "", 200 ``` ### PHP ```php title="Plain PHP" use SeatLayer\Webhook; use SeatLayer\WebhookVerificationException; // Laravel: use $request->getContent(), never $request->all() $payload = file_get_contents('php://input'); try { $event = Webhook::verify( $payload, $_SERVER['HTTP_X_SEATLAYER_SIGNATURE'] ?? null, getenv('SEATLAYER_WEBHOOK_SECRET'), ); } catch (WebhookVerificationException) { http_response_code(400); return; } if (alreadyProcessed($event['occurrenceId'])) { http_response_code(200); return; } handle($event); http_response_code(200); ``` ### Java ```java title="Spring" @PostMapping("/webhooks/seatlayer") public ResponseEntity handle( @RequestBody byte[] payload, // byte[], never a parsed object @RequestHeader("X-SeatLayer-Signature") String signature) { Map event; try { event = Webhook.verify(payload, signature, System.getenv("SEATLAYER_WEBHOOK_SECRET")); } catch (WebhookVerificationException e) { return ResponseEntity.badRequest().build(); } if (alreadyProcessed((String) event.get("occurrenceId"))) { return ResponseEntity.ok().build(); } process(event); return ResponseEntity.ok().build(); } ``` ### Go ```go title="net/http" func handleWebhook(w http.ResponseWriter, r *http.Request) { payload, err := io.ReadAll(r.Body) // raw bytes, before any decoding if err != nil { w.WriteHeader(http.StatusBadRequest) return } event, err := seatlayer.VerifyWebhook( payload, r.Header.Get("X-SeatLayer-Signature"), os.Getenv("SEATLAYER_WEBHOOK_SECRET"), ) if errors.Is(err, seatlayer.ErrWebhookVerification) { w.WriteHeader(http.StatusBadRequest) return } if alreadyProcessed(event["occurrenceId"].(string)) { w.WriteHeader(http.StatusOK) return } process(event) w.WriteHeader(http.StatusOK) } ``` ### Ruby ```ruby title="Rails" def seatlayer event = SeatLayer::Webhook.verify( request.raw_post, # raw body, never params request.headers["X-SeatLayer-Signature"], ENV.fetch("SEATLAYER_WEBHOOK_SECRET") ) return head :ok if already_processed?(event["occurrenceId"]) Handler.call(event) head :ok rescue SeatLayer::WebhookVerificationError head :bad_request end ``` ### .NET ```csharp title="ASP.NET Core" app.MapPost("/webhooks/seatlayer", async (HttpRequest request) => { using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer); // raw bytes, never a bound model IReadOnlyDictionary seatEvent; try { seatEvent = Webhook.Verify( buffer.ToArray(), request.Headers["X-SeatLayer-Signature"], Environment.GetEnvironmentVariable("SEATLAYER_WEBHOOK_SECRET")!); } catch (SeatLayerWebhookVerificationException) { return Results.BadRequest(); } if (await AlreadyProcessedAsync((string)seatEvent["occurrenceId"]!)) { return Results.Ok(); } await ProcessAsync(seatEvent); return Results.Ok(); }); ``` ## The two mistakes this avoids **Verifying a re-serialised body.** Signatures cover the exact bytes we sent. Parsing the JSON and re-encoding it (`JSON.stringify(req.body)`, `json.dumps(request.json)`) reorders keys and changes whitespace, so verification fails, and the usual "fix" is to disable verification. Always hand the SDK the raw body: `express.raw()`, `request.get_data()`, `request.body` in Django, `await request.body()` in FastAPI. **Comparing signatures with `===`.** A short-circuiting comparison leaks the expected value through timing. The SDKs use a constant-time compare and handle a length mismatch without leaking which failure occurred. ## Replay protection is yours > **Deduplicate on occurrenceId** > > Deliveries are signed over the body, which includes an `at` timestamp, but > nothing enforces a freshness window, so a captured delivery stays valid > indefinitely. Anyone who can read one delivery (a log aggregator, a > misconfigured proxy) can replay it later and it will verify, because it is > genuinely signed by us. > > Every event carries an `occurrenceId`. Record the ones you have processed and > ignore repeats. This is your replay protection, not an optimisation. Deduplication also covers the honest cases: we retry failed deliveries, so the same `occurrenceId` legitimately arrives more than once whenever your endpoint was briefly down. ## What a failure means `WebhookVerificationError` means "this did not come from SeatLayer". Respond `400` without processing it. Do not log the raw body of an unverified request. You have no reason to trust its contents or its size. See [webhook signatures](/webhooks/signatures/) for the underlying scheme, and [delivery and retries](/webhooks/delivery-and-retries/) for our retry behaviour. Then [create and scope a subscription](/webhooks/manage-subscriptions/) and use the [event catalog](/webhooks/events/) to implement only the branches you need. Webhooks reconcile inventory; they do not replace synchronous command results. For an Event, the booking response or exact Booking History lookup is the checkout outcome. For a Performance Group or Season, poll any accepted booking to its documented terminal state. Never wait for a webhook before deciding whether to charge, and never charge again merely because a booked occurrence arrives. The current registry declares no Performance Group-specific webhook; use the terminal group booking result and underlying Event reconciliation. # Authentication and environments > Keep secret keys on your server, choose public or scoped Buyer SDK access, and separate test, live, staging, and production traffic. Source: https://docs.seatlayer.io/start/authentication/ · Markdown: https://docs.seatlayer.io/start/authentication/index.md SeatLayer has two authentication contexts because the buyer and your backend have different authority. | Surface | Credential | Capability | |---|---|---| | Managed public/unlisted Buyer SDK | Event key | Anonymous Public-sale render, select, hold, release | | Platform/SDK Public sale | Event key + `pk_test_…` or `pk_live_…` | Direct, origin-bound Public-sale bootstrap | | Private/login/presale/channel Buyer SDK | Event key + short-lived `bse_…` | Only the audience scope your backend granted | | Server API | `sk_test_…` or `sk_live_…` | Inspect holds, book, manage inventory, provision resources, report | ## Secret keys belong on your server Create keys in the dashboard and store the secret immediately. The complete value is shown once. ```js title="server/seatlayer-request.js" export async function seatlayerRequest(path, init = {}) { return fetch(`https://api.seatlayer.io${path}`, { ...init, headers: { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", ...init.headers, }, }); } ``` > **Never ship a secret key to a client** > > Do not put `sk_…` in browser bundles, mobile applications, public environment variables, page source, analytics, logs, or screenshots. Anyone who obtains it may perform server-authorized operations for your account. Use your deployment platform’s encrypted server-secret mechanism. Fail startup or the server operation clearly when the variable is missing; do not silently fall back to a client credential. ## Test and live mode Every secret key has one immutable mode: ### Test `sk_test_…` creates and manages sandbox events. Sandbox holds, conflicts, realtime updates, and webhooks behave like production, but bookings consume no credits and payloads carry `livemode: false`. ### Live `sk_live_…` creates and manages live inventory. Real bookings consume credits and payloads carry `livemode: true`. An event inherits the mode of the key that creates it. Mode cannot be changed later. ```json title="403 response" { "error": "mode_mismatch" } ``` A test key cannot act on a live event, and a live key cannot act on a sandbox event. Secret-key event lists are filtered to the key's mode; dashboard sessions list both modes. A list with no matching events returns `200` and an empty `events` array. Direct same-organization access to an opposite-mode event returns the `403 mode_mismatch` response above. Build the complete journey with a test key, then create real events using a live key. ## Environment routing A key may also carry an environment tag such as `dev`, `staging`, or `prod`. - Events inherit the creating key’s environment. - Webhook subscriptions can filter delivery by environment. - Environment is routing metadata, not an event, tenant, or workspace authorization boundary. - On webhook management routes, an environment-tagged key can manage only subscriptions in that routing scope. - An unset subscription environment behaves as a wildcard. Recommended setup: | Application environment | SeatLayer key | Event mode | Webhook route | |---|---|---|---| | Local/CI | Dedicated test key | Test | Test receiver or fixture | | Staging | Staging test key | Test | Staging endpoint | | Production | Production live key | Live | Production endpoint | Do not use an environment tag as proof of tenant or workspace ownership. ## Buyer SDK authentication The Buyer SDK never receives an account secret. For an ordinary Public-sale Platform event, give it the event key and the publishable half of your API key: ```js title="browser/public-picker.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", }); await picker.render(); ``` On first render, the SDK sends the public key directly to SeatLayer and repeats that bootstrap only when its in-memory session needs renewal. SeatLayer verifies that the key belongs to the event, its test/live mode matches, and the browser's exact origin is registered for the account. A successful bootstrap returns a Public-sale-only `bse_…` bearer together with the chart and compact inventory status. The SDK keeps the bearer in memory and reuses it for holds, assets, and live updates. A public key is publishable, but it is not buyer identity and can never reveal private channel inventory or authorize booking. For a login, presale, partner, or other private audience, use an explicit buyer access token provider backed by your authenticated server: ```js title="browser/picker.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", buyerAccessTokenProvider: async ({ reason }) => { const response = await fetch("/api/seatlayer/buyer-access", { method: "POST", credentials: "same-origin", cache: "no-store", headers: { "content-type": "application/json" }, body: JSON.stringify({ reason }), }); if (!response.ok) throw new Error("Unable to open seat selection"); return response.json(); }, }); await picker.render(); ``` `buyerAccessTokenProvider` and `buyerAccessToken` always take precedence over `publicKey`. This lets one integration keep the public key in its normal embed while switching known buyers onto deliberately scoped inventory without any anonymous fallback. Browser buyer routes are rate-limited and intentionally limited to buyer-safe operations. The audience token can read/select only its event scope and cannot book; permanent booking remains unavailable without a server secret. ## Browser-scoped session tokens Three credentials exist that are neither a secret key nor an event key. They are bound to one origin, short lived, revocable, and shown once, never retrievable. Your trusted backend requests `dse_…`, `mse_…`, and private-audience `bse_…` sessions with `sk_…`. The Public-sale SDK bootstrap is the narrow exception: SeatLayer creates a Public-only `bse_…` after validating the publishable key and registered origin. | Prefix | What it authorizes | Issued through | |---|---|---| | `mse_` | An **operator** in the embedded control room, limited to an explicit capability list | [`POST /v1/events/:key/manage-sessions`](/platform/embed-sessions) | | `dse_` | A **designer** in the embedded chart editor | [`POST /v1/designer/sessions`](/platform/embedded-designer) | | `bse_` | A **buyer** on one event; either Public-only bootstrap or an explicit [sales-channel](/platform/sales-channels) scope | Direct SDK bootstrap for Public sale, or [`POST /v1/events/:key/buyer-access-sessions`](/server-api/buyer-access-sessions) for scoped audiences | `mse_` and `bse_` are not interchangeable, and neither is a weaker secret key. An operator token grants organizer capabilities on an event; a buyer token grants nothing but the right to *see and select* a particular audience's inventory. A buyer token can never book. Booking stays with your server. Send a buyer token as `Authorization: Bearer bse_…` on buyer routes after bootstrap. The SDK handles that header. A Platform/SDK Public-sale embed obtains its token directly with `publicKey`; private/login/presale/channel inventory still requires a token/provider backed by your server. Omitting both from a Platform event returns the same `404` as an unknown event. Managed public/unlisted events retain their anonymous Public-sale flow. Sending a bad token is always an error, never a downgrade: an expired, revoked, wrong-origin, or wrong-event token fails with a specific code rather than quietly becoming an anonymous shopper. ## Rotation checklist - [ ] Create the replacement key in the same mode and environment. - [ ] Store it in the server secret manager. - [ ] Deploy the backend using the replacement. - [ ] Exercise an authenticated read or sandbox booking. - [ ] Confirm relevant webhook routing. - [ ] Revoke the old key after traffic has moved. - [ ] Investigate logs if rotation followed suspected exposure. ## Common mistakes - Using a `PUBLIC_`, `NEXT_PUBLIC_`, or `VITE_` environment variable for the secret. - Creating a live event during staging tests. - Assuming the visible key prefix is the enforcement mechanism. - Treating an environment or `externalRef` as tenant authorization. - Logging the complete `Authorization` header during request debugging. Continue to [Install the Buyer SDK](/buyer-sdk/install) or start the [Quickstart](/start/quickstart). # Choose an integration > Choose who owns commerce first, then select the buyer and staff surfaces that belong to that product. Source: https://docs.seatlayer.io/start/choose-an-integration/ · Markdown: https://docs.seatlayer.io/start/choose-an-integration/index.md There is one seating inventory model, but two current products. Decide who owns commerce and fulfilment before choosing a framework, embed, link, or Website. For the full list of ready-made surfaces and their boundaries, read [product surfaces and their purpose](/start/product-model/#product-surfaces-and-their-purpose). ## 1. Choose the product boundary - **Platform / SDK infrastructure** — Choose this when your marketplace or ticketing product owns the event catalogue, buyer, checkout, payment, commercial order, tickets, delivery, refunds, scanning, and support. SeatLayer supplies seating inventory and operational tools. - **Managed ticketing** — Choose this self-serve product when a direct organizer wants SeatLayer to run hosted checkout, Orders, ticket/QR/PDF delivery, email, refunds, and Door/check-in through the organizer's connected gateway. Commerce ownership is fixed for each Event. Embedding a different buyer surface does not switch products; reuse the Chart and create a new Event when commerce ownership must change. > **Managed commerce for platform tenants is not a current product** > > One marketplace account cannot silently turn thousands of workspace tenants > into separate SeatLayer merchants. Workspace-scoped gateways, permissions, > sites, and commercial ownership require a separate product contract. ## 2. Platform / SDK infrastructure For a marketplace or existing ticketing backend, a SeatLayer Event is only the authoritative inventory occurrence. It can be created from a chart with minimal metadata. It does not imply a public SeatLayer event page or ticket fulfilment. ### Choose the buyer seating UI Pick the buyer SDK for where your code runs (web, native app, or both) on [Choose an SDK or API](/sdks/). That page and the platform pages it links to compare the ready-made picker, the map-only chart, and the native SDKs. The browser never receives a secret key and never confirms a permanent booking on its own. Your trusted backend inspects the opaque `holdId` and books with one stable `bookingRef` after its payment/order decision. ### Add staff surfaces when needed - Use a Workspace per platform customer or organizer. - Use Embedded Designer for workspace-scoped chart creation. - Use the embedded Control room for live inventory, blocking, safe booking cancellation, booking history, sell-through, and reports. - Mint short-lived, exact-origin Designer/Control room sessions from the platform backend after authenticating the platform user. The SDK Control room is not the platform's Orders, ticket-delivery, refund, or door-attendance system. ## 3. Managed ticketing For a direct organizer, choose where buyers should arrive before working through the setup below it. Hosted Ticketing is self-serve: events created in the dashboard use SeatLayer checkout, and the organizer connects their own Stripe or Razorpay account on the **Payments** page, with no access request or approval. SeatLayer owns the managed hold-to-payment-to-Order-to-ticket lifecycle on all three routes. A free event skips payment and uses one Hosted Ticket per confirmed ticket. Platform/SDK Events are created with a secret key instead. The organizer's connected provider controls processing fees, applicable taxes, and payout timing. | Route | What to choose | Access requirement | |---|---|---| | My existing website | Copy an inline picker, event card, or Buy button into WordPress, Wix, or HTML | This is a public route. Visitors need public buyer access; no backend is required | | SeatLayer-hosted page | Share the event booking page and optionally add Organizer Website discovery | Anyone with the enabled page URL can open it; Website listing is a separate public-discovery choice | | Private access | Use a scoped Channel link, or the authenticated website/app setup | A Channel link grants its bearer scoped access; a private website needs a backend-authenticated, short-lived session | Public website code can be generated and installed before gateway or payment setup is complete. Installation does not publish the event or open sales: visitors still need public buyer access, and checkout cannot complete until payment readiness is satisfied and sales are open. The inline picker reads its map, seat and category colours, logo, and brand from the published floor plan attached to the event. Sell & Share can add explicit buyer-control theme, accent, button-label, and height overrides to the copied markup; it does not duplicate the venue design into HTML. Wix Embed HTML is framed, so its generated card/button opens the SeatLayer booking page before any seat hold instead of attempting redirect-based payment in the iframe. Every generated block contains a normal buyer-page link. It stays useful when JavaScript or the widget cannot load, and non-production blocks carry their matching API and buyer origins rather than falling through to production. These routes do not change who owns checkout. Publishing page content, listing an event on an Organizer Website, and opening sales remain explicit actions. ## Common combinations ### A multi-tenant ticketing platform Create one Workspace per organizer, create or duplicate reusable charts, create minimal inventory Events, embed the buyer SDK, and confirm inventory from the platform backend. Add Designer and Control room sessions only for authorized organizer staff. ### A commerce site with its own checkout Use `SeatPicker` or its React wrapper and receive `holdId` in `onCheckout`. Inspect the hold, run your own order/payment flow, then call `/book`. ### A direct organizer without a commerce backend Use Hosted Ticketing. Choose an existing website, a SeatLayer-hosted page, or private access; create the event in the dashboard, connect a gateway (not needed for a free event), finish event readiness, and open sales. ## Do not cross the boundaries - Do not create SeatLayer Orders, tickets, email, PDFs, refunds, or attendance records from a Platform/SDK inventory booking. - Do not expose an account secret because a browser needs more control. - Do not use organization-wide dashboard membership as workspace-specific organizer access. - Do not use `SeatManager` as a buyer picker; it grants operator capabilities. - Do not assume an Event ID makes a Hosted Event Page or Website public. - Do not describe configured booked value as money collected or settled. ## Selection checklist - [ ] We chose Platform/SDK or Hosted Ticketing before choosing placement. - [ ] Managed events are created in the dashboard, and each paid event has a connected payment gateway assigned before sales open. - [ ] We wrote down which system owns payment, commercial Orders, tickets, refunds, and check-in. - [ ] Platform integrations store their own order ID as `bookingRef`. - [ ] Platform-created Events remain inventory-only and have no implicit hosted public surface. - [ ] The secret key exists only in a trusted environment. - [ ] Embedded staff sessions are workspace/event scoped and short lived. - [ ] Inventory reports and financial reports are labelled and reconciled as separate authorities. Continue with the [platform integration guide](/integrations/platforms), the [ticketing backend flow](/integrations/ticketing-backend), or the [booking endpoint](/server-api/booking). # Your first chart with AI > Describe your venue in plain words and get a seating chart you can edit, check and publish. Three routes, from no setup at all to an AI connected to SeatLayer. Source: https://docs.seatlayer.io/start/first-chart-with-ai/ · Markdown: https://docs.seatlayer.io/start/first-chart-with-ai/index.md You do not need to learn the Designer to make a chart. Describe the venue in plain words: how many seats, the layout, the ticket types and their prices, where the bar and exits are. An AI writes that down as a [Venue Spec](/agents/venue-spec/), a short JSON file, and SeatLayer places every seat, table and booth. You get a normal chart that you can change, preview in 3D and publish. ## Pick a route | Route | What you need | Time | |---|---|---| | [1. Any chat AI](#1-any-chat-ai) | Nothing. The free demo Designer works without an account | A few minutes | | [2. Claude or ChatGPT connected to SeatLayer](#2-claude-or-chatgpt-connected-to-seatlayer) | A SeatLayer account and a one-time connector setup | A few minutes after the setup | | [3. Copilot in the Designer](#3-copilot-in-the-designer) | A SeatLayer account | A few minutes | Most of the time goes into writing the description. The build itself is one step in every route. ## 1. Any chat AI Works with ChatGPT, Claude, Gemini or any other chat AI, with no connection to SeatLayer. 1. **Open the Designer** Use the free demo at [app.seatlayer.io/demo/designer](https://app.seatlayer.io/demo/designer), which needs no account, or open a chart in the Designer in your dashboard. 2. **Open the AI dialog** Choose **New from → Paste from an AI…**. 3. **Copy the prompt** Choose **Copy prompt for your AI**. 4. **Ask your AI** Paste the prompt into your chat AI. Replace the line in square brackets with your venue, using the [checklist below](#getting-what-you-asked-for). 5. **Paste the reply** Copy the AI's whole reply and paste it into **2 · Paste its reply**. You do not need to cut out the JSON. 6. **Fix anything it lists** If **3 · Check** lists problems, choose **Copy fixes for your AI**, paste that into the same chat, and paste the new reply back. Repeat until the check shows a preview. 7. **Build** Compare the summary with what you asked for, then choose **Build N-place chart**. The build replaces the draft; one **Undo** brings the old draft back. The demo keeps charts in your browser. Sign up to publish or to keep the chart across devices. ## 2. Claude or ChatGPT connected to SeatLayer The AI builds the chart in your SeatLayer workspace itself, with no copy and paste. 1. **Connect once** Add `https://mcp.seatlayer.io/mcp?tools=build` as a custom connector in [Claude](/agents/connect-claude/) or a custom app in [ChatGPT](/agents/connect-chatgpt/). A SeatLayer admin approves the connection on the consent screen, for one chart or a whole workspace. 2. **Describe the venue** In a chat with SeatLayer turned on, write something like: "Build me a 400-seat theatre with stalls in two blocks, curved rear stalls, Premium at 80 and Standard at 55, exits on both sides." 3. **Let it check and build** The AI writes a Venue Spec, checks it with `build_from_venue_spec` in `checkOnly` mode, fixes what the check lists, then builds. Approve the write when your AI asks. The build replaces the chart's draft. 4. **Look at the preview** The AI validates the chart and shows a preview on desktop and mobile, with the counts it built. 5. **Review and publish** Open the chart in the Designer to review it. Publishing is your decision: the AI can prepare a review, but it does not approve or publish on its own. ## 3. Copilot in the Designer 1. **Open Copilot** Open a chart in the Designer from your dashboard, open **Copilot**, and choose **Edit chart**. 2. **Describe the venue** Write the venue in your own words, as in the other routes. 3. **Confirm the build** Copilot writes a Venue Spec, checks it, and asks you to confirm because the build replaces the draft. Confirm, and the chart appears as a revision you can review. If **Edit chart** is unavailable on your account, use route 1. ## Getting what you asked for The AI can only build what the description says. Include: - **Seats or capacity**: rows and seats per row, tables and chairs per table, standing capacity, or booth rows and columns. - **Layout**: straight rows, rows curved around the stage, tables, standing, booths, or an arena bowl with stands. - **Ticket types and prices**, most expensive first, and which area each one covers. Prices are plain numbers. - **The stage**, or say there is none. - **Bars, entrances and exits**, and which side they are on. - **Section names** as buyers should see them, such as "Stalls" or "North Stand". A good description: ```text title="good-description.txt" A 400-seat theatre. Stalls: 10 rows of 24 seats with a centre aisle, all Premium at 80. Behind them, Rear Stalls: 5 curved rows of 32 seats with two aisles, Standard at 55. Curved stage at the front. Exits on the left and right, main entrance at the back. ``` A vague description, which leaves the AI to guess the counts, the prices and where things go: ```text title="vague-description.txt" A medium theatre with good seats and cheap seats. ``` ### Check the summary Before you build, compare the counts with what you asked for. The Designer shows them next to the preview, for example `400 seats · 2 categories · 2 sections`. Over MCP, the AI gets the same summary: seats, tables, booths, standing places, sections, categories and total capacity. If a number is wrong, tell the AI what to change before building. ## Then finish in the Designer The AI gives you the structure. Finish the rest by hand: - change row letters, seat numbers, prices and colours; - move or curve rows, and draw section outlines, which a Venue Spec cannot do; - look at the chart in the 3D preview; - [publish](/designer/publishing-and-versioning/) when it is ready. More: [charts and objects](/designer/charts-and-objects/), [sections and seat plans](/designer/sections-and-seat-plans/), and the full [Venue Spec reference](/agents/venue-spec/). # Your first managed event > Create a full organizer event, prepare managed checkout, and deliberately choose where buyers can access it. Source: https://docs.seatlayer.io/start/first-event/ · Markdown: https://docs.seatlayer.io/start/first-event/index.md This is the direct-organizer route for **SeatLayer Hosted Ticketing**. SeatLayer can run the checkout, Order, ticket/QR/PDF delivery, email, refund, and Door/check-in lifecycle through the organizer's connected gateway. Hosted Ticketing is self-serve: there is no access request or approval step. Events created in the dashboard use SeatLayer checkout. For a paid event, connect your own payment gateway on the **Payments** page. Stripe, PayPal, Square, Braintree, Authorize.Net, Checkout.com, Mollie, Razorpay, Tap, Mercado Pago, Xendit, Flutterwave, Paystack and iyzico are all supported. The connected provider controls processing fees, applicable taxes, and payout timing; SeatLayer bills Hosted Ticket usage through the separate Managed wallet. Once the event is set up, [Sell your own tickets](/sell/overview/) covers payments, checkout, the box office, refunds, the door and staff roles. If you are adding seating to an existing marketplace or ticketing backend, use the [Platform SDK quickstart](/start/quickstart/) instead. An API-created Event is only an inventory occurrence and does not need a SeatLayer buyer page or fulfilment. > **Creating is not publishing** > > A new managed event starts in setup. Creating its chart and inventory does > not automatically publish a Hosted Event Page, list it on an Organizer > Website, or open sales. ## Paid or free Choose how people book when you create the event: - **Pay online**: buyers pay through the payment gateway assigned to the event. - **Free**: there is no payment step. Buyers still receive the same tickets, QR codes, PDF, and confirmation email. Each confirmed ticket uses one Hosted Ticket ($0.50), the same meter as a paid Hosted Ticket, so the Managed wallet needs enough balance for the bookings you expect. The booking mode cannot be changed once the event has an order. ## Create the managed event 1. **Open Events and choose New event** Start from a curated seated venue or a published floor plan. Every SeatLayer Event belongs to a chart; a chart can be reused for many event occurrences. 2. **Add the organizer-facing event details** Set the name, date and time, venue, timezone, description, poster, currency, and the Event region nearest the venue. The form starts from the workspace default and this Event can override it. These fields are part of Hosted Ticketing readiness; the minimal Platform/API event does not require them all. 3. **Create the setup event** SeatLayer publishes or pins the selected chart snapshot and creates independent event inventory. The event remains in setup until the organizer deliberately completes commerce and publication choices. 4. **Choose the buyer route first** Open **Sell & share** and start with the destination you want: - **My existing website**: choose an inline picker, event card, or Buy button and copy its installation code. You can generate and install this public code before payment setup is complete. - **A SeatLayer-hosted page**: use the ready-made booking page, then optionally list the event on an Organizer Website for public discovery. - **Private access**: share a scoped Channel link, or choose the authenticated website/app setup when access must depend on a login or membership check. A normal website embed is a public route. Do not use it for a private audience; a private website must use the authenticated setup. The inline picker loads the published floor plan, category colours, map, logo, and brand attached to this event. Use **Appearance and size** to override only buyer-control light/dark style, accent, button label, or picker height; those choices are written into the copied code. Wix custom HTML runs inside an iframe, so SeatLayer generates a card or button that opens the hosted booking page before a hold instead of starting checkout inside the frame. 5. **Complete checkout and buyer access** For a paid event, connect a test or live payment gateway on the **Payments** page and assign it to this event. A free event skips this step. Complete prices and the managed readiness checks, then enable the access required by the selected route. To run an early-bird discount or a scheduled price change, set up [ticket pricing and offers](/platform/ticket-pricing-and-offers/). Installing public embed code does not make the event public. Visitors can load it only after public buyer access is enabled, and they cannot purchase until payment setup is ready and sales are open. Development and staging snippets include their API and buyer origins so a test event cannot silently query production. The literal booking link in every snippet remains the failure and no-JavaScript route. 6. **Publish and open sales explicitly** Review the buyer page, payment mode, prices, event details, and availability. Publish the chosen surface and transition sales from setup only when the organizer is ready. Adding Organizer Website discovery remains a separate, optional choice. Region controls where live Event inventory starts for latency; currency controls money. They are independent. A Rotterdam Event can use Western Europe with EUR, while a Singapore Event can use Asia Pacific with SGD. See all [11 supported Event regions](/server-api/event-regions/). > **An unlisted booking page is not secret** > > It stays out of the Organizer Website and search indexing, but anyone who > receives or forwards the URL can open it. For VIP, sponsor, member, or partner > inventory, allocate the seats to a [Sales Channel](/platform/sales-channels/) > and create an expiring, > revocable private buyer link instead. ## What the organizer gets - independent realtime inventory created from a reusable chart; - SeatLayer-managed checkout through the assigned organizer gateway; - SeatLayer Orders, tickets, QR codes, printable PDFs, and buyer email; - refund and inventory-release workflows; - Door/check-in and attendance tools; and - a Hosted Event Page, managed embed, or optional Organizer Website. These buyer surfaces do not change the Event's commerce owner. To move between Hosted Ticketing and Platform/SDK, reuse the Chart and create a new Event. ## Test and live modes Use test mode to verify the complete managed flow without consuming live inventory credits or moving real money. The event mode, API credentials, and connected gateway mode must agree. Create a separate live event when a test event already has state that makes conversion unsafe. ## When to open the Designer Start in the Designer when you need a custom venue, accessibility relationships, seat/category overrides, multiple floors, detailed sections, or geometry that does not match a curated template. Publish the chart, then create one or more managed events from it. ## Next - [Choose an integration](/start/choose-an-integration/): confirm the product boundary and distribution surface. - [Platform SDK quickstart](/start/quickstart/): use this instead when your platform owns commerce and tickets. - [Going-live checklist](/start/going-live/): verify mode, payment, inventory, and recovery before real buyers arrive. # Platform SDK going-live checklist > Verify charts, live credentials, external checkout recovery, inventory webhooks, accessibility, and operations before taking real money. Source: https://docs.seatlayer.io/start/going-live/ · Markdown: https://docs.seatlayer.io/start/going-live/index.md Run this checklist before directing real buyers through a Platform/SDK integration. Your platform owns payment, commercial Orders, tickets, delivery, refunds, scanning, and support; this checklist verifies how that system commits SeatLayer inventory. Direct organizers using Hosted Ticketing need the managed gateway and hosted-surface readiness checks instead; Hosted Ticketing is self-serve and needs no access request. This checklist covers the production-ready **single-event** Platform/SDK contract. Fixed multi-performance checkout is separately released through [Performance Groups](/integrations/performance-groups); complete that guide's compatibility and live-readiness checks when you use it. Fixed Renewable Seasons have their own [Plan, buyer-rehearsal, sales, booking, renewal, and operational contract](/server-api/seasons/). Do not launch an integration that depends on buyer-created or ad hoc event bundles, partial Seasons, arbitrary custom inventory statuses, or true partial-GA channel allocation: those capabilities are not part of the current release. [Resale](/server-api/inventory/#resale) is available on Platform events but not on Hosted Ticketing events. > **An Event keeps its mode and commerce owner** > > Test/live mode and Platform/Managed commerce ownership are fixed when an Event > is created. Create production Events with the intended live key and product > boundary; reuse the Chart and create a new Event rather than trying to convert > an existing Event. ## Application readiness 1. **Secure live credentials** Store `sk_live_…` only in the production server secret manager. Confirm it is absent from browser bundles, mobile binaries, logs, and public configuration. 2. **Verify browser access** For ordinary Platform Public sale, confirm the mount supplies a matching `pk_live_…` and its exact production origin is registered under Embed domains. For login, presale, partner, or channel inventory, confirm the mount instead receives a `buyerAccessTokenProvider` backed by your authenticated server and the same exact origin. A test-only key, missing origin, or missing applicable credential deliberately produces `404 not_found`. 3. **Complete the trusted checkout** Verify the browser sends only the hold id, the server inspects authoritative hold items, and your order id becomes `bookingRef`. 4. **Test payment compensation** Deliberately expire a hold after payment authorization. Confirm a booking `409` moves the order into reselection and voids/refunds correctly. 5. **Prove idempotent recovery** Simulate a booking response timeout and retry with the same order id. Confirm no duplicate sale, credit usage, or payment capture. 6. **Configure signed webhooks** Verify the [webhook signature](/server-sdk/webhooks/) against the raw body, deduplicate deliveries, acknowledge quickly, and separate test/live processing. 7. **Verify the buyer experience** Complete checkout and your own ticket delivery on desktop and mobile, with keyboard navigation, accessible inventory, color/zoom constraints, and a browser without 3D support. ## Event readiness - [ ] The chart is published. - [ ] The event was created with a live key. - [ ] Event name, date, venue, currency, prices, and ticket tiers are correct. - [ ] Capacity matches the authoritative venue plan. - [ ] Accessible and companion seats are correctly related. - [ ] Obstructed views, booths, tables, GA, sections, zones, and floors were reviewed. - [ ] Intended sections are on sale and reveal rules are correct. - [ ] Test holds, blocks, and bookings are absent from live inventory. - [ ] The account has sufficient booking credits. - [ ] Operations staff can access the intended control surface. - [ ] Independent Events are not presented as a Season or renewal right unless they belong to a published Fixed Renewable Season Plan. - [ ] If checkout uses a Performance Group, its fixed member Events pass the [Performance Groups compatibility and test checklist](/integrations/performance-groups#1-create-compatible-performances). - [ ] If checkout uses the released Season baseline, its Plan is published, buyer rehearsal is recorded, sales are open, the session is origin-bound, and the host can reconcile every accepted or partial-terminal operation. - [ ] Each private channel has the intended allocation, sale route, and sparse price overrides; a real scoped buyer session was used to verify both the visible inventory and the authoritative hold price. ## Environment checks | Check | Expected production value | |---|---| | SDK/API origin | `https://api.seatlayer.io` unless SeatLayer supplied another | | Secret key | `sk_live_…` in server secret storage | | Event mode | Live | | Webhook environment | Production routing tag or intentional wildcard | | `livemode` handling | Production logic accepts `true`; test traffic is isolated | | Logs | Event, hold, order, and payment correlated without secrets | The `order` and payment in this table are your platform's records. SeatLayer stores the inventory booking and your `bookingRef`, not a second commerce Order. ## Failure rehearsal Before launch, rehearse: 1. two buyers competing for the same final seat; 2. hold expiry on the payment page; 3. payment authorization followed by booking conflict; 4. successful booking with a lost HTTP response; 5. delayed and duplicate webhook delivery; 6. realtime disconnect and reconnect; 7. event sold out while a buyer is viewing it; and 8. an operator blocking inventory during active sales. ## Launch evidence Record: - the production event key and your business event id; - the chart version reviewed; - the live credential/environment used, without the secret; - automated check output; - the successful manual order/booking reference; - webhook delivery evidence; and - the owner of rollback or buyer-support decisions. ## Final decision Do not launch when any of these remain unknown: - who owns payment compensation; - how an expired hold returns to selection; - whether booking retries reuse the same reference; - whether webhook signatures are verified; - whether tenant/workspace ownership is enforced; or - whether accessible inventory was validated. Use [Integration best practices](/integrations/best-practices) for the complete production standard and the [complete checkout example](/examples/complete-checkout) as the reference flow. # How the Platform integration works > Understand the browser hold, inventory booking, and realtime boundary when your platform owns commerce and fulfilment. Source: https://docs.seatlayer.io/start/how-it-works/ · Markdown: https://docs.seatlayer.io/start/how-it-works/index.md Every SeatLayer Platform/SDK integration follows one invariant: > **The browser holds. Your server books inventory.** This page assumes the [chart and event model](/start/overview/): one reusable venue document, and many independent realtime occurrences created from it. The buyer can explore inventory and temporarily reserve a selection without receiving an account secret. Only your trusted backend can commit that hold. Your platform separately owns payment, its commercial Order, and ticket fulfilment. Direct organizers using SeatLayer-managed checkout follow the [Hosted Ticketing path](/start/choose-an-integration#3-managed-ticketing). > **A browser never books production inventory** > > Do not place a SeatLayer secret key in client JavaScript, a mobile binary, page HTML, or a client-exposed environment variable. ## The complete journey 1. **Render live inventory** Your browser or mobile surface loads a published event through the buyer SDK. For ordinary Platform Public sale, the event key and publishable key start a direct, exact-origin bootstrap. Login, presale, partner, and channel inventory use a scoped token from your authenticated backend. Neither path grants server booking authority. 2. **Select and hold** The buyer chooses seats, a table, booth, or GA quantity. The SDK creates a short-lived hold and returns an opaque `holdId` with an expiry time. 3. **Inspect on your server** Your backend receives the `holdId` and resolves it with `GET /v1/events/:eventKey/holds/:holdId`. The returned items, not browser-posted prices, become the order source. 4. **Run your order and payment logic** Create or reuse your own order. Authorize or capture payment according to your payment provider and recovery model. 5. **Book inventory atomically** Your backend calls `POST /v1/events/:eventKey/book` using a secret key and your immutable order id as `bookingRef`. 6. **Confirm, fulfil, and reconcile** The synchronous response drives the buyer journey. Your platform creates and delivers its tickets. Realtime updates repaint open pickers, and signed inventory webhooks reconcile downstream systems. ## Why the split exists - **Security** — A browser can reserve inventory but cannot create a permanent inventory booking. Booking authority remains in your trusted environment. - **Correctness** — When two buyers reach for the same seat, exactly one competing request wins. See how that holds up under [10,000 concurrent buyers](/platform/concurrency-performance/). - **Product control** — Your application owns identity, pricing rules, payment, tax, commercial Orders, tickets, email, refunds, scanning, support, and the final confirmation experience. ## What each system owns | Responsibility | Browser / SDK | Your server | SeatLayer | |---|:---:|:---:|:---:| | Display chart and live availability | Yes | No | Supplies state | | Let a buyer select | Yes | No | Enforces availability | | Create and restore holds | Yes | May inspect | Stores authoritative hold | | Calculate the trusted charge | No | Yes | Supplies trusted line items | | Process payment and orders | No | Yes | No | | Permanently book inventory | No | Initiates | Applies atomically | | Create and deliver tickets | No | Yes | No | | Refund and check in buyers | No | Yes | No | | Realtime inventory synchronization | Receives | Optional listener | Publishes | | Signed event notification | No | Receives | Sends | ## Seat lifecycle | State | Meaning | Typical transition | |---|---|---| | `free` | Available to buyers | Initial, released, or expired | | `held` | Temporarily reserved | Buyer SDK creates a hold | | `booked` | Permanently committed inventory | Your server books | | `not_for_sale` | Removed from buyer inventory | Operator or inventory rule | Holds expire automatically. Booking is all-or-nothing: if any required item cannot be booked, the request returns a conflict without partially completing the sale. ## The browser handoff ```js title="browser/seat-picker.js" const picker = new seatlayer.SeatPicker({ container: "#picker", event: "", publicKey: "", onCheckout: async (_, __, handoff) => { await fetch("/api/checkout/seats", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ holdId: handoff.holdId }), }); }, }); await picker.render(); ``` For Public sale, the first SeatLayer response carries both the chart and compact inventory status. The SDK keeps the returned bearer in memory and renews it when needed. Supplying `buyerAccessTokenProvider` or `buyerAccessToken` for a private audience takes precedence over `publicKey` and prevents an anonymous fallback. The browser sends your backend the hold id. Your backend obtains trusted labels, quantities, tiers, currency, and prices from the hold inspection endpoint. ## Expected failure paths - **Hold expired:** return the buyer to selection and clear stale cart state. - **Booking returned `409`:** treat it as a recoverable inventory conflict; do not partially confirm the order. - **Payment succeeded but booking is uncertain:** retry using the same `bookingRef`. - **Realtime connection dropped:** reconnect and refresh authoritative inventory. - **Mode mismatch:** use a test key with test events and a live key with live events. ## Verify your understanding Before implementation, you should be able to answer: - Which value crosses from the browser to your server? - Where is the secret key stored? - Which response supplies authoritative inventory and configured-price input, and where does the final amount charged remain authoritative? - Which identifier makes a retry safe? - What buyer experience follows a `409`? Continue to [Authentication](/start/authentication), then [install the Buyer SDK](/buyer-sdk/install). # Choose an inventory model > Decide whether one sale should use an Event, an atomic multi-performance group, or a renewable fixed Season before choosing SDK methods. Source: https://docs.seatlayer.io/start/inventory-models/ · Markdown: https://docs.seatlayer.io/start/inventory-models/index.md Choose the inventory model before choosing an SDK component. An Event, a Performance Group, and a Fixed Renewable Season solve different concurrency and entitlement problems; they are not three names for the same bundle. This page owns the **decision between inventory models**. The child integration guides own complete workflows, buyer pages own UI configuration, and API pages own exact trusted operations. Return to the [SeatLayer product model](/start/product-model/) when the open question is commerce ownership, audience access, pricing, or SDK choice instead. This decision applies to the **Platform / SDK** product. Your platform keeps buyer identity, pricing, tax, checkout, payment, the commercial order, tickets or passes, refunds, and fulfilment. SeatLayer coordinates seating inventory. ## Compare the three models | Question | Event | Performance Group | Fixed Renewable Season | |---|---|---|---| | What is sold? | Seats for one dated inventory occurrence | One fixed member set of 2–8 compatible Events; the inclusion policy may require all dates or permit an availability-based subset | One immutable Plan of 2–20 compatible Events | | Seat rule | Exact seats or Best Available for that Event | Same seats for every Event, or an equal party size selected per performance | The same seats across the complete Plan | | Inventory transaction | One Event hold and booking | One recoverable all-or-nothing group hold and booking | One recoverable same-seat Season hold and booking | | May the buyer omit dates? | Not applicable | `fixed`: no. `flexible_dates` + `same_seat`: a partial seat may include only dates where it is available. | No; every published Plan Event is included | | Durable right after the booking? | No | No | Yes; Contract and Seat Right support later renewal offers | | Current server SDK coverage | All seven SDKs | All 13 operations in all seven SDKs | All 48 frozen `0.8.0` operations in all seven SDKs | | Start here | [Single-Event Platform flow](/start/how-it-works/) | [Performance Group integration](/integrations/performance-groups/) | [Season integration](/integrations/seasons/) | ## How the inventory documentation is organized | Model | End-to-end workflow | Buyer surface | Trusted API | |---|---|---|---| | Event | [Platform flow](/start/how-it-works/) and [quickstart](/start/quickstart/) | [SeatPicker](/buyer-sdk/seat-picker/) or [SeatingChart](/buyer-sdk/seating-chart/) | [Events](/server-api/events/), [holds](/server-api/holds/), and [booking](/server-api/booking/) | | Performance Group | [Integrate a Performance Group](/integrations/performance-groups/) | [Performance Group picker](/buyer-sdk/performance-groups/) | [Performance Groups API](/server-api/performance-groups/) | | Fixed Renewable Season | [Integrate a Fixed Renewable Season](/integrations/seasons/) | [Season picker](/buyer-sdk/seasons/) | [Seasons API](/server-api/seasons/) | ## Choose an Event Use one Event when the buyer is purchasing one occurrence, such as one match, concert, screening, or performance date. The buyer SDK creates a temporary hold. Your trusted backend retrieves that hold, calculates the commercial order from trusted data, takes payment under your own rules, and books the inventory with a stable `bookingRef`. An Event remains the underlying inventory occurrence even when it later joins a Performance Group or Season. Do not duplicate it merely to build a second buyer journey. Choose Event when all of these are true: - one booking affects one dated occurrence; - the buyer does not need an atomic choice across several dates; and - no retained same-seat renewal right is required. Continue with [how the Platform flow works](/start/how-it-works/) and the [booking endpoint](/server-api/booking/). ## Choose a Performance Group Use a Performance Group for one fixed multi-performance run of 2–8 compatible Events. The group is an inventory primitive, not a season ticket or commercial package. The host selects one mode before the picker opens: - `same_seat`: the buyer chooses one assigned seat set used for every Event; or - `per_performance`: the buyer chooses an equal party size separately for each Event. The group also stores a server-owned inclusion policy. `fixed` requires every activated Event. `flexible_dates` applies to the `same_seat` flow: when a seat is unavailable on some dates, the buyer may hold it for the dates where it is available. It does not turn the fixed 2–8 Event membership into an arbitrary build-your-own package. `per_performance` still requires the configured equal party size on every dated tab. SeatLayer performs one atomic group operation. Do not intersect availability in the browser, issue one independent hold per Event, or attempt to roll back a partially successful loop yourself. Choose Performance Group when all of these are true: - the run contains 2–8 compatible Events; - the offer uses either mandatory `fixed` inclusion or the explicitly availability-based `flexible_dates` same-seat policy; - the purchase must succeed or fail as one inventory action; and - the buyer receives no durable renewal right. The REST API supports both inclusion policies. All seven typed server SDK `0.8.0` create methods currently omit the `inclusionMode` field, so create a `flexible_dates` group through REST or a raw request until a later typed release adds that field. This is a field-level version boundary, not missing Performance Group operation coverage. Performance Groups are available to every organizer. Start with [Integrate a Performance Group](/integrations/performance-groups/) for the complete backend and buyer handoff, or inspect the [Performance Groups server API](/server-api/performance-groups/). ## Choose a Fixed Renewable Season Use a Fixed Renewable Season when a buyer should retain the same assigned seats across an immutable 2–20-Event Plan and may renew those rights into a successor Plan. The durable model adds concepts that a Performance Group deliberately does not have: - a **Plan** fixes occurrence membership and publication identity; - a **Contract** records the holder's Season relationship; - a **Seat Right** fences the assigned seat set across the Plan; and - a **Renewal Offer** gives that holder a time-bounded path into a successor Plan. Choose a Season when all of these are true: - the offer contains 2–20 compatible Events; - the same assigned seat must be coordinated across every included Event; - published Plan identity and operational recovery must be retained; and - incumbent import or future renewal rights are part of the product. SeatLayer Season holds contain inventory identity, not an authoritative package amount. Your trusted commerce system owns package pricing, payment, the order, benefits, passes or tickets, refunds, and support. > **Current Season delivery boundaries** > > All seven server SDKs publish the frozen 48-operation Season contract at > `0.8.0`; the REST API has 65 Season operations. The released Season buyer > surface is web-based. React Native, Flutter, iOS, and Android do not > currently expose a native Season v1 picker. [Season Best > Available](/server-api/seasons/#season-best-available) picks the same seats > across every date for a buyer session. Organizers can also create and run Seasons from the dashboard's > **Seasons** page. Start with the [end-to-end Season integration](/integrations/seasons/), then use the [Season picker](/buyer-sdk/seasons/) and [Seasons API](/server-api/seasons/) for the two implementation surfaces. ## Do not substitute one model for another - Do not create overlapping Performance Groups to simulate buyer-selectable date bundles. - Do not call a Performance Group a Season when no Contract, Seat Right, or renewal lifecycle exists. - Do not model a Season as independent Event holds; partial success would break the same-seat promise. - Do not treat an inventory booking as proof of payment, a commercial Order, a ticket, a pass, a refund, or attendance. - Do not send a server secret key to any buyer SDK. ## Next decision After choosing the model: 1. Return to the [product model](/start/product-model/) and confirm commerce, access, and SDK boundaries. 2. [Choose the product and integration boundary](/start/choose-an-integration/). 3. [Install the server SDK for your language](/server-sdk/install/). 4. Use test-mode Events and credentials for the full buyer-to-webhook rehearsal. 5. Complete the [Platform going-live checklist](/start/going-live/). # What is SeatLayer? > One seating inventory core for platform integrations and SeatLayer-managed ticketing. Source: https://docs.seatlayer.io/start/overview/ · Markdown: https://docs.seatlayer.io/start/overview/index.md SeatLayer is a reserved-seating platform for products and organizers that need buyers to choose a real seat, table, booth, or a general-admission area inside a seated chart. A pure-GA event without a seating chart is not a supported target. One inventory core supports two deliberately separate products: Platform/SDK infrastructure and SeatLayer-managed ticketing. > **The Platform/SDK contract** > > The **browser holds** inventory temporarily. **Your server books inventory** > after it has inspected the hold and completed your payment or order logic. > Your platform creates and delivers its own tickets. You do not need to learn the Designer to make your first chart. Describe the venue in plain words to ChatGPT, Claude, Gemini or SeatLayer Copilot, and SeatLayer builds the chart: see [your first chart with AI](/start/first-chart-with-ai/). For the relationship between inventory, audience access, pricing, and every SDK or API surface, read the [SeatLayer product model](/start/product-model/). For every ready-made surface, what it does and where its responsibility stops, read [product surfaces and their purpose](/start/product-model/#product-surfaces-and-their-purpose). ## Two objects power everything - **Chart** — The reusable venue document: rows, tables, booths, GA areas, sections, accessibility metadata, and pricing categories. - **Event** — One independent realtime inventory occurrence created from a published chart snapshot. Reuse one chart for many performances. An Event does not by itself imply a public page, checkout, order, or ticket. ## Seat lifecycle | Status | Meaning | Who changes it | |---|---|---| | `free` | Available to select | Expiry, release, or operator | | `held` | Reserved for a short checkout window | Buyer SDK | | `booked` | Permanently committed inventory | Your server or SeatLayer managed checkout | | `not_for_sale` | Removed from sale | Organizer or operator | The picker receives inventory updates in realtime, so buyers see a seat disappear before they can select stale inventory. A successful booking is all-or-nothing: conflicts return without partially booking the request. ## Choose the product boundary first ### SeatLayer Platform / Seating Infrastructure Use this for a marketplace or ticketing platform that already owns its event catalogue and commerce. SeatLayer supplies workspaces, charts, event inventory, buyer SDKs, holds, idempotent booking, webhooks, inventory reports, and embedded Designer/Control room tools. Your platform remains the sole owner of buyer accounts, checkout and payment, commercial Orders, ticket/QR/PDF creation and delivery, email, refunds, check-in, and customer support. `bookingRef` links your order to the SeatLayer inventory booking; SeatLayer does not mirror that purchase into its hosted fulfilment product. ### SeatLayer Hosted Ticketing Use this when a direct organizer wants SeatLayer to run the end-to-end sale. Hosted Ticketing is self-serve: there is no access request or approval step. The organizer creates a full event in the dashboard and connects their own payment gateway on the **Payments** page (Stripe, PayPal, Square, Braintree, Authorize.Net, Checkout.com, Mollie, Razorpay, Tap, Mercado Pago, Xendit, Flutterwave, Paystack or iyzico). A free event needs no payment account. SeatLayer can then provide hosted checkout, Orders, tickets, QR/PDF delivery, email, refunds, and Door/check-in through a Hosted Event Page, managed embed, or Organizer Website. The connected provider controls its processing fees, applicable taxes, and payout timing. Commerce ownership is fixed for an Event. Changing between Platform/SDK and Hosted Ticketing means reusing the Chart and creating a new Event with the intended product boundary. ## In a Platform/SDK integration, what stays in your product - Event catalogue and buyer identity. - Commercial orders, payments, refunds, fees, and tax logic. - Tickets, QR/barcodes, PDFs, email delivery, scanning, and support. - The final decision to confirm a booking. - Your own checkout UI and business rules. ## What the shared SeatLayer inventory core provides - A white-label Designer for authoring complex venues. - Stable web SDKs for script tag, JavaScript, React, Vue, and Angular, plus supported production SDKs for React Native, iOS, Android, and Flutter. - Realtime holds and inventory synchronization. - Server APIs, signed inventory webhooks, an embeddable Control room, booking history, and inventory reporting. ## Current Platform/SDK scope An Event remains the base inventory boundary. Each Event has its own chart snapshot, availability, channels, and reports. For one fixed run of 2–8 compatible assigned-seat Events, [Performance Groups](/integrations/performance-groups) add one coordinated hold and booking flow. For a renewable fixed Plan of 2–20 compatible Events, [Fixed Renewable Seasons](/integrations/seasons/) add same-seat holds and booking, incumbent Seat Rights, renewal offers, occurrence amendments, and operational reports. Reusing one Chart across independent Events alone does not create a Season or shared entitlement. The current Platform/SDK release does not provide: - partial or buyer-built Seasons, or arbitrary per-performance Plan overrides; - arbitrary event baskets, buyer-selected date subsets, or partial status changes across a Performance Group; - organizer-defined custom inventory statuses; - true partial-GA channel allocation or season-inherited channels; or - cross-product aggregate reporting beyond the documented Performance Group and Season contracts. Booked seats can be [listed for resale](/server-api/inventory/#resale) on their event. A listed seat stays booked until a new buyer books it, then moves to the new booking. Resale moves seats only: the resale price, the new buyer's payment, and any refund or payout to the original holder stay in your system. Separately, `unbook` returns booked inventory to `free` for an ordinary new sale. > **Current SDK availability** > > Web JavaScript, React, Vue, and Angular packages provide their documented > surfaces. React Native, iOS, Android, and Flutter are supported production > SDKs. Vue and Angular provide a native Seating Chart wrapper plus the > raw JavaScript Picker/frame helpers; they do not imply native Designer or > Control room components. - **Build the full loop** — Continue to the [Quickstart](/start/quickstart) to create a test event, embed the picker, and confirm a booking. - **Inspect the server contract** — Jump to the [booking endpoint](/server-api/booking) for request fields, responses, and conflict handling. ## Open-source resources - **SeatLayer SDK** — Inspect the official JavaScript, React, Vue, and Angular packages in [seatlayer/seatlayer-sdk](https://github.com/seatlayer/seatlayer-sdk). - **SeatLayer for Flutter** — Follow the [Flutter seat-map guide](/buyer-sdk/flutter), install [`seatlayer`](https://pub.dev/packages/seatlayer), or inspect the source in [seatlayer/seatlayer-flutter](https://github.com/seatlayer/seatlayer-flutter). - **SeatLayer for React Native** — Build Expo, iOS, and Android applications with [seatlayer/seatlayer-react-native](https://github.com/seatlayer/seatlayer-react-native). - **SeatLayer iOS SDK** — Evaluate the public Swift package source in [seatlayer/seatlayer-ios](https://github.com/seatlayer/seatlayer-ios). - **SeatLayer Android SDK** — Build native Kotlin applications with [seatlayer/seatlayer-android](https://github.com/seatlayer/seatlayer-android). - **SeatLayer AI Toolkit** — Give a coding agent the portable integration skill, diagnostics, and verification workflow from [seatlayer/seatlayer-ai-toolkit](https://github.com/seatlayer/seatlayer-ai-toolkit). - **SeatLayer on GitHub** — Discover all public SeatLayer repositories at [github.com/seatlayer](https://github.com/seatlayer). The SeatLayer platform and inventory service remain private. Public repositories contain supported SDKs, examples, and agent-integration tooling. # Understand the SeatLayer product model > Relate Events, Performance Groups, Seasons, sales channels, buyer access, SDKs, holds, bookings, and host-owned commerce before implementing. Source: https://docs.seatlayer.io/start/product-model/ · Markdown: https://docs.seatlayer.io/start/product-model/index.md SeatLayer is a reserved-seat ticketing and seating platform. Venues and organizers design the room, sell on their own website and at the box office counter, and take payment through their own gateway. Ticketing platforms embed the same seating engine through the SDK and API. SeatLayer has one inventory core and several ways to compose it. The concepts below answer different questions and can be combined, but they do not replace one another. > **The shortest correct mental model** > > Choose **who owns commerce**, then choose the **inventory transaction**, add > any **audience access**, and finally choose the **SDK or API surface**. A > browser or mobile SDK creates a temporary hold; a trusted server commits the > inventory booking. ## Product surfaces and their purpose These are the ready-made surfaces SeatLayer publishes. Each row says what the surface is for, where it runs, and where its responsibility stops. The same table mirrors `product-positioning.json`, the structured source that also feeds the machine-readable `llms.txt` index and the public knowledge MCP, so these answers do not drift between surfaces. | Surface | What it does | Where it runs | Boundary | |---|---|---|---| | [Venue Designer](/designer/charts-and-objects/) | Create reusable charts with rows, sections, tiers, floors, tables, categories and accessibility information. | SeatLayer dashboard, or the white-label Embedded Designer inside your own application, admin portal or CMS. | Your team reviews and publishes the chart. Saving is always allowed; publishing runs the validation rules. | | [Embedded Designer for multi-tenant platforms](/platform/embedded-designer/) | Let each of your customers create and edit their own venue inside your product, without a separate SeatLayer login. | White-label editor mounted by the SDK using a short-lived session scoped to one workspace, chart and origin. | Your backend authenticates the tenant, checks ownership and grants read, edit or publish authority. | | [Workspace isolation](/platform/workspaces/) | Keep each tenant's charts and events separate inside one SeatLayer organization. | Server API and scoped embedded sessions, typically one workspace per organizer or customer. | Billing, API keys, webhooks and team roles stay organization-wide. Workspace isolation does not give every tenant its own managed commerce. | | [Buyer seat picker](/buyer-sdk/seat-picker/) | Let buyers browse availability, find seats together, choose exact seats and create temporary holds. | Ready-made SeatPicker or the lower-level SeatingChart control, on the web and in the supported mobile packages. | In SDK mode the platform owns checkout and the final booking. A browser hold is not a ticket and not a payment. | | [Live inventory, holds and booking](/start/inventory-models/) | Keep selection, expiring holds, booked seats and blocked seats consistent for one event. | Shared inventory service behind the buyer picker, the trusted server APIs and the operator surfaces. | Each event sells on its own, so its seats stay consistent under load and a sell-out never slows another event. No unlimited-concurrency claim is implied. | | [Control Room](/platform/embedded-control-room/) | Let staff inspect live inventory, block seats, manage sections, categories, tables and channels, and run reporting or host-owned selection tools. | Same-document operator board embedded in your own product, plus the dashboard operations view. | Your backend authorizes an event-scoped session with explicit capabilities. View-only is the default. This is not the account administration panel. | | [Sales channels and allocations](/platform/sales-channels/) | Allocate one event's seats to public sale, partners, presales, sponsors, groups or the box office, with audience-specific prices. | Scoped buyer sessions, channel rules and operator tools over a single event inventory. | A channel says who may select inventory; it is not authorization on its own and it does not create a second event. Commission settlement is not included; [resale](/server-api/inventory/#resale) is a separate inventory feature. | | [Hosted Ticketing end to end](https://seatlayer.io/sell-tickets-on-your-website/) | Sell seated events from your own website: seat selection, checkout through your own payment gateway, orders, QR and PDF tickets, confirmation email and supported refunds. | Managed inline picker, event card or buy button on your site. A hosted event page is an optional extra route. | The organizer owns setup, policies, the payment gateway and buyer support. Gateway fees and tax are separate from the ticket fee. | | [Box Office and Door](https://seatlayer.io/box-office/) | Sell at the counter against the same live inventory and scan issued tickets to record entry. | Staff tools inside Hosted Ticketing. | The organizer supplies authorized staff. Platform SDK customers keep their own selling and ticket-scanning systems. | | [Optional 3D and seat view](/buyer-sdk/3d-view/) | Help buyers understand the venue layout and where a seat sits, using the same chart and the same selection state. | On-demand buyer 3D and available seat-view media alongside the complete 2D map. | Chart and browser support are required. Generated geometry gives orientation; it is not a certified photographic sightline. | | [SDKs, REST API and webhooks](https://seatlayer.io/developers/) | Connect your product to seating, trusted booking and inventory updates. | Web and native buyer packages, seven server SDK families, REST and OpenAPI, and signed webhooks. | Use the release catalog for per-package versions and support. Do not infer feature parity across every SDK. | | [Copilot and agent integrations](/agents/overview/) | Help with chart creation and editing, buyer seat discovery and integration research. | Designer Copilot, the authorized Designer MCP, opt-in buyer WebMCP and the public read-only knowledge MCP. | Chart changes stay reviewable revisions and publishing stays a human action. Buyer agents get no payment authority. AI-led 3D improvement is not available. | | [Performance Groups](/integrations/performance-groups/) | Coordinate one purchase across a fixed set of 2 to 8 compatible performances. | Documented Platform SDK integration with one group hold and one group booking. | Available to every organizer. Not an arbitrary basket of events. It creates no durable renewal right. | | [Fixed Renewable Seasons](/integrations/seasons/) | Coordinate same-seat plans, renewal offers, seat rights, amendments and reports across 2 to 20 compatible events. | Browser, REST and OpenAPI, and all seven server SDK families. | Organizer-CMS rollout is controlled by account. Native mobile Season pickers and Season Best Available are not supported. The platform keeps commerce. | ## Read the model in this order | Decision | Question it answers | Canonical guide | |---|---|---| | Product boundary | Does your product or SeatLayer own checkout, payment, orders, and tickets? | [Choose an integration](/start/choose-an-integration/) | | Inventory model | Is the buyer purchasing one Event, one multi-performance run, or one renewable Plan? | [Choose an inventory model](/start/inventory-models/) | | Sales and access | Is inventory public, private, allocated to a partner, or shared with several eligible audiences? | [Sales channels and allocations](/platform/sales-channels/) | | Integration surface | Does the experience run on web, Flutter, React Native, iOS, Android, or only on a trusted backend? | [Choose an SDK or API](/sdks/) | | Price presentation | Does an Event use its normal prices, ticket types, a temporary offer, or an audience-specific channel price? | [Ticket pricing and offers](/platform/ticket-pricing-and-offers/) | These decisions are independent. For example, a Platform integration can use a Performance Group, sell part of each member Event through private channels, render the buyer journey on the web, and still keep payment and ticket fulfilment in the host product. The product boundary itself is immutable for an Event. A buyer surface does not change who owns commerce; reuse the Chart and create a new Event if the integration must move between Platform/SDK and Hosted Ticketing. ## Core entities and relationships | Entity | Owns | Relates to | |---|---|---| | **Chart** | Reusable venue geometry, labels, sections, categories, and accessibility metadata | A published Chart snapshot creates one or more Events | | **Event** | Realtime availability for one occurrence | The base inventory unit; may participate in a Performance Group or a Season Plan | | **Performance Group** | One coordinated purchase across a fixed 2–8 Event run | Creates one atomic group hold and booking; it creates no durable renewal right | | **Season** | A renewable programme and its lifecycle | Contains an immutable 2–20 Event Plan, Contracts, Seat Rights, and renewal offers | | **Sales Channel** | The audience assignment for inventory on one Event | Public sale or exactly one private channel per seat; it does not create another Event or stock count | | **Buyer access session** | Short-lived browser authority for an exact audience and origin | May expose Public sale and/or named channels; it can hold but never book | | **Hold** | Temporary inventory protection during selection or checkout | Created by a buyer surface or trusted server and expires unless booked | | **Booking** | Permanent inventory commitment | Created from a trusted server; linked to the host order by `bookingRef` | | **Offer or channel price** | A rule for resolving the price shown and frozen on a hold | Changes price only; it does not grant access or reserve inventory | ### The inventory hierarchy ```text Chart (reusable venue design) └── Event (one realtime occurrence) ├── may be a member of one Performance Group purchase ├── may be included in a fixed Season Plan └── owns Public sale and private Sales Channel assignments ``` A Chart is not inventory. An Event is. Reusing one Chart for several dates does not automatically make those dates a Performance Group or Season. ### The access hierarchy ```text Event inventory ├── Public sale └── Private Sales Channel ├── hosted buyer link ├── buyer access session for your website or app ├── staff selling flow └── Partner access for an external organisation ``` A channel answers **who may select this inventory**. Blocking answers whether the inventory is on sale. An offer answers which price applies. Those are three separate controls. ### The Platform transaction boundary ```text Buyer SDK → temporary hold → your trusted checkout → SeatLayer booking └────────────→ your order, payment, tickets ``` The hold handoff is an inventory reference, not proof of payment and not a ticket. Your backend inspects authoritative allocations, applies your commerce rules, then books with a retry-safe reference. Read the [single-Event Platform flow](/start/how-it-works/) for the complete sequence. ## Follow one canonical feature family Each feature family has one overview and separate pages for different jobs. Use the overview to understand the concept; use the integration guide to build the workflow; use the buyer page for UI behavior; use the API page for exact operations and schemas. | Feature family | Understand and decide | Build end to end | Buyer surface | Trusted API | |---|---|---|---|---| | Single Event | [Platform flow](/start/how-it-works/) | [Quickstart](/start/quickstart/) | [SeatPicker](/buyer-sdk/seat-picker/) | [Events](/server-api/events/), [holds](/server-api/holds/), and [booking](/server-api/booking/) | | Performance Group | [Inventory model comparison](/start/inventory-models/#choose-a-performance-group) | [Performance Group integration](/integrations/performance-groups/) | [Performance Group picker](/buyer-sdk/performance-groups/) | [Performance Groups API](/server-api/performance-groups/) | | Fixed Renewable Season | [Inventory model comparison](/start/inventory-models/#choose-a-fixed-renewable-season) | [Season integration](/integrations/seasons/) | [Season picker](/buyer-sdk/seasons/) | [Seasons API](/server-api/seasons/) | | Sales channels and partner access | [Sales channels and allocations](/platform/sales-channels/) | [Organizer private-sales guide](/integrations/private-and-partner-sales/) | [Partner selling guide](/integrations/partner-selling/) | [Buyer access sessions](/server-api/buyer-access-sessions/) and [Channels API](/server-api/channels/) | ## Do not infer these relationships - A Performance Group is not a Season. It has no Contract, Seat Right, or renewal lifecycle. - A Season is not a set of independent Event holds. Its same-seat transaction and Plan lifecycle are coordinated as one product. - A Sales Channel is not a second event, cloned inventory, a login, or a block. - Partner access is an operating context for an allocated channel. It does not grant the organizer's secret key or direct booking authority. - A scheduled offer changes price. It does not make inventory private or reserve it for an audience. - A client SDK does not own payment, orders, tickets, refunds, or fulfilment in a Platform integration. - A server SDK is a typed way to call the same trusted contracts. It is not a buyer-facing widget. ## Recommended reading paths ### Building a ticketing platform 1. [Choose the Platform boundary](/start/choose-an-integration/). 2. [Choose Event, Performance Group, or Season inventory](/start/inventory-models/). 3. [Choose a client SDK](/buyer-sdk/) and [server SDK](/server-sdk/). 4. Add [Sales Channels](/platform/sales-channels/) only when audience allocation is required. 5. Rehearse the complete hold, payment, booking, webhook, and recovery path in test mode. ### Running Hosted Ticketing as a direct organizer 1. Create the event in the dashboard; dashboard events use SeatLayer checkout. 2. Choose a paid or free event. 3. For a paid event, connect the organizer's own payment gateway on the **Payments** page and assign it to the event. 4. Choose a Hosted Event Page, managed embed, or Organizer Website without changing the Event's commerce boundary. 5. Complete the managed readiness checks before opening sales. ### Integrating a private or partner sale 1. Read the [Sales Channels model](/platform/sales-channels/). 2. Follow the [organizer integration](/integrations/private-and-partner-sales/) or the [partner operating guide](/integrations/partner-selling/), according to your role. 3. Use the [Buyer Access Sessions API](/server-api/buyer-access-sessions/) for an authenticated app flow and the [Channels API](/server-api/channels/) for exact allocation operations. ### Building a renewable season product 1. Confirm that a [Season is the correct inventory model](/start/inventory-models/). 2. Follow the [end-to-end Season integration](/integrations/seasons/). 3. Implement the [Season picker](/buyer-sdk/seasons/) and trusted [Seasons API](/server-api/seasons/) as two halves of the same transaction. # Platform SDK quickstart > Create a test inventory event, embed the picker, and commit inventory from your own backend. Source: https://docs.seatlayer.io/start/quickstart/ · Markdown: https://docs.seatlayer.io/start/quickstart/index.md > **Using SeatLayer checkout? You don't need a key** > > Copy the embed code from [Sell & share](/start/first-event/#create-the-managed-event). > This page is for events that take payment with your own checkout. SeatLayer's SDK is the reserved-seating layer for a ticketing website: it draws the venue, shows live seat availability, and returns the exact seats a buyer picked. Adding it takes two steps: embed the picker in the browser with a public key, then confirm the booking from your own server with a secret key. Your platform keeps its checkout, its own payment provider, and its commercial orders, tickets, delivery, refunds, and check-in. ## Packages | Surface | Package | Docs | | --- | --- | --- | | JavaScript / TypeScript | `@seatlayer/js` | [Install the Buyer SDK](/buyer-sdk/install) | | React | `@seatlayer/react` | [React SDK](/buyer-sdk/react) | | Vue | `@seatlayer/vue` | [Vue SDK](/buyer-sdk/vue) | | Angular | `@seatlayer/angular` | [Angular SDK](/buyer-sdk/angular) | | React Native | `@seatlayer/react-native` | [React Native SDK](/buyer-sdk/react-native) | | Flutter | `seatlayer` | [Flutter SDK](/buyer-sdk/flutter) | | iOS | `seatlayer-ios` Swift package | [iOS SDK](/buyer-sdk/ios) | | Android | `io.seatlayer:seatlayer-android` and `-android-compose` | [Android SDK](/buyer-sdk/android) | | Node.js (server) | `@seatlayer/server` | [Node server SDK](/server-sdk/node) | | Python (server) | `seatlayer` | [Python server SDK](/server-sdk/python) | | PHP (server) | `seatlayer/seatlayer-php` | [PHP server SDK](/server-sdk/php) | | Ruby (server) | `seatlayer` | [Ruby server SDK](/server-sdk/ruby) | | Java (server) | `io.seatlayer:seatlayer-java` | [Java server SDK](/server-sdk/java) | | .NET (server) | `SeatLayer` | [.NET server SDK](/server-sdk/dotnet) | | Go (server) | `github.com/seatlayer/seatlayer-go` | [Go server SDK](/server-sdk/go) | Current verified versions for every package live in the machine-readable [SDK catalog](/sdk-catalog.json). Install from there rather than copying a version number between packages. > **Start in test mode** > > Use a `pk_test_…` / `sk_test_…` key pair. Sandbox events behave like live events, but bookings spend no credits. Going live is a credential swap after the flow works. 1. **Create an event** Publish a chart in the Designer, then create a sandbox event from your server. ```js title="server/create-event.js" {5} const response = await fetch("https://api.seatlayer.io/v1/events", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, }, body: JSON.stringify({ chartId: "ch_grand_theatre", name: "Dress rehearsal", }), }); const { meta } = await response.json(); console.log(meta.key); // ev_9f3a ``` 2. **Install the buyer SDK** Use the CDN directly or add the JavaScript package to your app. ```sh npm install @seatlayer/js@0 pnpm add @seatlayer/js@0 yarn add @seatlayer/js@0 bun add @seatlayer/js@0 ``` 3. **Authorize Public-sale browser access** Keep the `pk_test_…` public half of the key pair you used to create the event, and register your buyer site's exact HTTPS origin under **Embed domains** in the dashboard. The public key is safe to publish in browser code; the matching `sk_test_…` is not. On first render the SDK sends the event key, public key, and browser-provided origin directly to SeatLayer. SeatLayer verifies their account and test/live mode, creates a short-lived Public-sale-only session, and returns its in-memory bearer together with the chart and compact inventory status. There is no customer-backend token round trip on this path. A public key cannot reveal presale, login, partner, or channel inventory. Those audiences still use an authenticated [`buyerAccessTokenProvider`](/server-api/buyer-access-sessions); an explicit provider or token always takes precedence over `publicKey`. 4. **Embed the picker** Choose the integration that matches your frontend. All variants return the same `holdId` checkout handoff. > **Event key or public key?** > > Replace `` with your event's key. Find it at the top of the event's page in the dashboard, with a Copy button, or read `meta.key` from the `POST /v1/events` response. It starts with `ev_`. > > The two keys go in different places: > > - `event` takes the **event key** (`ev_…`). It says which event to show. > - `publicKey` takes your **public key** (`pk_test_…` or `pk_live_…`), from **Developer → API keys**. It says which account is asking. > > Both must come from the same account, and a `pk_test_` key only opens test events. A 404 on first load usually means one of the two is in the wrong place, the event key is still the placeholder, or the event sells through SeatLayer checkout rather than your own (those events use the hosted page, not the SDK). ### Script tag ```html title="checkout.html"
``` ### JavaScript ```js title="checkout.js" import { SeatPicker } from "@seatlayer/js"; const picker = new SeatPicker({ container: "#picker", event: "", publicKey: "", onCheckout: async (_, __, handoff) => { await fetch("/api/checkout", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ holdId: handoff.holdId }), }); }, }); await picker.render(); ``` ### React ```tsx title="Checkout.tsx" import { SeatPicker } from "@seatlayer/react"; export function Checkout() { return ( { await fetch("/api/checkout", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ holdId: handoff.holdId }), }); }} /> ); } ``` 5. **Book inventory from your server** Resolve authoritative inventory and configured prices from the hold, complete your payment logic, then book inventory with the secret key. After success, create and deliver tickets from your own platform. ```js title="server/checkout.js" {3,15,24} const eventKey = ""; const headers = { authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`, "content-type": "application/json", }; const holdResponse = await fetch( `https://api.seatlayer.io/v1/events/${eventKey}/holds/${holdId}`, { headers }, ); if (!holdResponse.ok) throw new Error("Hold is no longer active"); const hold = await holdResponse.json(); // Price from trusted hold items, not values posted by the browser. const order = await createPaidOrder(hold.items); const bookResponse = await fetch( `https://api.seatlayer.io/v1/events/${eventKey}/book`, { method: "POST", headers, body: JSON.stringify({ holdId, bookingRef: order.id, }), }, ); if (!bookResponse.ok) await handleBookingFailure(bookResponse, order); ``` > **Never expose the secret key** > > Browser code may receive a publishable `pk_…` for ordinary Public bootstrap, > but never an `sk_…`. SeatLayer keeps the resulting `bse_…` in the SDK's memory; > a private-audience provider must do the same rather than using storage or a > URL. Keep every `sk_test_…` and `sk_live_…` in a server-side secret manager. ## Next Use the [complete checkout example](/examples/complete-checkout) as an implementation template, then read [integration best practices](/integrations/best-practices) before going live. If you would rather have SeatLayer run checkout, tickets and the door instead of building them yourself, follow [your first managed event](/start/first-event/) for the direct-organizer route. # Delivery and retries > Build an idempotent webhook receiver for asynchronous, scoped, bounded-attempt delivery. Source: https://docs.seatlayer.io/webhooks/delivery-and-retries/ · Markdown: https://docs.seatlayer.io/webhooks/delivery-and-retries/index.md Webhook delivery is asynchronous. A hold or booking does not wait for your receiver, and delivery order across independent occurrences is not guaranteed. Treat each verified message as a fact that may arrive late or more than once. ## Delivery contract - Each HTTP attempt has a ten-second timeout. - Any `2xx` acknowledges the delivery. - Non-`2xx` responses and transport failures are retryable. - SeatLayer makes the first attempt plus up to three retries. - Exhausted messages leave the automatic path; attempts remain inspectable. - Each matching subscription has an independent retry chain. One failing endpoint does not redeliver the occurrence to healthy sibling endpoints. - Delivery remains at-least-once, so every endpoint must still deduplicate. ## Scoped delivery An occurrence reaches a subscription only when the subscribed event name and both routing axes match. | Subscription mode | Environment | Receives | |---|---|---| | `null` | `null` | Every matching event | | `live` | `null` | Live events in any environment | | `live` | `prod` | Live events created in `prod` | | `test` | `staging` | Test events created in `staging` | A `null` subscription field is a wildcard. Event environment is stamped from the secret key used to create that event. Payload `livemode` remains available as a second handler guard. ## Idempotent processing Use `occurrenceId` as the primary deduplication key. Keep `deliveryId` for transport debugging. ```ts title="server/idempotent-receiver.ts" await database.transaction(async (tx) => { const inserted = await tx.claimWebhook(message.occurrenceId); if (!inserted) return; await applyBusinessEvent(tx, message); }); return new Response(null, { status: 204 }); ``` Useful secondary constraints include: - `(eventId, bookingRef)` for a completed order; - `(eventId, holdId, event)` for hold lifecycle; - your own immutable order or tenant reference. Do not mark an occurrence processed before the business transaction succeeds. Do not return `2xx` for a failure you expect SeatLayer to retry. ## Delivery ledger The API stores the newest attempts for each subscription, including: - HTTP status (`0` when no response arrived); - attempt and maximum attempts; - `willRetry`; - occurrence id and transport id; - the sent payload; - a truncated non-`2xx` response body; or - a transport error message. The list defaults to 20, caps at 50 per page, and supports `ok`/`failed` filtering. SeatLayer retains a bounded per-subscription history and applies a 30-day retention cleanup; export operational evidence you must retain longer. ## Manual redelivery Retrying from the dashboard sends the stored payload only to the selected subscription. It preserves `occurrenceId`, creates a new attempt/signature, and does not re-arm automatic retries. Your receiver should return success for an occurrence that was already applied: ```ts if (await wasProcessed(message.occurrenceId)) { return new Response(null, { status: 204 }); } ``` ## Failure-response hygiene Non-`2xx` response text can appear in the delivery ledger for debugging. Return a short safe error code. Never include secrets, stack traces, customer records, request headers, or raw internal exception messages. ## Production checklist - [ ] Verify the raw body before JSON parsing. - [ ] Compare signatures in constant time. - [ ] Store and atomically claim `occurrenceId`. - [ ] Return quickly; queue slow downstream work in your own system. - [ ] Support events arriving out of order. - [ ] Route by trusted workspace mapping and validate `livemode`. - [ ] Monitor failed deliveries and seven-day uptime. - [ ] Keep receiver errors short and non-sensitive. - [ ] Test duplicate, delayed, malformed, expired-secret, and retry cases. See [manage subscriptions](/webhooks/manage-subscriptions), [webhook events](/webhooks/events), and [signature verification](/webhooks/signatures). # Webhook events > The signed delivery envelope, routing fields, and every public webhook event with when it fires and a payload sample. Source: https://docs.seatlayer.io/webhooks/events/ · Markdown: https://docs.seatlayer.io/webhooks/events/index.md Every delivery is an HTTPS `POST` with one stable business occurrence id and one transport-attempt id. ```json { "deliveryId": "d_91c3", "occurrenceId": "evtocc_8a2f", "event": "seat.booked", "at": 1761436800000, "payload": { "workspaceId": "ws_7e2d", "eventId": "ev_9f3a", "labels": ["A-1"], "bookingRef": "order_42", "livemode": true } } ``` | Header | Value | |---|---| | `Content-Type` | `application/json` | | `X-SeatLayer-Event` | Event name | | `X-SeatLayer-Signature` | `sha256=` plus the raw-body HMAC | Verify the signature before parsing or acting on the payload. Older integrations may read `X-SeatMap-Event` and `X-SeatMap-Signature`, which carry the same values. ## Identity and routing fields | Field | Meaning | |---|---| | `occurrenceId` | Stable business occurrence; idempotency key across retries and subscriptions | | `deliveryId` | One HTTP delivery attempt | | `at` | Occurrence time in epoch milliseconds | | `payload.workspaceId` | Immutable workspace boundary | | `payload.externalRef` | Optional host reconciliation tag | | `payload.environment` | Optional routing environment inherited from event creation | | `payload.livemode` | `false` for test events, otherwise `true` | The four `payload` routing fields are added to Event events (`seat.*`, `hold.*`, `event.*`, and `object.resale.*`). `livemode` is always present; the other three appear when they are set. Season events use the [Season envelope](#season-events) instead and carry no routing fields. Use `workspaceId` plus your stored mapping to route tenants. Never use `externalRef` alone as authorization. ## Event catalog There are 40 public event names. Subscribe to them by exact name in the dashboard event picker or through the [subscriptions API](/webhooks/manage-subscriptions/). | Group | Events | |---|---| | [Event inventory](#event-inventory) | `event.created`, `event.soldout`, `hold.created`, `hold.extended`, `hold.expired`, `seat.booked`, `seat.released`, `seat.blocked` | | [Resale](#resale) | `object.resale.listed`, `object.resale.unlisted`, `object.resale.sold` | | [Season lifecycle](#season-lifecycle) | `season.structure.activated`, `season.structure.closed`, `season.structure.archived`, `season.plan.published`, `season.plan.superseded`, `season.sales.opened`, `season.sales.paused`, `season.sales.ended` | | [Season holds and bookings](#season-holds-and-bookings) | `season.hold.committed`, `season.hold.released`, `season.hold.expired`, `season.booking.confirmed`, `season.booking.cancelled`, `season.booking.emergency_requested`, `season.operation.partial_terminal` | | [Season dates](#season-dates-and-amendments) | `season.occurrence.returned`, `season.occurrence.reclaimed`, `season.amendment.applied`, `season.performance.added`, `season.performance.removed`, `season.performance.past` | | [Season renewals](#season-renewals) | `season.renewal.offered`, `season.renewal.intent_received`, `season.renewal.renewed`, `season.renewal.declined`, `season.renewal.lapsed`, `season.renewal.released` | | [Season orders](#season-orders) | `season.order.paid`, `season.order.refunded` | > **Server SDK type lists** > > The `0.8.0` server SDKs list 30 of these names in their typed event union. > The resale, Season date, performance, and order events arrive as ordinary > deliveries with the same envelope and signature. Handle the event name as a > string and keep a default branch for names your code does not know yet. ## Event inventory Samples below show `payload` only. Every one also carries `livemode` and, when set, `workspaceId`, `externalRef`, and `environment`. ### event.created Sent once when an event is created, copied, or added to a Season as a new performance. ```json { "eventId": "ev_9f3a", "name": "Opening Night", "chartId": "ch_41b2", "workspaceId": "ws_7e2d", "at": 1761436800000, "livemode": true } ``` ### event.soldout Sent the first time a booking leaves the event with no free inventory. It is sent at most once per event: a cancellation followed by another sell-out does not send it again. ```json { "eventId": "ev_9f3a", "at": 1761436800000, "livemode": true } ``` ### hold.created Sent when a hold is placed, including by Best Available. Replacing a hold's seats sends it again with `replaced: true` and the complete new set. ```json { "eventId": "ev_9f3a", "labels": ["A-11", "A-12"], "holdId": "h_9f2c", "expiresAt": 1761437700000, "items": [{ "label": "A-11", "objectId": "seat-a-11", "objectType": "seat", "categoryKey": "stalls", "tierId": null, "unitPrice": 45, "currency": "GBP", "quantity": 1 }], "livemode": true } ``` `items` is shortened here; see [line items](#line-items). ### hold.extended Sent when an active hold's expiry moves later: the buyer asked for more time, or checkout opened a payment window (`paymentWindow: true`). No labels, because the seats did not change. ```json { "eventId": "ev_9f3a", "holdId": "h_9f2c", "expiresAt": 1761438300000, "extends": 1, "livemode": true } ``` ### hold.expired Sent once per hold that reaches its expiry. A `seat.released` for the freed seats follows. ```json { "eventId": "ev_9f3a", "labels": ["A-11", "A-12"], "holdId": "h_9f2c", "items": [], "livemode": true } ``` ### seat.booked Sent when a booking succeeds: buyer checkout, server booking, box office, Best Available book, Performance Group, or Season. A repeated call with the same `bookingRef` does not send it again. ```json { "eventId": "ev_9f3a", "labels": ["A-12"], "bookingRef": "order_42", "holdId": "h_9f2c", "at": 1761436800000, "items": [{ "label": "A-12", "objectId": "seat-a-12", "objectType": "seat", "categoryKey": "stalls", "tierId": "adult", "unitPrice": 45, "currency": "GBP", "quantity": 1, "channelId": null, "channelExternalRef": null, "accessSource": "public" }], "livemode": true } ``` Items here carry [sale attribution](#sale-attribution-on-seatbooked). ### seat.released Sent when held, blocked, or booked inventory becomes free again. The payload depends on the cause: | Cause | Payload | |---|---| | Hold released, or seats dropped when a hold is replaced | `eventId`, `labels`, `holdId`, `items` | | Hold expired | `eventId`, `labels`, `items` | | `unbook` or a cancellation | `eventId`, `labels`, `items` | | Some tickets of a SeatLayer order refunded or cancelled from the dashboard | `eventId`, `labels`, `items`, `source: "ticket_refund"`, `bookingRef` | | Unblock, unblock all, a scheduled block ending, or a configuration no longer killing seats | `eventId`, `labels` | ```json { "eventId": "ev_9f3a", "labels": ["A-12"], "holdId": "h_9f2c", "items": [], "livemode": true } ``` When only some tickets of an order are refunded or cancelled, `labels` names just those seats and `bookingRef` is the order's booking, so you can match the release to the order. The rest of the order stays booked. No other release carries `source`; treat an unknown `source` value as a plain release. A listed [resale](#resale) seat whose buyer hold ends goes back on the resale market, not to free, so it is not in `seat.released`. ### seat.blocked Sent when free seats are blocked: a manual block, Best Available block, or an [Event Configuration](/server-api/events/#event-configuration-binding) taking seats off sale. ```json { "eventId": "ev_9f3a", "labels": ["K-1", "K-2"], "livemode": true } ``` ## Resale Sent by the [resale API](/server-api/inventory/#resale). Resale moves seats between bookings; it carries no price or payout. ```json title="object.resale.listed" { "eventId": "ev_9f3a", "labels": ["A-11", "A-12"], "listingId": "rsl_5c0e", "livemode": true } ``` ```json title="object.resale.unlisted" { "eventId": "ev_9f3a", "labels": ["A-11", "A-12"], "listingIds": ["rsl_5c0e"], "livemode": true } ``` ```json title="object.resale.sold" { "eventId": "ev_9f3a", "bookingRef": "order_77", "at": 1761436800000, "sold": [ { "label": "A-11", "listingId": "rsl_5c0e", "previousBookingRef": "order_42" }, { "label": "A-12", "listingId": "rsl_5c0e", "previousBookingRef": "order_42" } ], "livemode": true } ``` `listed` is sent on every successful call, including a repeat for seats already listed. `unlisted` is sent only when at least one seat came off. `sold` is sent with the new buyer's `seat.booked`; use `previousBookingRef` to find the original order and settle with its holder. ## Season events Every `season.*` payload uses the same envelope. The event-specific facts are in `data`. ```json { "seasonId": "sea_a245", "eventName": "season.booking.confirmed", "occurrenceKey": "sba_7", "structureState": "active", "salesState": "open", "activationId": "sac_1", "closeId": null, "data": { "actionId": "sba_7", "operationId": "sop_3", "bookingRef": "order_1", "state": "booked", "outcomes": [{ "eventId": "ev_1", "state": "booked", "code": null }] } } ``` | Field | Meaning | |---|---| | `structureState` | `draft`, `active`, `closing`, `closed`, or `archived`, at the time of the event | | `salesState` | `closed`, `open`, `paused`, or `ended`, at the time of the event | | `activationId` / `closeId` | The Season's current activation and close ids, or `null` | | `occurrenceKey` | The id that makes this occurrence unique within the Season | Season events are written to the Season outbox before delivery. Read or replay them with `GET /v1/seasons/:key/outbox`. ### Season lifecycle | Event | Sent when | `data` | |---|---|---| | `season.structure.activated` | The Season is activated | `{}` | | `season.structure.closed` | Closing starts | `{}` | | `season.structure.archived` | The Season is archived | `{}` | | `season.plan.published` | A Plan is published | `planId`, `activationId` (the Plan's) | | `season.plan.superseded` | A newer Plan replaces a published one | `planId`, `activationId` | | `season.sales.opened` | Sales open | `{}` | | `season.sales.paused` | Sales pause | `{}` | | `season.sales.ended` | Sales end | `{}` | ### Season holds and bookings | Event | Sent when | `data` | |---|---|---| | `season.hold.committed` | A same-seat hold is in place on every date | `operationId`, `holdId`, `planActivationId`, `state`, `outcomes[]` | | `season.hold.released` | The hold is released | `releaseActionId`, `operationId`, `holdId`, `state: "aborted"`, `outcomes[]` | | `season.hold.expired` | The hold expires | same as released, `state: "expired"` | | `season.booking.confirmed` | A Season booking is recorded | `actionId`, `operationId`, `bookingRef`, `state`, `outcomes[]` | | `season.booking.cancelled` | A Season booking is cancelled | `cancelActionId`, `bookingActionId`, `operationId`, `bookingRef`, `planActivationId`, `rightDisposition`, `state`, `outcomes[]` | | `season.booking.emergency_requested` | An operator starts emergency recovery for a booking | `recoveryActionId`, `bookingActionId`, `bookingRef`, `planActivationId`, `rightDisposition` | | `season.operation.partial_terminal` | A hold, booking, cancellation, or renewal ended with mixed per-date results | The fields of the matching operation, with `state` such as `partial_terminal`, `book_failed`, or `cancel_failed` | Each entry in `outcomes` is `{ eventId, state, code }` for one date. A `partial_terminal` result needs reconciliation, not a blind retry. ### Season dates and amendments | Event | Sent when | `data` | |---|---|---| | `season.occurrence.returned` | One booked date is returned to ordinary event inventory | `actionId`, `returnActionId`, `eventKey`, `planActivationId`, `bookingRef`, `source`, `state`, `labels` | | `season.occurrence.reclaimed` | A returned date that is still free goes back to its holder | same fields, `state: "reclaimed"` | | `season.amendment.applied` | A reschedule or date exception is recorded | `amendmentId`, `eventKey`, `kind`, `classification` (`identity_preserving` or `exception`) | | `season.performance.added` | A performance joins a published Season | `amendmentId`, `eventKey`, `planActivationId`, `occurrenceSetSha256`, `before`, `after` | | `season.performance.removed` | A performance leaves; `after.released` counts freed seats | same fields | | `season.performance.past` | A performance is first marked past | same fields; `before` and `after` are `{ eventKey, past, startsAt }` | Each `season.performance.*` event is followed by a `season.amendment.applied` for the same change. Returning or reclaiming a date sends only the `season.occurrence.*` event, not `seat.released` or `seat.booked`. ### Season renewals | Event | Sent when | `data` | |---|---|---| | `season.renewal.offered` | A renewal offer is created for a holder | `offerId`, `contractId` | | `season.renewal.intent_received` | The holder says they intend to renew | `offerId` | | `season.renewal.renewed` | The renewal is committed on every date | `offerId`, `commitActionId`, `state: "renewed"`, `outcomes[]` | | `season.renewal.declined` | The holder declines | `offerId` | | `season.renewal.lapsed` | The offer deadline passes with no answer | `offerId` | | `season.renewal.released` | The offer ends and its Seat Right is released | `offerId` | ### Season activity on each Event A Season booking or renewal also sends the ordinary `seat.booked` on each Event date, and `event.soldout` when it sells a date out. A Season cancellation that frees seats sends `seat.released`. In these Season copies, items have no `unitPrice` or `currency`, and there is no `holdId`: the package price belongs to your commerce system, not to one date. Season holds do not send `hold.created`. ## Season orders When SeatLayer takes the money for a season, two events tell your system about it. Both are season events: the payload is the season envelope, and the order facts are in `data`. ```json title="season.order.paid" { "deliveryId": "d_4b1e", "occurrenceId": "swo_9c2f…", "event": "season.order.paid", "at": 1790220682290, "payload": { "seasonId": "sea_a245…", "eventName": "season.order.paid", "occurrenceKey": "ord_ea11…", "structureState": "active", "salesState": "open", "activationId": "spa_0b23…", "closeId": null, "data": { "seasonKey": "sea_a245…", "orderId": "ord_ea11…", "packageId": "spl_97d1…", "labels": ["A-2", "A-3"], "amountMinor": 18000, "currency": "GBP", "holder": { "name": "Hanna Holder", "email": "hanna@example.com" }, "mode": "live", "channel": "online", "method": "stripe" } } } ``` | Field | Meaning | |---|---| | `packageId` | The package's plan key (`spl_…`), as `plans[].key` in `GET /v1/seasons/:key` | | `labels` | The seats, the same on every date of the package | | `amountMinor` | What the order cost, in minor units; `0` for a complimentary sale | | `holder.email` | `null` when a box office buyer gave no address | | `channel` | `online` for SeatLayer checkout and hosted renewals, `box_office` for a counter sale | | `method` | For `box_office`: `cash`, `card_external`, or `complimentary`. For `online`: the payment provider, such as `stripe` | It is sent once per order, when payment is confirmed. A box office sale counts: staff took the money at the counter and recorded how. A redelivered payment notice, or the counter retrying the same sale, sends nothing new. ```json title="season.order.refunded (data)" { "seasonKey": "sea_a245…", "orderId": "ord_2596…", "refundedMinor": 5000, "totalRefundedMinor": 5000, "currency": "GBP", "full": false, "mode": "live" } ``` It is sent once for each refund SeatLayer records through the payment provider: `refundedMinor` is this refund, `totalRefundedMinor` everything refunded on the order so far, and `full` is `true` once nothing is left. A refund made in the provider's own dashboard counts too. Cancelling an order without a refund, including a box office cancel, sends `season.booking.cancelled` instead. Neither event carries the provider's own refund or payment id. Both are delivered only to endpoints of the season's mode (or of every mode), and both appear in the season outbox (`GET /v1/seasons/:key/outbox`), where you can replay a missed one. ## Line items Where present, `items` are server-generated inventory snapshots: ```ts interface WebhookLineItem { label: string; objectId: string; objectType: "seat" | "booth" | "ga" | "table"; categoryKey: string; tierId: string | null; unitPrice: number; currency: string; quantity: number; bookingMode?: "individual" | "whole" | "variable"; capacity?: number; minOccupancy?: number; maxOccupancy?: number; releaseId: string | null; channelId: string | null; channelPricingVersion: number | null; accessSource?: "public" | "promoter" | "partner" | "hosted_link" | "staff_override"; } ``` `releaseId` names the [ticket release](/server-api/events/#ticket-releases-and-on-sale-waves) the price came from. Use items for order reconciliation and customer messaging. Your payment system remains authoritative for charges, captures, refunds, taxes, and settlement. ## Sale attribution on `seat.booked` Items on a `seat.booked` payload additionally carry which [sales channel](/platform/sales-channels) made the sale, recorded at the moment of booking and never rewritten afterwards. ```ts interface BookedLineItem extends WebhookLineItem { /** The channel that sold this unit. `null` means Public sale. */ channelId: string | null; /** That channel's stable external reference, when it has one. */ channelExternalRef: string | null; /** How the buyer was authorized when the unit was captured. */ accessSource: "public" | "promoter" | "partner" | "hosted_link" | "staff_override"; /** Present only when the buyer's grant carried a partner reference. */ partnerRef?: string; } ``` ```json { "label": "A-12", "channelId": "chn_7f2a", "channelExternalRef": "travel-agency-a", "accessSource": "partner", "partnerRef": "travel-agency-a" } ``` Four things worth knowing before you write the handler: - **`channelId: null` is a value, not a gap.** It means the seat was sold on Public sale. The field is always present on a booked item, so `null` and "an older delivery that predates this" stay distinguishable. - **Attribution is per item, not per delivery.** One `bookingRef` can cover units captured under different channels and different access sources. Do not read `items[0]` and assume the rest match. - **It never changes.** Moving or archiving a channel later does not rewrite the attribution on a sale that already happened, which is what makes these fields safe reconciliation keys. - **`channelExternalRef` is the key you can match on.** A SeatLayer channel id means nothing in your partner's system; the external reference is the one you set yourself. > **Webhooks never carry credentials** > > A payload contains no bearer token, buyer access session, hosted-link > capability, secret key, or raw authentication code, ever. `partnerRef` is an > opaque label your own backend supplied when it minted the buyer's access, and > it is the only identity-adjacent field on the payload. ### Reconciliation keys For a booking that came through a private channel, reconcile on: - the SeatLayer `bookingRef`; - your own order reference; - the partner's order reference, where you captured one; - `channelExternalRef`, stable across renames; - the `occurrenceId` and your idempotency key, for replay safety. ## Replacement and release nuances - Replacing a hold emits `hold.created` with `replaced: true` and the complete new held set. - Labels removed during replacement also emit `seat.released`. - Expiry emits one `hold.expired` per hold and a release occurrence for freed labels. - Manual hold release, unblock, scheduled block release, and cancellation can all produce `seat.released`. - `hold.extended` carries no labels because inventory identity did not change. Design handlers around the event name and ids rather than inferring the cause only from a seat status. ## Minimal handler ```ts title="server/webhooks.ts" export async function handleSeatLayerWebhook(request: Request) { const rawBody = await request.text(); verifySeatLayerSignature( rawBody, request.headers.get("x-seatlayer-signature"), ); const message = JSON.parse(rawBody); if (await wasProcessed(message.occurrenceId)) { return new Response(null, { status: 204 }); } switch (message.event) { case "seat.booked": await reconcileBooking(message.payload); break; case "seat.released": await reconcileRelease(message.payload); break; case "object.resale.sold": await settleResale(message.payload); break; default: await recordUnhandledEvent(message); } await markProcessed(message.occurrenceId); return new Response(null, { status: 204 }); } ``` Persist the business mutation and processed occurrence id atomically when possible. Return `2xx` for a known duplicate. An Event booking response or exact Booking History record, and a terminal Performance Group or Season booking poll, remain the checkout command outcome. Use these deliveries to reconcile that outcome, not as permission to charge or fulfil a second time. Continue to [manage subscriptions](/webhooks/manage-subscriptions/), [delivery and retries](/webhooks/delivery-and-retries/), and [signature verification](/webhooks/signatures/). # Manage subscriptions > Create, scope, pause, inspect, and delete webhook subscriptions through the Server API; test and manually retry from the dashboard. Source: https://docs.seatlayer.io/webhooks/manage-subscriptions/ · Markdown: https://docs.seatlayer.io/webhooks/manage-subscriptions/index.md Use the Webhooks API when subscription setup must be repeatable across customer provisioning, environments, or deployment pipelines. One-off integrations can use the dashboard over the same underlying resources. The stable Server API/SDK surface covers subscription CRUD and delivery-list inspection. Sending a representative test and manually redelivering one stored attempt are operator actions in the dashboard for this launch; they are not published OpenAPI operations or server-SDK methods. Do not build an integration against the dashboard's internal action routes. ## Authentication Secret keys have organization authority within their webhook routing scope. Dashboard reads allow signed-in members; writes require an admin. Event-scoped manage tokens cannot access webhook configuration. Dashboard members see every subscription in their organization. Secret keys are narrower for webhook management: - a live key cannot list, inspect, update, or delete a test-mode subscription, and a test key cannot manage a live subscription; - an environment-tagged key can manage subscriptions only in that same webhook routing environment; and - a key without an environment tag is broad across environments, but only within its live/test mode. Out-of-scope subscription IDs answer `404` and reveal no delivery metadata. This fence protects webhook routing configuration. Event `environment` remains routing metadata, not an event authorization boundary. Legacy subscriptions whose mode is `null` continue to receive both live and test occurrences, but are dashboard-managed only. An admin can assign one a concrete live/test mode before returning its management to a secret-key integration. | Method and path | Purpose | |---|---| | `GET /v1/webhooks` | List subscriptions and seven-day uptime | | `POST /v1/webhooks` | Create; signing secret returned once | | `PATCH /v1/webhooks/:id` | Update URL, events, disabled state, or scope | | `POST /v1/webhooks/:id/rotate-secret` | Rotate signing secret; replacement returned once | | `DELETE /v1/webhooks/:id` | Delete subscription | | `GET /v1/webhooks/:id/deliveries` | Inspect delivery attempts | ## Create a subscription **POST /v1/webhooks**. Authentication: Secret key or admin dashboard session ```bash curl -sX POST "https://api.seatlayer.io/v1/webhooks" \ -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "url": "https://example.com/webhooks/seatlayer", "events": [ "seat.booked", "seat.released", "event.soldout" ] }' ``` The URL must use HTTPS, fit within 2,048 characters, and `events` must be a non-empty set of known names. An organization can keep up to 10 active subscriptions and 25 subscriptions in total, including paused ones. Delete an unused paused subscription to free a saved slot. ```json { "sub": { "id": "sub_8a2f", "url": "https://example.com/webhooks/seatlayer", "events": ["seat.booked", "seat.released", "event.soldout"], "disabled": false, "lastStatus": null, "lastAt": null, "createdAt": 1761436800000, "mode": "live", "environment": "prod", "uptime7d": null }, "secret": "whsec_1f8c" } ``` > **Store the secret now** > > `secret` is returned only at creation or rotation and is never included in > list or patch responses. Rotation preserves subscription identity, scope, > and delivery history while invalidating the previous secret for new sends. ## Scope delivery Subscriptions can filter by `mode` and `environment`. - A secret-key create inherits that key's mode and environment. - A dashboard-session create defaults to live mode and accepts an optional environment. - A dashboard admin, or a key without an environment tag, may use a `null` environment as a wildcard. A dashboard admin may also use `mode: null` for a cross-mode legacy subscription; no live/test secret key can manage it. ```json { "url": "https://staging.example.com/webhooks/seatlayer", "events": ["seat.booked"], "mode": "test", "environment": "staging" } ``` A secret-key create always derives scope from the key; supplied `mode` or `environment` fields cannot override it. Secret keys cannot rescope a subscription after creation. Identical echoed fields are accepted as no-ops; changing either axis returns `403 webhook_scope_immutable`. A key that names an out-of-scope subscription receives `404` before any resource metadata is returned. ## Update or pause **PATCH /v1/webhooks/:id**. Authentication: Secret key or admin dashboard session Send only changed fields. `events` replaces the full set. ```json { "events": [ "seat.booked", "seat.released", "hold.created", "hold.expired" ], "disabled": true } ``` Pause with `disabled: true` while repairing a receiver. Existing delivery history remains available. Dashboard admins can send `mode: null` or `environment: null` to restore a wildcard. Secret keys may update URL, events, and disabled state only; they cannot change subscription scope. ## Rotate the signing secret **POST /v1/webhooks/:id/rotate-secret**. Authentication: Secret key or admin dashboard session Rotation returns `{ sub, secret }` with `Cache-Control: no-store`. The new secret is shown once. Subscription ID, URL, event set, scope, and delivery history are unchanged; new deliveries immediately use only the replacement. Pause the endpoint first when the receiver deployment cannot update its secret atomically. ## Send a test In the dashboard, choose **Send test** on a subscription. SeatLayer sends a signed `ping` and waits up to five seconds: ```json { "status": 200, "ok": true, "event": "ping", "responseBody": null } ``` An admin can choose a representative payload such as: ```json { "event": "seat.booked" } ``` A sample is available for every event name in the [event reference](/webhooks/events/#event-catalog). Samples are fixed examples and may omit fields a real delivery carries, so check your handler against a real test-mode event before going live. Test requests carry `test: true`, are delivered inline, and are not written to the normal delivery ledger. ## Inspect deliveries **GET /v1/webhooks/:id/deliveries**. Authentication: Secret key or dashboard session Query parameters: | Parameter | Behavior | |---|---| | `limit` | Default 20; maximum 50 | | `status` | `ok`, `failed`, or omit for both | | `before` | Timestamp cursor from `nextBefore` | Cursor pages can overlap at the timestamp boundary; de-duplicate by delivery `id`. Each record includes status, attempt count, retry state, occurrence id, stored payload, and a truncated receiver response or transport error on failure. ## Retry one delivery In the dashboard delivery panel, choose **Retry**. This sends the stored payload to that subscription only. It keeps the original `occurrenceId`, creates a new delivery attempt and signature, and does not restart the automatic retry chain. Manual retry is not a stable server-SDK operation in this release. ## Delete **DELETE /v1/webhooks/:id**. Authentication: Secret key or admin dashboard session Deletion removes the subscription. If temporary downtime is expected, prefer `disabled: true` so configuration and history remain available. Continue to [event payloads](/webhooks/events), [delivery and retries](/webhooks/delivery-and-retries), and [signature verification](/webhooks/signatures). # Verify webhook signatures > Validate every SeatLayer webhook using the raw body, HMAC-SHA256, and a timing-safe comparison. Source: https://docs.seatlayer.io/webhooks/signatures/ · Markdown: https://docs.seatlayer.io/webhooks/signatures/index.md Every webhook delivery is signed. Verify the signature before parsing or trusting its payload. ## Signature contract | Field | Value | |---|---| | Secret | The subscription's `whsec_…` secret | | Signed message | Exact raw request-body bytes | | Algorithm | HMAC-SHA256 | | Encoding | Lowercase hexadecimal | | Header | `X-SeatLayer-Signature: sha256=` | > **Capture the raw body first** > > Re-serializing a parsed JSON object can change whitespace, key order, or > number formatting. Verify the original bytes, then parse JSON. Legacy deliveries may also include `X-SeatMap-Signature` and `X-SeatMap-Event`. New integrations should verify `X-SeatLayer-Signature`. ## Implement verification ### Node.js ```js title="server/webhooks.js" import crypto from "node:crypto"; import express from "express"; function isValidSignature(rawBody, header, secret) { if ( typeof header !== "string" || !/^sha256=[0-9a-f]{64}$/i.test(header) ) return false; const expected = crypto .createHmac("sha256", secret) .update(rawBody) .digest(); const provided = Buffer.from(header.slice("sha256=".length), "hex"); return ( provided.length === expected.length && crypto.timingSafeEqual(provided, expected) ); } app.post( "/webhooks/seatlayer", express.raw({ type: "application/json" }), async (req, res) => { const valid = isValidSignature( req.body, req.header("X-SeatLayer-Signature"), process.env.SEATLAYER_WEBHOOK_SECRET, ); if (!valid) return res.status(401).send("invalid signature"); const delivery = JSON.parse(req.body.toString("utf8")); await enqueueIdempotently(delivery); return res.status(200).end(); }, ); ``` ### Python ```python title="server/webhooks.py" import hashlib import hmac import os from flask import request def is_valid_signature(raw_body: bytes, header: str, secret: str) -> bool: if not header.startswith("sha256="): return False expected = hmac.new( secret.encode(), raw_body, hashlib.sha256, ).hexdigest() provided = header.removeprefix("sha256=") return hmac.compare_digest(expected, provided) @app.post("/webhooks/seatlayer") def seatlayer_webhook(): raw_body = request.get_data(cache=True) header = request.headers.get("X-SeatLayer-Signature", "") secret = os.environ["SEATLAYER_WEBHOOK_SECRET"] if not is_valid_signature(raw_body, header, secret): return "invalid signature", 401 enqueue_idempotently(request.get_json()) return "", 200 ``` ### PHP ```php title="server/webhooks.php"