Verifying signatures
Every webhook carries an X-Delivr-Signature header:
X-Delivr-Signature: t=1762175400,v1=5f2b9c…tis the attempt’s Unix time in seconds.v1is the hex HMAC-SHA256 of"<t>.<raw body>", keyed with your signing secret (whsec_…).
To verify:
- Split the header on
,and readtand everyv1. - Reject the event if
tis more than 5 minutes from now. This stops replays of captured requests. - Compute the HMAC over the raw body bytes, exactly as received. Re-serialized JSON will not match.
- Accept if any
v1matches, 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); });}func Verify(secret, header string, body []byte, now time.Time) bool { var t string var sigs []string for _, part := range strings.Split(header, ",") { k, v, _ := strings.Cut(part, "=") if k == "t" { t = v } else if k == "v1" { sigs = append(sigs, v) } } sec, err := strconv.ParseInt(t, 10, 64) if err != nil || now.Sub(time.Unix(sec, 0)).Abs() > 5*time.Minute { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(body) want := []byte(hex.EncodeToString(mac.Sum(nil))) for _, sig := range sigs { if hmac.Equal([]byte(sig), want) { return true } } return false}import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, now: float | None = None) -> bool: parts = [p.split("=", 1) for p in (header or "").split(",") if "=" in p] t = next((v for k, v in parts if k == "t"), None) now = time.time() if now is None else now if t is None or not t.isdigit() or abs(now - int(t)) > 300: return False want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(v, want) for k, v in parts if k == "v1")function delivr_verify(string $secret, string $header, string $rawBody, ?int $now = null): bool { $now ??= time(); $t = null; $sigs = []; foreach (explode(',', $header) as $part) { [$k, $v] = array_pad(explode('=', $part, 2), 2, ''); if ($k === 't') { $t = $v; } elseif ($k === 'v1') { $sigs[] = $v; } } if ($t === null || !ctype_digit($t) || abs($now - (int) $t) > 300) { return false; } $want = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($sigs as $sig) { if (hash_equals($want, $sig)) { return true; } } return false;}Common mistakes
Section titled “Common mistakes”- 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
tfrom the header.