# 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-protected
- `v0 = HMAC-SHA256(secret, payload)` — legacy body-only digest emitted during the
  migration window for partners on pre-`@trillboards/api-client@0.3.0` verifiers
  or who built their own body-only verifier from earlier dsp-api.yaml examples

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.

## Recommended: `@trillboards/api-client@>=0.3.0`

```ts
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.type);
    res.sendStatus(200);
  } catch (err) {
    // TrillboardsError { code: 'webhook_signature_invalid', statusCode: 401 }
    res.sendStatus(401);
  }
});
```

Override the replay window (default 300s):

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

## Manual verification (no SDK)

```js
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 via a separate PR tracked in `deferred-work.yaml`.

## 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 via `POST /openrtb/v2/webhooks`. Currently
  delivers `impression.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-256`
- `middleware/vapiAuth.js` — Vapi inbound, `vapi-signature`
- `services/partner/impressionSecurity.js` — Trillboards impression-pixel
  inbound verifier, custom `timestamp.adid.impid.did.sid.aid` format
- `modules/mdm/services/alertService.js` — internal MDM dashboard, `X-MDM-Signature`
- `controller/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:

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.
