@zap-studio/webhooks
v2.1.0
Published
A lightweight, type-safe, tree-shakeable webhook router with Standard Schema validation, signature verification, and lifecycle hooks.
Maintainers
Readme
@zap-studio/webhooks
Schema-first, type-safe webhook routing built on the standard Web API Request and Response primitives, with runtime-agnostic signature verification support.
Full documentation: zapstudio.dev/webhooks
Motivation
Webhook endpoints are usually built by hand, per provider, and this tends to repeat the same two mistakes. First, signature checks often use Node's crypto module, which does not exist on Cloudflare Workers, Deno, or Bun's edge runtimes — so the same code cannot run everywhere the webhook needs to be received.
Second, signatures are often compared with ===, which leaks timing information and opens a timing-attack risk that most teams do not know they have.
@zap-studio/webhooks fixes both. It is built on the standard Request/Response objects, so the same router works on Bun, Deno, Cloudflare Workers, or any framework that accepts a Request.
Signature verification is opt-in through the verify option, and the built-in HMAC verifier uses the Web Crypto API (globalThis.crypto.subtle) with a constant-time comparison, so once you turn it on, you get the safe comparison without writing it yourself. Payloads are validated and typed straight from your Standard Schema — no any from JSON.parse, no manual casts.
Installation
npm install @zap-studio/webhooksYou also need a schema library that implements Standard Schema, such as Zod, Valibot, or ArkType.
Features
- Web API native —
handle(request: Request)returns aResponse, so the router plugs directly into Bun, Deno, Cloudflare Workers, Next.js route handlers, Hono, and any other fetch-compatible runtime. - Type-safe routing — handler payload types are inferred from the route schema.
- Standard Schema validation — bring Zod, Valibot, ArkType, or any compatible library.
- Signature verification — built-in HMAC verifier with constant-time comparison, or plug in your own
verifyfunction. - Lifecycle hooks — global
before,after, andonErrorhooks for cross-cutting behavior. - Runtime-agnostic — uses the Web Crypto API, not Node-specific APIs.
- Optional logging through
createWebhookRouter({ logger })(@zap-studio/logger) — omit it and there's zero logging overhead. - Tree-shakeable — validation and hook-running internals are standalone functions; unused exports are dropped by any modern bundler.
Quick Start
import { ConsoleLogger } from "@zap-studio/logger";
import { createWebhookRouter } from "@zap-studio/webhooks";
import { z } from "zod";
const logger = new ConsoleLogger({ minLevel: "debug" });
const router = createWebhookRouter({ prefix: "/webhooks", logger });
router.register("/payments/succeeded", {
schema: z.object({ id: z.string(), amount: z.number().positive() }),
handler: ({ payload }) => {
// payload is inferred from schema
return Response.json({ processed: payload.id });
},
});
export default {
fetch: (request: Request) => router.handle(request),
};Web API Native
handle(request: Request) returns a Response, so the router plugs directly into any fetch-compatible runtime.
// Bun / Deno / Cloudflare Workers
export default { fetch: (request: Request) => router.handle(request) };
// Next.js route handler (app/webhooks/[...path]/route.ts)
export const POST = (request: Request) => router.handle(request);
// Hono
app.all("/webhooks/*", (c) => router.handle(c.req.raw));Type-Safe Routing
Handler payload types are inferred from the route schema.
router.register("/payments/succeeded", {
schema: z.object({ id: z.string(), amount: z.number() }),
handler: ({ payload }) => {
// payload.id: string, payload.amount: number — inferred from schema
return Response.json({ ok: true });
},
});Standard Schema Validation
Bring Zod, Valibot, ArkType, or any compatible library.
import { z } from "zod";
// or: import * as v from "valibot"; import { type } from "arktype";
router.register("/event", {
schema: z.object({ id: z.string() }),
handler: ({ payload }) => Response.json(payload),
});Signature Verification
Built-in HMAC verifier with constant-time comparison, or plug in your own verify function.
import { createHmacVerifier, createWebhookRouter, VerificationError } from "@zap-studio/webhooks";
const router = createWebhookRouter({
verify: createHmacVerifier({
headerName: "x-hub-signature-256",
secret: process.env.WEBHOOK_SECRET!,
}),
onError: (error) => {
if (error instanceof VerificationError) {
return Response.json({ error: "invalid signature" }, { status: 401 });
}
},
});verify also works per-route on register(), overriding the router-level one — useful for a router handling multiple providers, each with its own signing scheme:
router.register("/github", {
verify: createHmacVerifier({ headerName: "x-hub-signature-256", secret: githubSecret }),
schema: githubEventSchema,
handler: githubHandler,
});
router.register("/stripe", {
verify: stripeVerify, // a different scheme entirely
schema: stripeEventSchema,
handler: stripeHandler,
});Lifecycle Hooks
Global before, after, and onError hooks for cross-cutting behavior.
const router = createWebhookRouter({
before: (ctx) => console.log("incoming", ctx.path),
after: (_ctx, response) => console.log("status", response.status),
onError: (error) => Response.json({ error: error.message }, { status: 500 }),
});Runtime-Agnostic
Uses the Web Crypto API, not Node-specific APIs.
// Uses globalThis.crypto.subtle — no Node `crypto` import required
const verify = createHmacVerifier({
headerName: "x-hub-signature-256",
secret: process.env.WEBHOOK_SECRET!,
});Logging
Pass a logger?: Logger from @zap-studio/logger to createWebhookRouter(...) to observe delivery attempts, dispatch, verification failures, and unmatched routes. Omit it and nothing is logged.
import { ConsoleLogger } from "@zap-studio/logger";
import { createWebhookRouter } from "@zap-studio/webhooks";
const logger = new ConsoleLogger({ minLevel: "debug" });
const router = createWebhookRouter({ prefix: "/webhooks", logger });Each delivery attempt and handler dispatch logs at debug; verification failures and unmatched routes log at warn.
OpenTelemetry
@opentelemetry/api is a required peer dependency — tiny, side-effect-free, and a no-op until an app registers a real SDK, so installing it costs nothing at runtime for consumers who never set one up.
Each delivery gets a SERVER span, and the sender's traceparent header is extracted so the delivery continues their trace instead of starting a new one. Each handler dispatch gets its own child INTERNAL span:
npm install @opentelemetry/apiimport { createWebhookRouter } from "@zap-studio/webhooks";
const router = createWebhookRouter({ prefix: "/webhooks" });
router.register("/stripe", { schema: stripeEventSchema, handler });
// If your app has registered an OpenTelemetry SDK, router.handle(request)
// now produces a SERVER span per delivery (continuing the sender's trace
// when a traceparent header is present) and an INTERNAL span per handler
// dispatch. If not, it's a no-op — no wiring required either way.
export default { fetch: (request: Request) => router.handle(request) };A non-2xx response (unmatched route, validation failure, verification failure, handler error) marks the delivery span ERROR; a thrown handler error is also recorded as an exception on the handler span.
Runtime Support
| Runtime | Minimum version | | ------------------ | ------------------------------------------------ | | Node.js | 18.0.0 (router), 19.0.0 (verification helper) | | Bun | 1.0.0 | | Deno | 1.42 | | Cloudflare Workers | Any current release | | Browsers | Latest evergreen (Chrome, Edge, Firefox, Safari) |
The router only needs the standard Request/Response APIs, available globally since Node.js 18. The verification helper additionally needs globalThis.crypto.subtle, which is global by default from Node.js 19 (on Node.js 18, pass the --experimental-global-webcrypto flag). In browsers, Web Crypto requires a secure context (HTTPS). Deno 1.42 is the first release that can install packages from JSR (deno add jsr:@zap-studio/webhooks).
License
MIT
