Verify a webhook
Verify the Seats-Signature HMAC on every webhook delivery — Stripe-style format, timing-safe comparison, replay protection.
Verify a webhook
Every webhook delivery from Seats carries a Seats-Signature header.
Verify it on every request before processing the payload — without
verification, anyone who knows your endpoint URL can forge an event.
The flow below uses Node's crypto module and is framework-agnostic;
the Express example is illustrative.
The Seats-Signature header
The header value follows the Stripe-style format:
Seats-Signature: t=1726156800,v1=4f9c1d2a...t=<unix_seconds>— the timestamp when the platform signed the payload, in seconds since epoch.v1=<hex>— the HMAC-SHA256 of`${t}.${rawBody}`computed with your endpoint secret, encoded as a lowercase hex string.
You MUST verify the signature over the raw request body — JSON
re-serialisation will change the bytes (key ordering, whitespace) and
break the HMAC. Most frameworks need a small adjustment to keep the
raw body available; the Express example below uses
express.raw({ type: 'application/json' }).
The verification code
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
const app = express();
// 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('Seats-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');
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');
}
// Optional: reject if |now - t| > 300s to defeat replay attacks.
const event = JSON.parse(req.body.toString('utf8'));
// ...handle event...
res.status(200).send('ok');
});Three lines deserve a closer look:
createHmac('sha256', SEATS_WEBHOOK_SECRET)— your endpoint secret is a 32+ byte value emitted byPOST /api/v1/webhooksor by therotate-secretendpoint. Store it in your secret manager; never commit it to source control.`${t}.${req.body.toString('utf8')}`— the signed payload is the timestamp, a literal., and the raw body. Order matters.timingSafeEqual(a, b)— the comparison must be constant-time. A naiveexpected === v1leaks signature bytes via early-exit timing.
Replay protection
A valid signature only proves authenticity, not freshness. An attacker who captures a single delivery (e.g., off a misconfigured proxy) can replay it indefinitely. Defend by rejecting deliveries whose timestamp is too far from now:
const REPLAY_WINDOW_SECONDS = 300; // 5 minutes
const nowSec = Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - Number(t)) > REPLAY_WINDOW_SECONDS) {
return res.status(401).send('signature too old');
}Five minutes is the platform default — wide enough to absorb clock skew between your servers and the Seats workers, tight enough to make captured deliveries useless within minutes. Tighten or widen the window to match your own tolerance.
See the Webhooks API reference for endpoint registration, secret rotation, delivery listing, and manual replay.
Hold and book seats at checkout
The deferred-countdown e-commerce flow — instant cross-user lock on select, a short browse TTL, extend to a payment window at checkout, an integrator-owned countdown, and graceful handling of expired or contested holds.
Versioning
How the REST API and the @seats/sdk package are versioned, and how to pin against CDN cache lag.