Webhook signature verification

Canonical contract for verifying Trillboards-emitted webhook deliveries. Single source of truth — partner-api.yaml, dsp-api.yaml, the @trillboards/api-client SDK, and the /api/discover.globals.webhook_contract runtime manifest all derive from this document.

TL;DR

Every outbound webhook from api.trillboards.com carries:


X-Trillboards-Event:        <event_name>             # e.g. "impression.recorded"
X-Trillboards-Timestamp:    <unix_seconds>           # mirrored from `t=` for redundancy
X-Trillboards-Signature:    t=<unix_seconds>,v0=<hex>,v1=<hex>
X-Trillboards-Webhook-Id:   <subscription_id>        # Partner surface only
X-Trillboards-Delivery-Id:  del_<uuid>               # DSP surface (Partner adds in PR-D-ish)
User-Agent:                 Trillboards-Webhook/1.0  # Partner
                            Trillboards-DSP-API/1.0  # DSP

Where:

A verifier MUST:

  1. Parse the compound X-Trillboards-Signature header.
  2. Extract t=<ts>; reject if |now - ts| > tolerance (default 300 seconds, Stripe-aligned).
  3. Compute expected_v1 = HMAC-SHA256(secret, "${t}.${payload}") and compare against v1= from the header using a constant-time string comparison.
  4. If v1= is absent (server in pre-migration state), fall back to verifying v0= against HMAC-SHA256(secret, payload).
  5. If the header is the legacy sha256=<hex> form, try both v1 and v0 candidate inputs.
  6. Reject with HTTP 401 on any mismatch.

import { Webhooks } from '@trillboards/api-client';

app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = Webhooks.constructEvent(
      req.body,                                  // raw Buffer or string
      req.headers,                               // IncomingHttpHeaders
      process.env.WEBHOOK_SECRET!,
    );
    console.log('Received', event.event);
    res.sendStatus(200);
  } catch (err) {
    // TrillboardsError { code: 'webhook_signature_invalid', statusCode: 401 }
    res.sendStatus(401);
  }
});

Override the replay window (default 300s):


Webhooks.constructEvent(req.body, req.headers, secret, { tolerance: 600 });

Manual verification (no SDK)


const crypto = require('crypto');

function verifyTrillboardsWebhook(rawBody, headers, secret, toleranceSec = 300) {
  const sigHeader = headers['x-trillboards-signature'];
  if (!sigHeader) throw new Error('missing signature header');

  // Parse `t=<ts>,v0=<hex>,v1=<hex>`
  const parts = Object.fromEntries(
    sigHeader.split(',').map((p) => {
      const i = p.indexOf('=');
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
    })
  );
  const ts = Number(parts.t);
  if (!Number.isFinite(ts)) throw new Error('missing t=<unix_seconds>');

  // Replay protection
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ts) > toleranceSec) throw new Error('timestamp outside tolerance');

  // Prefer v1; fall back to v0
  if (parts.v1) {
    const expected = crypto.createHmac('sha256', secret)
      .update(`${ts}.${rawBody}`)
      .digest('hex');
    if (parts.v1.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))) {
      return JSON.parse(rawBody);
    }
  }
  if (parts.v0) {
    const expected = crypto.createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex');
    if (parts.v0.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(parts.v0), Buffer.from(expected))) {
      return JSON.parse(rawBody);
    }
  }
  throw new Error('signature mismatch');
}

Replay window

Default tolerance: 300 seconds (Stripe-aligned). Configurable per partner via network_config.webhook_replay_tolerance_seconds on the partner record. Outside-window deliveries are rejected with HTTP 401 + a structured error envelope (code: webhook_signature_invalid).

Migration timeline

PhaseStatusServer emitsSDK reads
Pre-0.3.0 (broken)donesha256=<v1_hex> + separate X-Trillboards-Timestamponly payload — FAILS verification
0.3.0 dual-emit (NOW)activet=<ts>,v0=<hex>,v1=<hex>parses compound + replay window
Post-T+180dfuturet=<ts>,v1=<hex> only (drop v0=)unchanged

The v0= value is emitted only during the migration window so partners who built their own body-only verifier (matching the original dsp-api.yaml example) pass verification for the first time. After T+180 days, once @trillboards/api-client@0.3.0 adoption is confirmed via npm download stats, v0= is dropped in a separate PR. Nothing schedules that drop: the npm download share is the trigger and a human has to read it, so this paragraph is the only thing that remembers. The drop itself is one place — services/webhooks/canonicalSigner.js, which builds the t=,v0=,v1= header for every outbound surface listed below.

Industry alignment

The compound header shape is byte-for-byte compatible with Stripe's Stripe-Signature parser. A partner who already integrated Stripe webhooks can copy their parsing helper unchanged; only the secret and signed input change.

Outbound surfaces using this contract

Both call services/webhooks/canonicalSigner.js#buildSignatureHeader so the format cannot drift between them.

NOT covered by this document (different verifier per sender)

These are scoped to their respective senders/consumers and are not part of the Trillboards public-partner webhook contract.

Test plan

A cross-package golden-vector test at trillboards-api-client/tests/interop.test.ts imports services/webhooks/canonicalSigner.js#buildSignatureHeader directly and asserts that:

  1. The compound header parses + verifies through Webhooks.constructEvent.
  2. Modified payloads are rejected.
  3. Expired timestamps are rejected (replay protection).
  4. v0-only headers verify when v1 is absent (back-compat path).
  5. The header shape is byte-stable across regenerations (locked vector).

If buildSignatureHeader's output changes without a coordinated SDK update, this test fails loudly in CI before any partner is affected.