Webhook signature verification
Every webhook WhatIsUp.dev POSTs carries an X-WhatIsUp-Signature header in this format:
X-WhatIsUp-Signature: t=1700000000,v1=<hex_hmac>
The signature is computed as:
HMAC_SHA256(<endpoint_signing_secret>, "<unix_timestamp>.<raw_request_body>")
…where <raw_request_body> is the exact bytes sent on the wire. Do not re-serialize the JSON before verifying — pretty-printing, key-order canonicalization, anything like that breaks the signature.
Verification recipe
Three steps:
- Parse the header into
{t, v1}. - Recompute the HMAC over
<t>.<raw_body>using the endpoint's signing secret. - Compare
v1to your computed HMAC in constant time. Reject if they don't match, or iftis more than 5 minutes off from your wall clock (replay defense).
Code
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(
rawBody: Buffer,
header: string,
secret: string,
toleranceSec = 300,
): boolean {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=', 2)),
) as { t?: string; v1?: string };
if (!parts.t || !parts.v1) return false;
const ts = Number(parts.t);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > toleranceSec) return false;
const expected = createHmac('sha256', secret)
.update(`${ts}.${rawBody.toString('utf8')}`)
.digest();
const provided = Buffer.from(parts.v1, 'hex');
if (provided.length !== expected.length) return false;
return timingSafeEqual(expected, provided);
}
Common pitfalls
- Express's
body-parsermutates the request body. Verify against the raw body, not the parsed JSON. Passverify: (req, res, buf) => { req.rawBody = buf }to the JSON parser, then verify againstreq.rawBody. - Comparing strings with
==or===is a timing oracle. Usecrypto.timingSafeEqual(Node),hmac.compare_digest(Python),hmac.Equal(Go). - Wide tolerance. Don't bump the 5-minute tolerance to mask a clock-drift problem. Run NTP.
- Logging the secret. Don't. We HMAC it on our side; you should store it like a password — env-var only, never in your source tree.
Replay defense
The 5-minute timestamp tolerance protects against an attacker replaying a captured request. For belt-and-suspenders: store event_id in a dedupe table on your side. Idempotency makes replays no-ops even if the signature window is wide open.