SeatBuilderSeatBuilder Docs
Recipes

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 by POST /api/v1/webhooks or by the rotate-secret endpoint. 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 naive expected === v1 leaks 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.