Webhooks
The complete webhook reference — the four seat event types, the Stripe-style envelope, and SeatBuilder-Signature HMAC-SHA256 verification.
Webhooks
Webhooks let your backend react to seat-state changes it did not initiate — a hold expiring, another integration booking a seat. This guide documents every event type, the exact payload envelope, and how to verify a delivery is genuinely from SeatBuilder. The concepts behind the statuses live in Core concepts.
Registering an endpoint
Register an HTTPS endpoint with POST /api/v1/webhooks, subscribing to
the event types you care about. At least one event type is required. The
URL must be a reachable public HTTPS endpoint (no localhost, no private
IP ranges).
POST /api/v1/webhooks HTTP/1.1
Host: seatbuilder.org
X-Api-Key: sk_live_xxx
Content-Type: application/json
{
"url": "https://your-app.example.com/webhooks/seats",
"events": ["seat.held", "seat.booked", "seat.released", "seat.hold_expired"],
"description": "Order-sync worker"
}Registration returns the endpoint's signing secret once — store it in
your secret manager. Rotating the URL does not rotate the secret; call
POST /api/v1/webhooks/{id}/rotate-secret separately. See the
Webhooks API reference for the full endpoint
lifecycle (update, disable, delivery log, manual replay).
Event types
SeatBuilder emits four seat event types, one event type, plus one test-delivery type:
| Event type | Fires when |
|---|---|
seat.held | A seat is held (a holdToken reserves it). |
seat.booked | A held seat is promoted to a permanent booking. |
seat.released | A held or booked seat is released back to free. |
seat.hold_expired | A hold reaches holdExpiresAt and auto-expires to free, or another buyer's /hold takes over a seat whose previous hold had already expired. |
event.chart_changed | An event is moved to its own copy of its chart (POST /events/{eventKey}/actions/move-to-new-chart-copy, or the dashboard's "Give this event its own chart"). Not sent when a chart is republished. |
webhook.test | A test delivery you trigger from the dashboard or API — same envelope, no real seat change. |
seat.hold_expired fires from a background sweep, not the instant
holdExpiresAt passes — expect it within a few seconds of expiry, not
immediately. The seat itself is free for every read and write (a fresh
/hold, /book, /status) exactly at holdExpiresAt, before this
webhook fires, so don't gate your own logic on receiving it; treat it as
an eventual notification for reconciliation, not a live signal.
The payload envelope
Every delivery is a Stripe-style envelope. The signed body is a JSON object with these fields:
| Field | Type | Notes |
|---|---|---|
id | string | evt_<nanoid> — the canonical idempotency key. The same id is sent on every retry, so dedupe on it. |
type | string | One of the event types above (or webhook.test). |
created | number | Unix seconds at envelope creation time. |
livemode | boolean | true for production endpoints, false for sandbox. |
data.object | object | The seat that changed (see below), or for event.chart_changed the event that moved. |
For the seat.* types, data.object carries the seat:
| Field | Type | Notes |
|---|---|---|
eventKey | string | The event the seat belongs to. |
objectLabel | string | The seat label, e.g. A-12. |
objectType | string | seat, table, gaArea, … |
status | 'free' | 'held' | 'booked' | 'not_for_sale' | The seat's status after the change. |
categoryKey | string | null | The pricing category, or null. |
extraData | object | null | Whatever opaque bag you attached, or null. |
holdExpiresAt | string (ISO-8601) | Present only for seat.held and seat.hold_expired. Absent otherwise. |
The hold-ownership token is intentionally absent from the payload. Leaking it would let any webhook recipient release or book the seat, so the envelope tells you that a seat changed state, never who holds it.
Example seat.held delivery body:
{
"id": "evt_9aQ2xk3",
"type": "seat.held",
"created": 1759431300,
"livemode": true,
"data": {
"object": {
"eventKey": "evt_q3-2026-jazz-night",
"objectLabel": "A-12",
"objectType": "seat",
"status": "held",
"categoryKey": "premium",
"extraData": { "orderId": "ord_98a2" },
"holdExpiresAt": "2026-10-02T18:55:00.000Z"
}
}
}For event.chart_changed, data.object carries the event and both charts:
{
"id": "evt_3Jd8sLq",
"type": "event.chart_changed",
"created": 1759820400,
"livemode": true,
"data": {
"object": {
"eventKey": "tg-prod-session-44778",
"chartKey": "chart_c0py4e",
"previousChartKey": "chart_8a2b1c"
}
}
}Update the chart you store for this event to chartKey: layout changes for
this event are now made on that chart, and publishing previousChartKey no
longer affects it. Holds and bookings are unchanged, so no seat.* webhooks
are sent for the move.
Verifying the SeatBuilder-Signature header
Every delivery carries a SeatBuilder-Signature header in the Stripe-style
format.
SeatBuilder-Signature: t=1726156800,v1=4f9c1d2a...t=<unix_seconds>— the timestamp when the platform signed the body.v1=<hex>— the HMAC-SHA256 of`${t}.${rawBody}`computed with your endpoint secret, lowercase hex.
Verification rules, mirrored from the platform's own signing code:
- Compute
HMAC-SHA256(secret, `${t}.${rawBody}`)and hex-encode it. - Verify over the raw request body — re-serialising the JSON changes the bytes (key order, whitespace) and breaks the HMAC.
- Compare with a constant-time equality check (
timingSafeEqual), short-circuiting first if the lengths differ. - Reject deliveries whose timestamp is more than ±300 seconds (5 minutes) from now, to defeat replay.
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
const app = express();
const REPLAY_WINDOW_SECONDS = 300; // ±5 minutes — the platform default
// Capture the raw body — HMAC must be computed over the exact bytes received.
app.post('/webhooks/seats', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.header('SeatBuilder-Signature') ?? '';
const [tPart, v1Part] = header.split(',');
const t = tPart?.startsWith('t=') ? tPart.slice(2) : '';
const v1 = v1Part?.startsWith('v1=') ? v1Part.slice(3) : '';
if (!t || !v1) return res.status(401).send('bad signature header');
// Replay guard: reject stale timestamps.
const nowSec = Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - Number(t)) > REPLAY_WINDOW_SECONDS) {
return res.status(401).send('signature too old');
}
const expected = createHmac('sha256', process.env.SEATS_WEBHOOK_SECRET!)
.update(`${t}.${req.body.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Dedupe on event.id (same id on every retry), then handle event.type.
res.status(200).send('ok');
});The standalone Verify a webhook recipe walks the same snippet line by line, including the raw-body caveat and replay window in more depth.
Where to go next
- Webhooks API — endpoint registration, secret rotation, delivery listing, and manual replay.
- Hold and book lifecycle — the seat state changes that trigger these events.
- Server-side integration — where your secret key lives and how the backend flow fits together.
General admission
The full GA quantity lifecycle — hold, extend, book, release — with contract-verified request and response JSON, remaining-capacity semantics, and the SDK selection flow.
Server-side integration
The secret-key backend flow — where sk_live may and may not appear, the public key as the browser render credential, and the two checkout flows (SDK checkout vs headless).