Skip to content

Webhooks

@digize/sdk/webhooks checks the digize-signature on each webhook delivery: HMAC-SHA256 under the endpoint’s secret of the timestamp and the raw body, compared in constant time, and refused when it’s more than five minutes old. It uses Web Crypto only, so it runs the same in Workers, Node 22 and later, Deno and Bun.

src/index.ts
import { isDigizeError, parseWebhook } from "@digize/sdk/webhooks";
interface Env {
DIGIZE_WEBHOOK_SECRET: string;
EVENTS: Queue;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
const event = await parseWebhook(
await request.text(), // the raw body, before anything parses it
request.headers.get("digize-signature"),
env.DIGIZE_WEBHOOK_SECRET,
);
// Answer within 5 seconds: queue the work and do it afterwards.
await env.EVENTS.send(event);
return new Response(null, { status: 204 });
} catch (error) {
if (isDigizeError(error)) return new Response(error.message, { status: 400 });
throw error;
}
},
};

parseWebhook(body, header, secret, options) answers the event once the signature checks out. It throws unauthorized when the signature doesn’t match the secret, is missing or is too old, and invalid when a signed body isn’t an event.

Keep the body as it arrived: a body that’s parsed and turned back into JSON won’t match its signature.

import { createServer } from "node:http";
import { verifyWebhook } from "@digize/sdk/webhooks";
createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(chunk);
const body = Buffer.concat(chunks);
const signature = request.headers["digize-signature"];
const ok = await verifyWebhook(
body,
typeof signature === "string" ? signature : null,
process.env.DIGIZE_WEBHOOK_SECRET!,
);
response.writeHead(ok ? 204 : 400).end();
if (ok) handle(JSON.parse(body.toString("utf8")));
}).listen(3000);

With Express, take the body with express.raw({ type: "application/json" }) and pass req.body.

verifyWebhook(body, signatureHeader, secret, options?) Answers whether the delivery is Digize’s. body is the raw body as a string, Uint8Array or ArrayBuffer.
parseWebhook<T>(body, signatureHeader, secret, options?) Checks it the same way, then answers the parsed event as WebhookEvent<T>, or throws a DigizeError.
signWebhook(body, secret, timestamp?) Makes a digize-signature header the way Digize does, for testing your receiver. timestamp is in Unix seconds and defaults to now.

options.tolerance is how old a delivery may be, in seconds: 300 unless it says. options.now checks against another time, in milliseconds, for tests. A header carrying more than one v1 passes when any of them matches.

interface WebhookEvent<T = Record<string, unknown>> {
id: string; // the same for every endpoint and every retry: deduplicate on it
type: WebhookEventType; // such as "commerce.order.paid"
created_at: string;
workspace_id: string;
data: T; // the record the event is about
}

WEBHOOK_EVENTS lists every event an endpoint can subscribe to, and WebhookEventType is their names, with PING_EVENT (settings.webhook.ping), which a ping sends. What each event’s data holds is on the events page; give parseWebhook its shape to type it:

interface Order {
id: string;
number: number;
total: number;
currency: string;
}
const event = await parseWebhook<Order>(body, signature, secret);
if (event.type === "commerce.order.paid") {
console.log(`Order ${event.data.number} paid`);
}

WEBHOOK_HEADERS names the headers that come with a delivery: digize-event (the event’s name), digize-delivery (this delivery’s id, the same across its retries) and digize-signature.

import { signWebhook } from "@digize/sdk/webhooks";
const body = JSON.stringify({
id: "evt_test",
type: "commerce.order.paid",
created_at: new Date().toISOString(),
workspace_id: "ws_test",
data: { id: "ord_test", number: 1, total: 100, currency: "USD" },
});
const response = await fetch("http://localhost:8787/", {
method: "POST",
headers: {
"content-type": "application/json",
"digize-signature": await signWebhook(body, "whsec_test"),
},
body,
});

To send a real delivery to a live endpoint, ping it: POST /api/v1/webhooks/{id}/ping, or api.webhooks.ping({ id }) with the API client.