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.
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 |
Use workspaceId plus your stored mapping to route tenants. Never use
externalRef alone as authorization.
Event catalog
| Event | Fires when | Main payload fields |
|---|---|---|
event.created |
Event materialization succeeds | eventId, name, chartId, workspaceId, at |
hold.created |
A hold is created or replaced | eventId, labels, holdId, expiresAt, items, replaced? |
hold.extended |
Active hold expiry moves forward | eventId, holdId, expiresAt, extends |
hold.expired |
Hold reaches expiry | eventId, labels, holdId, items |
seat.booked |
Buyer/server/POS booking succeeds | eventId, labels, bookingRef, items, holdId?, at |
seat.released |
Held, blocked, or booked inventory returns to free | eventId, labels, holdId?, items? |
seat.blocked |
Organizer blocks free inventory | eventId, labels |
event.soldout |
Last free inventory is booked for the first time | eventId, at |
season.amendment.applied |
Reschedule or explicit occurrence exception recorded | amendmentId, eventKey, kind, classification |
season.occurrence.returned (candidate) |
One booked Season occurrence is returned to ordinary Event inventory | actionId, returnActionId, eventKey, planActivationId, bookingRef, source, labels |
season.occurrence.reclaimed (candidate) |
A still-free occurrence return is restored to its Season holder | actionId, returnActionId, eventKey, planActivationId, bookingRef, source, labels |
season.operation.partial_terminal |
Season operation reached visible mixed terminal outcomes | operation identity and occurrence outcomes |
Fixed Renewable Seasons also emit structure, Plan, sales, hold, booking, and
renewal lifecycle names under the season.* namespace. Subscribe using the
dashboard event picker or the exact names in the Seasons API contract. Each
delivery retains one stable occurrenceId across automatic and manual replay.
The two occurrence inventory events above belong to the unpublished REST/Node
candidate and are not a claim about the deployed baseline.
Occurrence return and reclaim remain separate candidate operations: they emit
season.occurrence.returned or season.occurrence.reclaimed through the Season
coordinator and project realtime inventory, not the standard Event
seat.released or seat.booked occurrences described in the caution above.
event.soldout is guarded to emit once per event. A cancellation followed by a
new ordinary booking of the freed inventory does not create a second sold-out
occurrence. This webhook wording does not imply a first-class resale/listing
state; the current Platform SDK has none.
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;
}Use them 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;
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.