The official SeatLayer Ruby server SDK — the trusted side of a reserved-seating
integration. Inspect what a hold really contains, price from server-owned seating-chart
data, and book with a stable booking_ref, while managing charts, events, inventory,
allocations, and webhooks through one typed ticketing API client.
seatlayer gem on RubyGems ·
SeatLayer server SDK documentation ·
SeatLayer reserved-seating platform ·
SeatLayer JavaScript seat map SDK ·
Server API reference
Server-side only. This gem authenticates with your secret key. Never load it anywhere a ticket buyer can reach — browser surfaces get short-lived, origin-bound tokens that you mint here.
gem "seatlayer"gem install seatlayerRequires Ruby 3.0 or newer. No runtime dependencies — net/http, json and openssl from the
standard library.
require "seatlayer"
client = SeatLayer::Client.new(ENV.fetch("SEATLAYER_SECRET_KEY"))
# 1. Provision a venue for a new organiser from a public template.
chart = client.templates.instantiate_template("arena-standard")["meta"]
client.charts.publish(chart["id"])
# 2. Create an event on it.
event = client.events.create(chart_id: chart["id"], name: "Spring Gala")["meta"]
# 3. Sell four seats over the phone.
held = client.inventory.hold_best_available(event["key"], qty: 4)
# … take payment against held["items"], which carry authoritative prices …
client.inventory.book(event["key"], hold_id: held["holdId"], booking_ref: "order-8842")Nullable event-create fields distinguish omission from an explicit reset: passing, for example,
venue: nil sends JSON null; leaving venue out sends no field.
Keys carry their own mode. sk_test_… keys can only touch test-mode events and sk_live_… only
live ones; crossing them returns 403 mode_mismatch, surfaced as AuthError with mode_mismatch?.
client = SeatLayer::Client.new(ENV.fetch("SEATLAYER_SECRET_KEY"))
raise "Refusing to boot production against test-mode seating data." if
ENV["RAILS_ENV"] == "production" && client.mode != "live"A publishable pk_ key is rejected at construction with a message naming the mistake, rather than
failing as a 401 three round-trips later.
Buyer picks seats in the browser. Your frontend holds them; your backend confirms the price and
books. Never price from what the browser sent you — retrieve_hold is authoritative.
hold = client.inventory.retrieve_hold(event_key, hold_id)
total = hold["items"].sum { |item| item["unitPrice"] }
# … charge `total` in hold["currency"] …
client.inventory.book(event_key, hold_id: hold_id, booking_ref: charge.id)Your backend picks the seats. Phone orders, box office, comps.
# Payment already taken — book outright, so nothing is stranded if a second call fails.
client.inventory.book_best_available(event_key, qty: 2, booking_ref: "phone-1183")
# Or name the seats yourself.
client.inventory.box_office_book(event_key, labels: ["A-1", "A-2"], booking_ref: "comp-14")Channels split event inventory into explicit allocations. A channel id is reporting/routing metadata, not browser authority. Authenticate the buyer in your backend and mint a short-lived token restricted to the event, origin, and allowed allocations:
access = client.channels.create_buyer_access_session(
event_key,
channel_ids: ["chn_partner_a"],
include_public: false,
allowed_origin: "https://tickets.example"
)
# Return access["token"] to the in-memory buyerAccessTokenProvider only.Never log or persist the returned bse_… bearer. Allocation setup, previews,
pause/archive controls, audit-safe session listing, and channel reports are on
client.channels.
list returns one page plus a nextCursor. list_all pages for you and returns a lazy
Enumerator when no block is given — the point of paginating is to not hold an unbounded result
set in memory, so .lazy.first(n) stops fetching once it has enough.
# One page, your own paging.
page = client.events.list(limit: 50)
page["events"]
page["nextCursor"] # nil once exhausted
# Or let the SDK walk it.
client.events.list_all do |event|
sync(event)
end
# Lazily — this fetches one page, not all of them.
client.charts.list_all.lazy.first(5)Listing events includes live availability counts by default, which costs the server one
round-trip per event. list_all turns them off automatically — walking a whole catalogue is
exactly when you don't want that — and you can control it explicitly:
client.events.list(limit: 50, counts: false)When an order takes longer than the checkout window — an invoice, a phone sale — extend rather than release and re-hold. Releasing first hands the seats to whoever is racing for them in between.
begin
client.inventory.extend_hold(event_key, hold_id, ttl_ms: 10 * 60_000)
rescue SeatLayer::ConflictError
# Gone, expired, or at its renewal cap — the buyer has to re-pick.
endYour secret key never reaches a browser. Mint a scoped token instead.
session = client.sessions.create_manage_session(
event_key,
allowed_origin: "https://box-office.yourplatform.com",
capabilities: ["event:view", "event:block"],
expires_in_seconds: 3600
)capabilities is required by this SDK even though the raw API safely defaults an omitted list
to view-only (event:view). Keeping the argument required makes browser authority visible at every
call site. Grant the smallest set the page needs.
The full set, all opt-in:
| Capability | Grants |
|---|---|
event:view |
Read the seat map and its live states |
event:block |
Block and unblock seats |
event:cancel |
Unbook paid seats and issue gateway refunds — destructive, moves money |
event:reports |
Read sales and availability reports |
event:channels:view |
Read sales channels and their allocations |
event:channels:manage |
Create, pause and archive channels; rotate access links |
event:orders:read |
Read SeatLayer-managed orders |
event:refund |
Refund a SeatLayer-managed order |
event:tickets:send |
Send SeatLayer-managed tickets |
event:door:view |
Read the door list |
event:door:checkin |
Check tickets in and out |
event:boxoffice |
Use the managed box-office surface |
The two event:channels:* capabilities are not in the default — a token minted before sales
channels existed must not silently acquire channel authority — so ask for them explicitly if the
page manages channels.
Designer minting returns the API envelope unchanged: read the token and effective safe-mode and
feature policy under result["session"]. Pass safe_mode_options only with mode: "safe".
Subscription responses use the wire envelopes exactly: list returns {"subs" => [...]},
create returns {"sub" => ..., "secret" => ...} (the secret is shown once), and update
returns {"sub" => ...}. SeatLayer::Webhooks::EVENT_NAMES is the exact eight-name event set;
delivery history accepts limit, status ("ok" or "failed"), and before.
Verify every delivery against the raw body. Re-encoding a parsed Hash changes the bytes and verification will fail.
# Rails
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def seatlayer
event = SeatLayer::Webhook.verify(
request.raw_post, # raw body, never params
request.headers["X-SeatLayer-Signature"],
ENV.fetch("SEATLAYER_WEBHOOK_SECRET")
)
# The signed body carries `at`, but nothing enforces a freshness window, so a
# captured delivery stays valid indefinitely. Deduplicate on occurrenceId —
# this is your replay protection, not an optimisation.
return head :ok if already_processed?(event["occurrenceId"])
Handler.call(event)
head :ok
rescue SeatLayer::WebhookVerificationError
head :bad_request
end
endbegin
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| Class | Status | Means |
|---|---|---|
AuthError |
401, 403 | Bad, revoked, or wrong-mode key |
NotFoundError |
404 | No such resource for this organisation |
ConflictError |
409 | Inventory moved, or a guard rejected the change |
ValidationError |
422 | Understood and rejected |
RateLimitError |
429 | Over budget; carries retry_after |
ConnectionError |
— | No answer: DNS, TLS, socket, timeout |
All descend from SeatLayer::Error, so rescue SeatLayer::Error catches everything. Every API
error carries status, code, body and request_id — quote the request id in support requests.
Retries. Reads (GET/HEAD) retry 429, 408 and 5xx with exponential backoff and full jitter;
Retry-After wins when the server sends it. Automatic mutation retries are limited to the five
operations backed by exact response replay: charts.create, charts.copy,
templates.instantiate_template, events.create, and workspaces.create. Other 4xx responses
are never retried.
Idempotency. Those five replay-backed operations carry an Idempotency-Key, generated when you
do not supply one and reused across attempts. Other mutations are single-attempt and receive no
automatic key. A caller-supplied key is forwarded but does not enable retries. This includes
inventory holds and bookings, show-once credential or secret creation, unsupported operations, and
raw request mutations. Keep booking_ref in the booking body for reconciliation, but handle an
unknown network outcome explicitly instead of automatically repeating the sale.
client.events.create(chart_id: chart_id, idempotency_key: "provision-event-#{event_id}")SeatLayer::Client.new(
ENV.fetch("SEATLAYER_SECRET_KEY"),
max_retries: 3, # total attempts
timeout: 30.0 # seconds, per attempt
)For surface this SDK does not wrap yet, request keeps auth and error mapping. Raw reads retain the
read retry policy; raw mutations are always single-attempt because their replay contract is unknown:
client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 })| Resource | Methods |
|---|---|
charts |
list list_all create retrieve update delete copy archive unarchive publish |
templates |
instantiate_template |
events |
list list_all create retrieve retrieve_configuration_binding update_configuration_binding update delete update_poster delete_poster update_chart close reopen archive retrieve_hold_ttl update_hold_ttl list_ticket_releases update_ticket_releases close_ticket_release retrieve_report retrieve_log |
inventory |
hold hold_best_available book_best_available extend_hold retrieve_hold release book box_office_book unbook list_bookings retrieve_booking block unblock unblock_all retrieve_availability update_availability |
channels |
list_channels create_channel update_channel update_assignments list_allocation retrieve_access_preview retrieve_report pause unpause archive create_buyer_access_session list_buyer_access_sessions revoke_buyer_access_session create_access_link list_access_links rotate_access_link revoke_access_link |
sessions |
create_manage_session revoke_manage_session create_designer_session revoke_designer_session |
webhooks |
list create update delete list_deliveries |
workspaces |
list create retrieve update |
Full reference: docs.seatlayer.io/server-sdk
Some API surface is intentionally unwrapped, not merely pending:
- Hosted-checkout orders and refunds. Reading or refunding a SeatLayer-hosted-checkout sale is not a server-SDK capability. Those records only exist for organisations using hosted checkout; if you run your own commerce store you refund in that store, through your own gateway.
- Connecting or assigning payment gateways. Connecting one is a dashboard flow, so shipping only the assignment half across seven SDKs would hand you a method that cannot yet succeed.
- Realtime seat updates. Live seat state reaches the browser through the widget's own socket.
There is no server-side subscribe; a secret-key caller gets authoritative state from
events.retrieve_reportandinventory.retrieve_availability.
None of these are reachable through request as a supported path either — they are excluded from
the public manifest, not just from the wrapper.
Create a client with your secret key, obtain a hold id — either from the buyer's
browser session or by holding server-side — and call inventory.book(event_key, hold_id: ..., booking_ref: ...).
booking_ref is your own stable order id and is the join between SeatLayer
inventory and your commercial order, so the same reference identifies the booking
in Booking History and when you later cancel it. For phone orders, box office, and
comps, inventory.book_best_available books outright with no browser involved.
The buyer SDK runs where the ticket buyer is: it renders the interactive seating chart, handles seat selection, and creates temporary holds. This server SDK is the trusted side. It authenticates with your secret key, inspects what a hold actually contains, prices from server-owned data, and books. Never bundle the secret key into a browser or a mobile app — browser surfaces get short-lived, origin-bound tokens that you mint here.
A hold reserves seats against concurrent buyers for a limited window.
inventory.retrieve_hold(event_key, hold_id) is the authoritative answer for what is held
and at what price, so charge from its items rather than from anything the browser
sent you. When an order runs longer than the checkout window, inventory.extend_hold
renews the hold instead of releasing and re-holding, which would hand the seats to
whoever is racing for them. Bookings carry the server's exact-selection plus
booking_ref safeguard, but the SDK sends each booking once — reconcile an unknown
outcome before trying again.
Yes. SeatLayer never processes payment. Inspect the hold, compute the charge from
the returned items and their authoritative unitPrice and currency, take the
money through whichever provider you already use — Stripe, Adyen, Razorpay, or your
own — and then book the hold with your order id as booking_ref. SeatLayer owns
seating state, holds, booking concurrency, and the inventory ledger; your platform
owns payments, commercial orders, tickets, delivery, and refunds.
- Follow the SeatLayer server SDK guide for installation, authentication, and the full hold-to-booking flow.
- Handle errors, retries, and safe booking repeats before connecting a production order flow.
- Verify SeatLayer webhooks to react to holds, expiry, and bookings on your server.
- Browse the SeatLayer server API reference for every endpoint behind this SDK.
- Generate clients from the SeatLayer OpenAPI description or explore the raw API surface.
- Point AI coding agents at the SeatLayer docs index
(
llms.txt) for an agent-readable map of the documentation. - Explore every SeatLayer SDK on GitHub across web, mobile, and server.
| Surface | Package or source |
|---|---|
| JavaScript | @seatlayer/js |
| React | @seatlayer/react |
| React Native | @seatlayer/react-native |
| iOS | seatlayer-ios |
| Flutter | seatlayer |
| Android | seatlayer-android |
| Node.js (server) | @seatlayer/server |
| Python (server) | seatlayer |
| PHP (server) | seatlayer/seatlayer-php |
| Ruby (server) | seatlayer (this package) |
| .NET (server) | SeatLayer |
| Java (server) | io.seatlayer:seatlayer-java |
| Go (server) | github.com/seatlayer/seatlayer-go |
bundle install
bundle exec rubocop
bundle exec rspecMIT