Every delivery is an HTTPS POST with one stable business occurrence id and
one transport-attempt id.
{
"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 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.
| Group | Events |
|---|---|
| Event inventory | event.created, event.soldout, hold.created, hold.extended, hold.expired, seat.booked, seat.released, seat.blocked |
| Resale | object.resale.listed, object.resale.unlisted, object.resale.sold |
| 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.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.occurrence.returned, season.occurrence.reclaimed, season.amendment.applied, season.performance.added, season.performance.removed, season.performance.past |
| Season renewals | season.renewal.offered, season.renewal.intent_received, season.renewal.renewed, season.renewal.declined, season.renewal.lapsed, season.renewal.released |
| Season orders | season.order.paid, season.order.refunded |
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.
{ "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.
{ "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.
{
"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.
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.
{ "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.
{ "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.
{
"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.
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 |
{ "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 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 taking seats off sale.
{ "eventId": "ev_9f3a", "labels": ["K-1", "K-2"], "livemode": true }Resale
Sent by the resale API. Resale moves seats between bookings; it carries no price or payout.
{ "eventId": "ev_9f3a", "labels": ["A-11", "A-12"], "listingId": "rsl_5c0e", "livemode": true }{ "eventId": "ev_9f3a", "labels": ["A-11", "A-12"], "listingIds": ["rsl_5c0e"], "livemode": true }{
"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.
{
"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.
{
"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.
{
"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:
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
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 made the sale, recorded at the moment
of booking and never rewritten afterwards.
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;
}{
"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: nullis a value, not a gap. It means the seat was sold on Public sale. The field is always present on a booked item, sonulland “an older delivery that predates this” stay distinguishable.- Attribution is per item, not per delivery. One
bookingRefcan cover units captured under different channels and different access sources. Do not readitems[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.
channelExternalRefis 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.
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
occurrenceIdand your idempotency key, for replay safety.
Replacement and release nuances
- Replacing a hold emits
hold.createdwithreplaced: trueand the complete new held set. - Labels removed during replacement also emit
seat.released. - Expiry emits one
hold.expiredper hold and a release occurrence for freed labels. - Manual hold release, unblock, scheduled block release, and cancellation can
all produce
seat.released. hold.extendedcarries 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
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, delivery and retries, and signature verification.