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:
v1 = HMAC-SHA256(secret, "${timestamp}.${payload}")— canonical, replay-protectedv0 = HMAC-SHA256(secret, payload)— legacy body-only digest emitted during the migration window for partners on pre-@trillboards/api-client@0.3.0verifiers or who built their own body-only verifier from earlier dsp-api.yaml examples
A verifier MUST:
- Parse the compound
X-Trillboards-Signatureheader. - Extract
t=<ts>; reject if|now - ts| > tolerance(default 300 seconds, Stripe-aligned). - Compute
expected_v1 = HMAC-SHA256(secret, "${t}.${payload}")and compare againstv1=from the header using a constant-time string comparison. - If
v1=is absent (server in pre-migration state), fall back to verifyingv0=againstHMAC-SHA256(secret, payload). - If the header is the legacy
sha256=<hex>form, try bothv1andv0candidate inputs. - Reject with HTTP 401 on any mismatch.
Recommended: @trillboards/api-client@>=0.3.0
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
| Phase | Status | Server emits | SDK reads |
|---|---|---|---|
| Pre-0.3.0 (broken) | done | sha256=<v1_hex> + separate X-Trillboards-Timestamp | only payload — FAILS verification |
| 0.3.0 dual-emit (NOW) | active | t=<ts>,v0=<hex>,v1=<hex> | parses compound + replay window |
| Post-T+180d | future | t=<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
- Partner API webhooks (
services/partner/webhookQueue.js) — emitted on events declared in partner-api.yaml (device.online,device.offline,impression.recorded,campaign.allocated,payout.processed,programmatic.*). - DSP API webhooks (
services/programmatic/dspWebhookDispatcher.js) — emitted to seats subscribed viaPOST /openrtb/v2/webhooks. Currently deliversimpression.delivered; events declared in dsp-api.yaml.
Both call services/webhooks/canonicalSigner.js#buildSignatureHeader so the format cannot drift between them.
NOT covered by this document (different verifier per sender)
routes/webhooks/github.js— GitHub inbound,X-Hub-Signature-256middleware/vapiAuth.js— Vapi inbound,vapi-signatureservices/partner/impressionSecurity.js— Trillboards impression-pixel inbound verifier, customtimestamp.adid.impid.did.sid.aidformatmodules/mdm/services/alertService.js— internal MDM dashboard,X-MDM-Signaturecontroller/openrtb/feinWebhookController.js— FEIN signal partners,X-FEIN-Signature: sha256=<hex>(body-only)
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:
- The compound header parses + verifies through
Webhooks.constructEvent. - Modified payloads are rejected.
- Expired timestamps are rejected (replay protection).
- v0-only headers verify when v1 is absent (back-compat path).
- 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.