Webhooks

Signatures

Verify that every webhook comes from TuCapi with HMAC-SHA256, in Node.js, Python, PHP and Go.

How to verify

  1. 1Take t and v1 from the header.
  2. 2Build the signed text: t, a dot and the raw body. That is, <t>.<body>.
  3. 3Compute HMAC-SHA256 using your full secret as the key, including the whsec_ prefix.
  4. 4Compare it with v1 in constant time.
  5. 5Reject the notice if t is more than 300 seconds away from your current time.
  6. 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 same id.
  • 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.