Checking signatures
Anyone can post to your endpoint’s URL. The digize-signature header proves a delivery came from Digize and wasn’t changed on the way:
digize-signature: t=1793095200,v1=1bc22bd169c5eaf9f3a834600a99bcbe6b0f802c57c8965b17473e0b701214a7tis when Digize sent this attempt, in Unix seconds. Each retry is signed again with its own time.v1is the HMAC-SHA256, in lower-case hex, oft, a., and the raw body, keyed with the endpoint’s whole secret (whsec_…, as the API gave it, prefix included).
To check one:
- Read the raw body as text, before parsing it.
- Split the header on
,and each part on the first=. - Refuse the delivery if
tis more than five minutes from now: an old delivery sent again is a replay. - Compute the HMAC of
`${t}.${body}`with the secret and compare it withv1in constant time.
In TypeScript
Section titled “In TypeScript”Web Crypto, so it runs the same in Node.js 20 and later, Workers, Deno and Bun:
const encoder = new TextEncoder();
/** Checks a delivery's `digize-signature` header against the endpoint's secret. */export async function verifyDigizeSignature( secret: string, body: string, header: string | null, toleranceSeconds = 300,): Promise<boolean> { if (!header) return false; const parts = new Map( header.split(",").map((part) => { const [key = "", ...value] = part.trim().split("="); return [key, value.join("=")] as const; }), ); const timestamp = Number(parts.get("t")); const signature = parts.get("v1") ?? ""; if (!Number.isInteger(timestamp) || !/^[0-9a-f]{64}$/i.test(signature)) { return false; } if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; const key = await crypto.subtle.importKey( "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"], ); const bytes = new Uint8Array( signature.match(/../g)?.map((pair) => Number.parseInt(pair, 16)) ?? [], ); // verify() compares in constant time. return crypto.subtle.verify( "HMAC", key, bytes, encoder.encode(`${timestamp}.${body}`), );}And an endpoint that uses it, as a Worker:
import { verifyDigizeSignature } from "./digize-signature";
interface Env { DIGIZE_WEBHOOK_SECRET: string; EVENTS: Queue;}
export default { async fetch(request: Request, env: Env): Promise<Response> { const body = await request.text(); const signature = request.headers.get("digize-signature"); if (!(await verifyDigizeSignature(env.DIGIZE_WEBHOOK_SECRET, body, signature))) { return new Response("Bad signature", { status: 400 }); } const event = JSON.parse(body) as { id: string; type: string; data: unknown }; // Answer within five seconds: hand the work to a queue and do it there, // taking each event.id once. await env.EVENTS.send(event); return new Response(null, { status: 204 }); },};By hand
Section titled “By hand”To see what a signature should be, with the body saved to body.json exactly as it arrived:
printf '%s' "1793095200.$(cat body.json)" | openssl dgst -sha256 -hmac "$DIGIZE_WEBHOOK_SECRET"A worked example
Section titled “A worked example”With the secret whsec_9yLx4mTq2VbN7cR1kP5sW8eH3jD6fG0aZuQiOtYrXlE, t of 1793095200 and this body:
{"id":"evt_01JD7QY3D5F7H9K1M3P5R7T9V1","type":"commerce.order.paid","created_at":"2026-10-27T10:00:00.000Z","workspace_id":"ws_01JB8X4Q2M7KTW9C3N5RVHZ1DE","data":{"id":"ord_01JD7QK2X8V5N3M4P6R9T0W1YZ","number":1042,"status":"paid","total":4850}}v1 is 1bc22bd169c5eaf9f3a834600a99bcbe6b0f802c57c8965b17473e0b701214a7. Change one character of the body or the secret and it no longer matches.