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.
The header
Section titled “The header”Every Patomic webhook arrives with:
Patomic-Signature: t=1700000000,v1=5257a869e7ecebeda32a…t— Unix timestamp (seconds), the moment Patomic signed the payloadv1=…— hex HMAC-SHA256 of${t}.${raw_body}using your webhook secret- Multiple
v1=…values may appear during secret rotation — match against any of them
Verification — copy-pasteable
Section titled “Verification — copy-pasteable”// 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; });}import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify_patomic_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> bool: if not signature_header: return False
timestamp: int | None = None signatures: list[str] = [] for part in (p.strip() for p in signature_header.split(",")): if "=" not in part: return False k, _, v = part.partition("=") if k == "t": try: timestamp = int(v) except ValueError: return False elif k == "v1": signatures.append(v)
if timestamp is None or not signatures: return False
if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS: return False
payload = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() return any(hmac.compare_digest(candidate, expected) for candidate in signatures)Why each step matters
Section titled “Why each step matters”- 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.
Don’t roll your own
Section titled “Don’t roll your own”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.