---
title: "Webhook events"
description: "Understand the signed delivery envelope, occurrence identity, routing metadata, and all public event payloads."
---

Every delivery is an HTTPS `POST` with one stable business occurrence id and
one transport-attempt id.

```json
{
  "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.

<Aside type="caution" title="Unpublished Season Event-webhook candidate">
  The current Worker source candidate also emits the standard Event-local
  `seat.booked` and, when applicable, `event.soldout` occurrences for an initial
  Season booking or renewal commit. A full Season cancellation emits the
  standard Event-local `seat.released` occurrence for inventory it actually
  frees. Exact replay does not emit those occurrences again, and no internal
  Season child hold id is exposed. Season-projected Event webhook items omit
  `unitPrice` and `currency`; child-Event configuration values remain available
  only in trusted Event booking history and are never the Season package price,
  amount paid, or SeatLayer revenue. This is a candidate-specific exception to
  the complete line-item shape below. The behavior has not been deployed and
  must not be inferred from the released baseline. These standard Event
  occurrences currently use the EventDO's direct queue path, not the central
  Season outbox; durable reconciliation after a producer send failure remains
  release-hardening work.
</Aside>

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:

```ts
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](/platform/sales-channels) made the sale, recorded at the moment
of booking and never rewritten afterwards.

```ts
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;
}
```

```json
{
  "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.

<Aside type="caution" title="Webhooks never carry credentials">
  A payload contains no bearer token, buyer access session, hosted-link
  capability, secret key, or raw authentication code — ever. `partnerRef` is an
  opaque label your own backend supplied when it minted the buyer's access, and
  it is the only identity-adjacent field on the payload.
</Aside>

### 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

```ts title="server/webhooks.ts"
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](/webhooks/manage-subscriptions/), [delivery
and retries](/webhooks/delivery-and-retries/), and [signature
verification](/webhooks/signatures/).