Verifying webhook signatures
The three headers, a verification function, and the mistake everyone makes once.
In short
- Three headers travel with every delivery: an id, a timestamp and a signature.
- The HMAC covers id, timestamp and the raw body — verify before parsing.
- The timestamp window is what makes a captured request useless later.
- During rotation two signatures are sent, so the switch costs no deliveries.
The headers
webhook-id: msg_01M03VKMCJ5H7C1E4K1EEA987B
webhook-timestamp: 1786836013
webhook-signature: v1,atTVDyPi26ackTT4nZbdk…
HookGet signs with Standard Webhooks, the open specification, so a consumer written for any compliant sender needs no changes.
The verification function
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, headers, rawBody) {
const id = headers['webhook-id'];
const ts = Number(headers['webhook-timestamp']);
// Outside the window this is a replay, not a delivery.
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key)
.update(`${id}.${ts}.${rawBody}`) // the RAW bytes, never a re-serialised object
.digest('base64');
return String(headers['webhook-signature'] ?? '')
.split(' ')
.some((part) => {
const [version, value] = part.split(',');
const a = Buffer.from(value ?? '', 'utf8');
const b = Buffer.from(expected, 'utf8');
return version === 'v1' && a.length === b.length && timingSafeEqual(a, b);
});
}
The mistake everyone makes once. Parsing the JSON and re-serialising it before verifying changes whitespace and key order, and the HMAC fails. Frameworks that helpfully parse the body for you are the usual cause — capture the raw bytes first.
Why each part is there
| Part | What it prevents |
|---|---|
| The shared secret | Anyone posting JSON to your public endpoint and being believed |
| The raw body in the HMAC | Modification in transit or by an intermediary |
| The timestamp in the HMAC | Replay of a captured request, hours or days later |
| Constant-time comparison | Learning the correct signature byte by byte from timing |
| The stable id | Double-processing when the same delivery is retried |
Rotating a secret without dropping deliveries
Rotation returns a new secret and keeps the old one valid for an overlap window. During the overlap both signatures are sent in the same header, so a consumer can switch whenever it is ready.
curl -X POST https://api.hookget.com/v1/endpoints/ep_…/rotate-secret \
-H "authorization: Bearer $HOOKGET_KEY" -d '{"expiry_hours":24}'