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, 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. |
webhook.test | A test delivery you trigger from the dashboard or API — same envelope, no real seat change. |
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 | WebhookEventDataObject | The seat that changed (see below). |
The 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"
}
}
}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).