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 for the complete lifecycle and responsibility boundary. Use the Season picker 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. 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.
| 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.
| 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 are authoritative for
the exact method spelling and request shape. The current OpenAPI document is
not a backport into 0.8.0.
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:
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
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 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:
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 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:
npm install @seatlayer/server@0.8.0import { 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
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:
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:
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:
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
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.
Use the JavaScript, React, Vue, or Angular Season picker
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.
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:
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.
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. 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:
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
/v1/seasons/:key/importsSecret key/v1/seasons/:key/imports/:importIdSecret key/v1/seasons/:key/renewal-offersSecret key/v1/seasons/:key/renewal-offersSecret key/v1/seasons/:key/renewal-offers/:offerIdSecret key/v1/seasons/:key/renewal-offers/:offerId/extendSecret key/v1/seasons/:key/renewal-offers/:offerId/inspectSecret key/v1/seasons/:key/renewal-offers/:offerId/commitSecret key/v1/seasons/:key/renewal-offers/:offerId/declineSecret key/v1/seasons/:key/renewal-offers/:offerId/releaseSecret keyDry-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.
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.
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
/v1/seasons/:key/imports/from-seasonSecret keyInstead 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.
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 }{
"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
Return the exact labels from one active booked Season allocation to ordinary Event inventory without cancelling the holder’s complete Season contract:
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:
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_…orsk_test_…). It reaches the seasons of its own account and mode. Another account’s season, or a live season from a test key, is404 not_found. - A publishable key gets
403 publishable_key_not_allowed. A dashboard login cookie gets401. - 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_…), theplans[].keyofGET /v1/seasons/:key.
List holders
/v1/seasons/:key/holdersSecret keyEveryone 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 |
{
"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
/v1/seasons/:key/orders/:orderId/refundSecret keyPays 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.
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 }{
"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
/v1/seasons/:key/orders/:orderId/cancelSecret keyFor 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.
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 }{
"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
/v1/seasons/:key/orders/:orderId/dates/:eventKey/cancelSecret keyTakes 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.
POST /v1/seasons/sea_a245…/orders/ord_ea11…/dates/ev_3709…/cancel
Authorization: Bearer sk_live_••••••••
Idempotency-Key: cancel-ord_ea11-night-1{
"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
/v1/seasons/:key/orders/:orderId/resendSecret keySends the season confirmation with every live ticket. Pass email to send it
to a new address, which also replaces the one on the order.
{ "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
/v1/seasons/:key/renewal-hostedSecret key/v1/seasons/:key/renewal-offers/:offerId/linkSecret key/v1/seasons/:key/renewal-offers/:offerId/contactSecret key/v1/seasons/:key/renewal-noticesSecret key/v1/seasons/:key/renewal-offers/:offerId/resendSecret keyWith 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:
{
"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/linkanswers201with{ "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/contactwith{ "email", "name" }answers{ "contact": { "name": "Ana Silva", "email": "ana@example.com" } }. A new email turns the current link off.POST /renewal-noticesemails 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/resendsends one holder a new invitation and answers202with{ "ok": true }. The old link stops working. A holder with no email is422 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
/v1/seasons/:key/reports/salesSecret key/v1/seasons/:key/reports/seatsSecret key/v1/seasons/:key/reports/holders.csvSecret key/v1/seasons/:key/reports/orders.csvSecret key/v1/seasons/:key/seat-countsSecret keyGET /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.
{
"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:
{
"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.csvhas 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 sameqandstatusas the holder list. Past 50,000 holders it is413 download_too_large: filter by status.orders.csvhas 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 for the payloads.
Amendments, reports, and support
/v1/seasons/:key/occurrencesSecret key/v1/seasons/:key/amendmentsSecret key/v1/seasons/:key/amendmentsSecret key/v1/seasons/:key/amendments/:amendmentIdSecret key/v1/seasons/:key/reportsSecret key/v1/seasons/:key/operationsSecret key/v1/seasons/:key/support-lookupsSecret key/v1/seasons/:key/outboxSecret key/v1/seasons/:key/outbox/:occurrenceId/replaySecret keyA 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.
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:
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"]
}