# Verify a signature

Anyone can POST to your endpoint. The signature is how you tell a real delivery from a forged one, so verify before you act on anything.

Every delivery carries three headers:

```http
X-InitTasks-Event-Id: evt_aCyx2ukBPQnIfeQ7
X-InitTasks-Event: todo.updated
X-InitTasks-Signature: t=1789459331,v1=8f3c...,v1=1b90...
```

The signed string is `"<t>.<raw body>"`, HMAC-SHA256, hex, keyed with the secret you got when you subscribed.

Verify against the **raw bytes**. Parsing the JSON and re-serialising it changes them, and the signature then never matches.

## A verifier

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verify(rawBody, header, secret) {
  const t = Number(/t=(\d+)/.exec(header)?.[1]);
  if (!Number.isFinite(t)) return false;

  // Reject an old timestamp. Without this a captured delivery replays
  // forever, because the body never changes and neither does its signature.
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const expectedBuf = Buffer.from(expected, 'utf8');

  // There may be TWO v1 values during a secret rotation. Accept either.
  return [...header.matchAll(/v1=([0-9a-f]+)/g)].some((m) => {
    const got = Buffer.from(m[1], 'utf8');
    return got.length === expectedBuf.length && timingSafeEqual(got, expectedBuf);
  });
}
```

Three details in that verifier matter. It checks the timestamp, so a captured delivery cannot be replayed a week later. It compares in constant time, so the comparison does not leak the expected value one byte at a time. It loops over every `v1=`, which matters during a rotation.

## Wiring it up

Take the raw body before any JSON parser touches it.

```js
app.post('/hooks/inittasks',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    if (!verify(req.body, req.get('X-InitTasks-Signature') ?? '', SECRET)) {
      return res.sendStatus(404);
    }
    res.sendStatus(200);           // ACK fast, work afterwards
    queue.push(JSON.parse(req.body.toString('utf8')));
  });
```

Answer 404 on a bad signature rather than 401. An endpoint that distinguishes "wrong signature" from "no such endpoint" tells a prober it found something.

Acknowledge before you do the work. The send times out after 10 seconds, and a slow handler turns into a retry storm.

## Rotating a secret

`POST /v1/webhooks/{id}/rotate-secret` returns a new secret and keeps the old one valid for 24 hours.

During the overlap, deliveries are signed with both and the header carries two `v1=` values. That is what lets you deploy the new secret without dropping the events that land mid-deploy, and it is why the verifier loops instead of reading the first value.

## Next

- [Delivery and reconciliation](/webhooks/delivery)
- [Reference for /webhooks](/reference/webhooks)
