Skip to content

Verifying signatures

Every webhook carries an X-Delivr-Signature header:

X-Delivr-Signature: t=1762175400,v1=5f2b9c…
  • t is the attempt’s Unix time in seconds.
  • v1 is the hex HMAC-SHA256 of "<t>.<raw body>", keyed with your signing secret (whsec_…).

To verify:

  1. Split the header on , and read t and every v1.
  2. Reject the event if t is more than 5 minutes from now. This stops replays of captured requests.
  3. Compute the HMAC over the raw body bytes, exactly as received. Re-serialized JSON will not match.
  4. Accept if any v1 matches, compared in constant time.

During a secret rotation, Delivr adds a second v1 signed with the old secret for 24 hours, so accepting any match lets you rotate without dropping events.

import crypto from "node:crypto";
export function verify(secret, header, rawBody, nowSec = Date.now() / 1000) {
const parts = (header ?? "").split(",").map((p) => p.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
if (!t || Math.abs(nowSec - Number(t)) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return parts
.filter(([k]) => k === "v1")
.some(([, v]) => {
const got = Buffer.from(v, "hex");
return got.length === want.length && crypto.timingSafeEqual(got, want);
});
}
  • Parsing before verifying. Frameworks that parse JSON first lose the raw bytes. Keep the raw body for the route (for example express.raw).
  • Comparing with ==. Use a constant-time comparison.
  • Only checking the first v1. During a rotation the matching signature may be the second.
  • Trusting the timestamp in the body. Use t from the header.