Skip to content

Webhook events

The signed delivery envelope, routing fields, and every public webhook event with when it fires and a payload sample.

Updated View as Markdown

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.

object.resale.listedjson
{ "eventId": "ev_9f3a", "labels": ["A-11", "A-12"], "listingId": "rsl_5c0e", "livemode": true }
object.resale.unlistedjson
{ "eventId": "ev_9f3a", "labels": ["A-11", "A-12"], "listingIds": ["rsl_5c0e"], "livemode": true }
object.resale.soldjson
{
  "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.

season.order.paidjson
{
  "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.

season.order.refunded (data)json
{
  "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: null is a value, not a gap. It means the seat was sold on Public sale. The field is always present on a booked item, so null and “an older delivery that predates this” stay distinguishable.
  • Attribution is per item, not per delivery. One bookingRef can cover units captured under different channels and different access sources. Do not read items[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.
  • channelExternalRef is 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 occurrenceId and your idempotency key, for replay safety.

Replacement and release nuances

  • Replacing a hold emits hold.created with replaced: true and the complete new held set.
  • Labels removed during replacement also emit seat.released.
  • Expiry emits one hold.expired per hold and a release occurrence for freed labels.
  • Manual hold release, unblock, scheduled block release, and cancellation can all produce seat.released.
  • hold.extended carries 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

server/webhooks.tsts
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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close