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 for buyer UI options and the Seasons API for exact operations, request fields, responses, lifecycle recovery, and support tooling. If your holders live in your own CMS or CRM, step 8 walks the year from your server: holders, refunds, renewals, reports, and the webhooks that keep the two in step.
Confirm that Season is the right model
| Requirement | Use |
|---|---|
| One dated occurrence | Event flow |
| One atomic purchase across a fixed 2–8 Event run, with no retained renewal right | Performance Group |
| 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 before provisioning either multi-event product.
The complete Season journey
Your serverCreates compatible Events
Every occurrence uses the same published chart, venue, timezone, currency, environment, and Platform commerce boundary.
SeatLayerFreezes and publishes one Plan
Validation, activation, and publication establish the immutable occurrence set buyers will receive.
Your teamRehearses the complete path
A test buyer holds, books, cancels, and produces successfully delivered webhook evidence before sales open.
BuyerChooses one seat package
SeasonPicker holds the same labels atomically across every Event in the published Plan.
Your backendPrices, charges, and books
The host inspects the opaque operation, calculates the package amount, takes payment, and commits one retry-safe booking.
SeatLayerRetains rights for renewal
The completed inventory booking produces the Contract and Seat Rights used for later renewal offers.
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.
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 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.
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:
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.
POST /v1/seasons/sea_main_stage/activate HTTP/1.1
Authorization: Bearer sk_test_••••••••
Content-Type: application/json
{ "expectedRevision": 1 }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:
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:
POST /v1/seasons/sea_main_stage/publish HTTP/1.1
Authorization: Bearer sk_test_••••••••
Idempotency-Key: membership-2027-publishKeep each package’s planKey: every later call and webhook names a package by
it (packageId). These routes are REST only; the
Seasons API 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:
- mint a Season buyer session for the test origin;
- create a same-seat hold with
SeasonPicker; - inspect and book that hold from the trusted backend;
- cancel the test booking with an explicit Seat Right disposition; and
- receive successful webhook delivery for the required lifecycle events.
Then ask SeatLayer to validate the retained evidence:
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:
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.
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.
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:
- calculate the package amount, benefits, tax, and discounts from its own catalogue;
- create or recover one commercial order;
- authorize or capture payment according to its recovery policy; and
- book the same Season operation with one caller-stable action id and the
order’s immutable
bookingRef.
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/<seasonKey>. 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.
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:
- create and publish the successor Plan;
- generate time-bounded Renewal Offers for eligible Contracts;
- present the commercial renewal terms in your product;
- inspect the accepted renewal inventory on your server;
- take payment; and
- 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 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.
List holders
One list for everyone who holds the season, however they bought:
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 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.
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:
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:
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:
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:
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 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; 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/<token> 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; 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 |
| Catalogue, lifecycle, booking, import, renewal, and support operations | Seasons API |
| A shorter multi-performance purchase without renewal rights | Performance Group integration |
| A mobile Event picker | Native app SDKs |