The verification algorithm
Split the header on commas to extract t (timestamp) and v1 (signature). Compute HMAC-SHA256 over the literal string `${t}.${rawBody}` using your per-merchant secret. Compare in constant time. Reject if the timestamp is older than 5 minutes.
That's it. Don't use === to compare — that leaks bytes via timing. Don't parse the body before verifying — once it's JSON it's tampered with.
Node.js
Use crypto.timingSafeEqual on Buffer values of the same length:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, rawBody: string, header: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = parts.t, v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}The one mistake
Frameworks like Express auto-parse JSON. If you read req.body after that, you're reading a re-stringified object — not the bytes we signed. Use express.raw({ type: 'application/json' }) on the webhook route only, or grab the raw stream before any middleware runs. Same trick applies to Hono, Fastify, FastAPI — anywhere middleware sits between the socket and your handler.