Signature Verification
Your endpoint signing secret never leaves your server, never leaves ours. Every delivery is signed with HMAC-SHA256 and a unix timestamp. POST signs the raw body; GET signs the canonical query string.
One header, two fields
Every delivery carries a single X-Webhook-Signature header. It’s a comma-separated pair: a unix timestamp and the hex HMAC-SHA256 of `${timestamp}.${signedPayload}`.
X-Webhook-Signature: t=1713745909,v1=2b8f5cdb7e0ea7c3f2a4b6e1d7c9f83a2a4b6e1d7c9f83ab1c9d6e4f2a0b8c7d|now − t| > 300 seconds to defeat replays.HMAC-SHA256(signing_secret, `$${t}.$${signedPayload}`). For POST, signedPayload is the raw body. For GET, it is the canonical query string.The contract
These rules are language-agnostic. Every reference implementation below is the same check expressed in that language’s idioms.
Pick your stack
Switch languages with the tabs. The algorithm is identical: parse, age-check, HMAC-SHA256, constant-time compare.
// Node.js / TypeScript — works in Next.js, Express, Fastify, Bun, Hono.
import crypto from "node:crypto";
export function verifyTradingLayerWebhook({
rawBody,
signatureHeader,
secret,
toleranceSeconds = 300,
}: {
rawBody: string;
signatureHeader: string | null;
secret: string;
toleranceSeconds?: number;
}) {
if (!signatureHeader) {
throw new Error("Missing X-Webhook-Signature header");
}
const parts = Object.fromEntries(
signatureHeader.split(",").map((part) => {
const [key, value] = part.split("=");
return [key, value];
}),
);
const timestamp = Number(parts.t);
const receivedSignature = parts.v1;
if (!timestamp || !receivedSignature) {
throw new Error("Invalid signature header");
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > toleranceSeconds) {
throw new Error("Webhook timestamp is too old");
}
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const expectedBuffer = Buffer.from(expectedSignature, "hex");
const receivedBuffer = Buffer.from(receivedSignature, "hex");
if (
expectedBuffer.length !== receivedBuffer.length ||
!crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
) {
throw new Error("Invalid signature");
}
}Things that bite teams
Nearly every 'signature doesn't match' support ticket lands on one of these five. Rule them out in this order.
Parsed-body leak
You called req.json() (or bodyParser.json()) before capturing the raw body. Fix: capture the raw body first, then verify, then parse.
Re-serialized JSON
You pretty-printed the payload for logging, then re-signed the pretty version. Always verify against the bytes we delivered, not a reformatted copy.
Wrong secret
You copied the dashboard secret for a different endpoint, or pasted with a trailing newline. Secrets are per-endpoint — rotate if you ever suspect drift.
Clock skew
Your host drifted more than 5 minutes from UTC. Enable NTP; relax the tolerance only if you understand the replay trade-off.
Non-constant-time compare
You used expected === received. In a well-behaved receiver this doesn't fail verification — but it leaks timing. Always use the language's timingSafeEqual / hash_equals / hmac.Equal equivalent.