Verification
Verify webhook authenticity with createHmacVerifier or a custom verify function — built-in HMAC verification with constant-time comparison, runtime-agnostic via the Web Crypto API.
The router ships a built-in HMAC verifier with constant-time comparison, or accepts a custom verify function. Its verify option is the authenticity gate for incoming requests: it runs after before hooks but before payload validation and the route handler, and if it throws, request handling stops. This prevents forged payloads from ever reaching your business logic.
Verify HMAC Signatures
Use createHmacVerifier from webhooks for providers that sign requests with a shared-secret HMAC (GitHub, and many others). It uses the Web Crypto API rather than Node crypto, so it works in any runtime that provides globalThis.crypto.subtle.
import { createHmacVerifier, createWebhookRouter } from "@zap-studio/webhooks";
const router = createWebhookRouter({
verify: createHmacVerifier({
headerName: "x-hub-signature-256",
secret: process.env.GITHUB_WEBHOOK_SECRET!,
algo: "sha256", // optional, defaults to "sha256"
}),
});
Options:
headerName— the request header containing the provider’s signaturesecret— the shared HMAC secret, as a stringalgo— one of"sha1","sha256","sha384","sha512"; defaults to"sha256"
The verifier:
- imports the secret once at creation time, then reuses the key for every request
- computes the HMAC from
ctx.rawBody - normalizes the header value by stripping an algorithm prefix such as
sha256=, so provider formats like GitHub’s work without extra parsing - compares the expected and received signatures in constant time, which prevents timing attacks that recover a signature byte by byte from response-time differences
createHmacVerifier throws VerificationError immediately at creation time if the Web Crypto API is unavailable or the algorithm is unsupported, and per request when the signature header is missing or the signature does not match.
Runtime-Agnostic
Uses the Web Crypto API, not Node-specific APIs. createHmacVerifier calls globalThis.crypto.subtle directly instead of importing Node’s crypto module, so the same code runs unchanged in Node.js, Bun, Deno, Cloudflare Workers, and browsers.
// No Node `crypto` import — works wherever globalThis.crypto.subtle exists
const verify = createHmacVerifier({
headerName: "x-hub-signature-256",
secret: process.env.WEBHOOK_SECRET!,
});
globalThis.crypto.subtle is available by default in Bun, Deno, Cloudflare Workers, and browsers over HTTPS. In Node.js, it’s global from Node 19 onward (pass --experimental-global-webcrypto on Node 18).
Handle Verification Failures
Import VerificationError from webhooks to distinguish verification failures from other errors — for example, in an onError hook that maps them to a 401 instead of the default 500.
import { createHmacVerifier, createWebhookRouter, VerificationError } from "@zap-studio/webhooks";
const router = createWebhookRouter({
verify: createHmacVerifier({
headerName: "x-hub-signature-256",
secret: process.env.GITHUB_WEBHOOK_SECRET!,
}),
onError: (error) => {
if (error instanceof VerificationError) {
return Response.json({ error: "invalid signature" }, { status: 401 });
}
return undefined; // fall back to the default 500 response
},
});
Write a Custom Verifier
For providers with their own signing scheme, pass any function matching VerifyFn — (ctx: WebhookContext) => Promise<void> | void — that throws on failure. The context exposes request (for headers), rawBody (the exact body bytes), and path. Stripe, for example, signs a timestamped payload, so verification should use the Stripe SDK.
import Stripe from "stripe";
import { createWebhookRouter, VerificationError } from "@zap-studio/webhooks";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = createWebhookRouter({
verify: ({ request, rawBody }) => {
const signature = request.headers.get("stripe-signature");
if (!signature) {
throw new VerificationError("Missing Stripe signature");
}
try {
stripe.webhooks.constructEvent(
Buffer.from(rawBody),
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
throw new VerificationError("Invalid Stripe signature");
}
},
});
Write a custom verifier when a provider:
- uses a non-HMAC signature format
- requires SDK-specific verification logic
- includes timestamp or replay-protection checks
If your custom verifier compares signatures itself, use constantTimeEquals from webhooks instead of ===. It compares byte arrays (Uint8Array), not strings — encode strings first.
import { constantTimeEquals } from "@zap-studio/webhooks";
const encoder = new TextEncoder();
const isValid = constantTimeEquals(
encoder.encode(expectedSignature),
encoder.encode(receivedSignature),
);
Per-Route Verification
verify also works on register(), not just createWebhookRouter(). A route-level verify overrides the router-level one for that route only — useful when a single router handles multiple providers, each with its own signing scheme:
import { createHmacVerifier, createWebhookRouter } from "@zap-studio/webhooks";
const router = createWebhookRouter();
router.register("/github", {
schema: githubEventSchema,
verify: createHmacVerifier({
headerName: "x-hub-signature-256",
secret: process.env.GITHUB_WEBHOOK_SECRET!,
}),
handler: async ({ payload }) => {
console.log("GitHub event:", payload.ref);
},
});
router.register("/stripe", {
schema: stripeEventSchema,
verify: ({ request, rawBody }) => {
// Stripe's own signing scheme — see "Write a Custom Verifier" above
},
handler: async ({ payload }) => {
console.log("Stripe event:", payload.type);
},
});
No router-level verify is needed here — each route carries its own, right next to its schema and handler. If a router-level verify is also set, it only applies to routes that don’t set their own; a route-level verify always takes priority when present.