SeatBuilderSeatBuilder Docs

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 typeFires when
seat.heldA seat is held (a holdToken reserves it).
seat.bookedA held seat is promoted to a permanent booking.
seat.releasedA held or booked seat is released back to free.
seat.hold_expiredA hold reaches holdExpiresAt and auto-expires to free.
webhook.testA 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:

FieldTypeNotes
idstringevt_<nanoid> — the canonical idempotency key. The same id is sent on every retry, so dedupe on it.
typestringOne of the event types above (or webhook.test).
creatednumberUnix seconds at envelope creation time.
livemodebooleantrue for production endpoints, false for sandbox.
data.objectWebhookEventDataObjectThe seat that changed (see below).

The data.object carries the seat:

FieldTypeNotes
eventKeystringThe event the seat belongs to.
objectLabelstringThe seat label, e.g. A-12.
objectTypestringseat, table, gaArea, …
status'free' | 'held' | 'booked' | 'not_for_sale'The seat's status after the change.
categoryKeystring | nullThe pricing category, or null.
extraDataobject | nullWhatever opaque bag you attached, or null.
holdExpiresAtstring (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:

  1. Compute HMAC-SHA256(secret, `${t}.${rawBody}`) and hex-encode it.
  2. Verify over the raw request body — re-serialising the JSON changes the bytes (key order, whitespace) and breaks the HMAC.
  3. Compare with a constant-time equality check (timingSafeEqual), short-circuiting first if the lengths differ.
  4. 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 — SeatBuilder Docs