Skip to content

Verifying webhook signatures

Patomic signs every webhook with HMAC-SHA256 and a server-side secret you configure when you create a webhook subscription. Never trust a webhook payload without verifying the signature first.

Every Patomic webhook arrives with:

Patomic-Signature: t=1700000000,v1=5257a869e7ecebeda32a…
  • t — Unix timestamp (seconds), the moment Patomic signed the payload
  • v1=… — hex HMAC-SHA256 of ${t}.${raw_body} using your webhook secret
  • Multiple v1=… values may appear during secret rotation — match against any of them
// Drop-in verifier — uses Web Crypto, runs in Workers, Node ≥ 20, Bun, Deno, browsers.
const TOLERANCE_SECONDS = 300;
export async function verifyPatomicWebhook(
rawBody: string,
signatureHeader: string | null,
secret: string,
): Promise<boolean> {
if (!signatureHeader) return false;
const parts = signatureHeader.split(",").map((p) => p.trim());
let timestamp: number | null = null;
const signatures: string[] = [];
for (const part of parts) {
const eq = part.indexOf("=");
if (eq <= 0) return false;
const [k, v] = [part.slice(0, eq), part.slice(eq + 1)];
if (k === "t") {
const n = Number.parseInt(v, 10);
if (!Number.isFinite(n)) return false;
timestamp = n;
} else if (k === "v1") {
signatures.push(v);
}
}
if (timestamp === null || signatures.length === 0) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = new Uint8Array(
await crypto.subtle.sign("HMAC", key, enc.encode(`${timestamp}.${rawBody}`)),
);
const expected = Array.from(sig, (b) => b.toString(16).padStart(2, "0")).join("");
// Constant-time compare
return signatures.some((candidate) => {
if (candidate.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < candidate.length; i++) {
diff |= candidate.charCodeAt(i) ^ expected.charCodeAt(i);
}
return diff === 0;
});
}
  • Timestamp tolerance check first. Without it, a captured signed payload could be replayed indefinitely. 5 minutes is the same default Stripe uses; tighten if your handler is idempotent enough.
  • HMAC-SHA256 of ${timestamp}.${raw_body}. The timestamp is part of the signed payload — moving it out of the signature would let an attacker swap timestamps freely.
  • Constant-time compare. Without it, an attacker could measure response timing to deduce signature characters. The TS sample uses XOR-and-OR; the Python sample uses hmac.compare_digest.
  • Multiple v1= allowed. Lets us rotate secrets without breaking your verifier — during rotation, Patomic signs with both old and new for a window. Yours matches whichever it knows.

If you can use an SDK, do. Patomic’s SDK (Phase 4) wraps this exact verifier with the canonical defaults so you can write:

import { verifyWebhook } from "@patomic/sdk";
const ok = await verifyWebhook(req.body, req.headers["patomic-signature"], secret);

Until then, the snippets above are the reference implementations. They were lifted from Patomic’s own @patomic/core package and will not drift.