Verifying webhooks

Check every webhook's signature before you act on it, so nobody can fake or replay an event.

Embed
Preview of the card
<a href="https://forumulate.com/p/trackified/docs/verifying-webhooks"><img src="https://forumulate.com/api/public/orgs/trackified/spaces/docs/pages/480f39bb-31fc-4eb2-a3eb-e2b145bdd2a9/social-card.svg?v=3e7ae33eec8b" alt="Verifying webhooks" width="600" height="315"></a>

Each request has an x-tracking-signature header with a Unix timestamp and an HMAC-SHA256 digest:

x-tracking-signature: t=1792560000,v1=<hex digest>

digest = HMAC_SHA256(webhook_secret, "<t>.<raw request body>")

Your webhook secret is shown once, when you create the endpoint.

verify.js
import { createHmac, timingSafeEqual } from "node:crypto";

// header: the x-tracking-signature value, e.g. "t=1792560000,v1=5f2c…"
// rawBody: the request body exactly as received, before JSON.parse
export function verifyTrackifiedSignature(header, rawBody, secret) {
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
  const timestamp = Number(parts.t);
  if (!timestamp || !parts.v1) return false;

  // Reject events older than five minutes to stop replays.
  if (Math.abs(Date.now() - timestamp * 1000) > 5 * 60 * 1000) return false;

  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
  const actual = Buffer.from(parts.v1, "hex");
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

Use the raw body

Compute the digest over the body exactly as it arrived. Parsing and re-serialising the JSON changes the bytes, and the signature stops matching.

Updated