Skip to content

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=1bc22bd169c5eaf9f3a834600a99bcbe6b0f802c57c8965b17473e0b701214a7
  • t is when Digize sent this attempt, in Unix seconds. Each retry is signed again with its own time.
  • v1 is the HMAC-SHA256, in lower-case hex, of t, a ., and the raw body, keyed with the endpoint’s whole secret (whsec_…, as the API gave it, prefix included).

To check one:

  1. Read the raw body as text, before parsing it.
  2. Split the header on , and each part on the first =.
  3. Refuse the delivery if t is more than five minutes from now: an old delivery sent again is a replay.
  4. Compute the HMAC of `${t}.${body}` with the secret and compare it with v1 in constant time.

Web Crypto, so it runs the same in Node.js 20 and later, Workers, Deno and Bun:

digize-signature.ts
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:

src/index.ts
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 });
},
};

To see what a signature should be, with the body saved to body.json exactly as it arrived:

Terminal window
printf '%s' "1793095200.$(cat body.json)" | openssl dgst -sha256 -hmac "$DIGIZE_WEBHOOK_SECRET"

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.