How webhook signing works
Every webhook delivery includes an X-Conduit-Signature header in the format:
t=<unix-timestamp>,v1=<hex-hmac>[,v1=<hex-hmac>]
Where each v1 is HMAC-SHA256(<unix-timestamp>.<raw-body>, secret), computed over the raw request bytes before any JSON parsing. The signing secret includes the whsec_ prefix; pass it verbatim, never strip it.
A delivery normally carries one v1. While you are rotating the endpoint’s signing secret, it carries two v1 values for a grace period — one signed with your new secret and one with the previous one — so deliveries keep verifying while you roll your secret over.
To verify:
- Parse
t and every v1 from the X-Conduit-Signature header.
- Re-compute HMAC-SHA256 of
<t>.<raw-request-body> using your endpoint secret (full whsec_... string as the key).
- Constant-time compare the result against each
v1; accept if it matches any of them.
- Reject signatures where
t is older than 300 seconds to protect against replay.
See the webhooks reference for the full signing-and-verification protocol.
Do not paste production endpoint secrets into any hosted page. Use a sandbox
endpoint secret or a dummy value when testing verification locally.
Verify locally
Use either snippet below. Replace the three constants with your actual values.
const crypto = require('crypto');
const SECRET = 'whsec_...'; // full string, do not strip the prefix
const SIGNATURE_HEADER = 't=1736000000,v1=...';
const RAW_BODY = '{"type":"transaction.completed",...}';
const pairs = SIGNATURE_HEADER.split(',').map(p => p.split('='));
const t = pairs.find(([k]) => k === 't')?.[1];
// A delivery carries more than one v1 while you rotate the signing secret;
// accept if your secret matches any of them.
const signatures = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);
// Reject replays older than 300 seconds.
const tNum = parseInt(t, 10);
if (Math.abs(Math.floor(Date.now() / 1000) - tNum) > 300) {
console.log('invalid — replay window exceeded');
process.exit(1);
}
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${RAW_BODY}`).digest('hex');
// Constant-time comparison to avoid timing side-channels.
const expectedBuf = Buffer.from(expected, 'hex');
const valid = signatures.some(v1 => {
const v1Buf = Buffer.from(v1, 'hex');
return expectedBuf.length === v1Buf.length && crypto.timingSafeEqual(expectedBuf, v1Buf);
});
console.log(valid ? 'valid' : 'invalid — expected: ' + expected);
import hmac
import hashlib
import time
SECRET = "whsec_..." # full string, do not strip the prefix
SIGNATURE_HEADER = "t=1736000000,v1=..."
RAW_BODY = '{"type":"transaction.completed",...}'
pairs = [p.split("=", 1) for p in SIGNATURE_HEADER.split(",")]
t = next((v for k, v in pairs if k == "t"), None)
# A delivery carries more than one v1 while you rotate the signing secret;
# accept if your secret matches any of them.
signatures = [v for k, v in pairs if k == "v1"]
# Reject replays older than 300 seconds.
if abs(int(time.time()) - int(t)) > 300:
print("invalid — replay window exceeded")
raise SystemExit(1)
expected = hmac.new(SECRET.encode(), f"{t}.{RAW_BODY}".encode(), hashlib.sha256).hexdigest()
valid = any(hmac.compare_digest(expected, v1) for v1 in signatures)
print("valid" if valid else f"invalid — expected: {expected}")
Both snippets implement constant-time comparison (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python) and enforce the 300-second replay window.
See also