Webhooks
Signatures
Verify that every webhook comes from TuCapi with HMAC-SHA256, in Node.js, Python, PHP and Go.
The header
Every notice has the TuCapi-Signature header:
HTTP
TuCapi-Signature: t=1726000000,v1=5f1a…9ct: when it was signed, in Unix seconds.v1: the HMAC-SHA256 signature, in lowercase hex.
const crypto = require("crypto");function verifyTuCapi(rawBody, header, secret, toleranceSec = 300) { const parts = header.split(",").map((p) => p.trim()); const t = parts.find((p) => p.startsWith("t="))?.slice(2); const v1 = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3)); if (!t || v1.length === 0) throw new Error("invalid header"); if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) { throw new Error("outside tolerance window"); } const expected = crypto .createHmac("sha256", secret) .update(`${t}.`) .update(rawBody) .digest("hex"); const ok = v1.some( (sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex")), ); if (!ok) throw new Error("invalid signature");}How to verify
- 1Take
tandv1from the header. - 2Build the signed text:
t, a dot and the raw body. That is,<t>.<body>. - 3Compute
HMAC-SHA256using your full secret as the key, including thewhsec_prefix. - 4Compare it with
v1in constant time. - 5Reject the notice if
tis more than 300 seconds away from your current time. - 6Dedupe by the body's
id.
const crypto = require("crypto");function verifyTuCapi(rawBody, header, secret, toleranceSec = 300) { const parts = header.split(",").map((p) => p.trim()); const t = parts.find((p) => p.startsWith("t="))?.slice(2); const v1 = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3)); if (!t || v1.length === 0) throw new Error("invalid header"); if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) { throw new Error("outside tolerance window"); } const expected = crypto .createHmac("sha256", secret) .update(`${t}.`) .update(rawBody) .digest("hex"); const ok = v1.some( (sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex")), ); if (!ok) throw new Error("invalid signature");}Keep in mind
Use the raw body
The body bytes are exactly the ones that were signed. In Express use
express.raw(); in PHP, file_get_contents("php://input").- Each attempt is signed with a new
t. A retry has a different signature, but the sameid. - That's why the signature is always within the 5-minute window when it's sent.
- When you rotate the secret, the old one stops working immediately. Update your verifier before the next delivery.