prove a delivery came from init.Tasks
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:
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
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.
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.