A webhook HMAC proves that someone holding a shared secret signed a particular byte sequence. It does not encrypt the payload, and it does not automatically prevent replay. Verify it before business logic, then add timestamp and event-id checks if the provider supports them.
The order matters
raw request body → HMAC(secret, raw bytes) → constant-time compare → parse JSON → process event
Parsing and reserialising JSON before verification is a common bug. Whitespace, key ordering, and Unicode representation can change even when the object looks identical.
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const actual = Buffer.from(signature, 'hex');
const expectedBytes = Buffer.from(expected, 'hex');
if (actual.length !== expectedBytes.length || !crypto.timingSafeEqual(expectedBytes, actual)) {
throw new Error('Invalid webhook signature');
}
| Requirement | Why |
|---|---|
| Raw bytes | Matches exactly what the sender signed |
| Constant-time comparison | Avoids leaking partial-match timing |
| Keyed secret | Stops anyone who only knows the algorithm |
| Event id / timestamp | Limits duplicate or delayed delivery |
A valid signature says the sender is authentic; it does not say this is the first time you have received the event. Make handlers idempotent.
Return a 2xx only after durable acceptance, such as enqueueing the event or recording its id. Providers retry on timeouts and many retry on 5xx, so a slow handler should hand off work rather than make the signature endpoint do the entire job.
