Getting Started
Install @zap-studio/webhooks and build your first schema-validated webhook router step by step.
This page walks you through installing the package and building a working webhook router.
Install
Install the router package. You also need a Standard Schema-compatible validator — this guide uses Zod, but Valibot, ArkType, or any other compatible library works the same way.
npm install @zap-studio/webhooks zodyarn add @zap-studio/webhooks zodpnpm add @zap-studio/webhooks zodbun add @zap-studio/webhooks zoddeno add jsr:@zap-studio/webhooks npm:zodCreate a Router
createWebhookRouter configures global behavior once: the required path prefix, request verification, and lifecycle hooks.
import { createWebhookRouter } from "@zap-studio/webhooks";
const router = createWebhookRouter({
prefix: "/webhooks", // default
});
The prefix starts with a slash and has no trailing slash. The router normalizes what you pass in — missing leading slashes are added, trailing slashes stripped, duplicate slashes collapsed — and "" (or "/") mounts routes at the root.
Register a Route
Each route binds a route key, an optional validation schema, and a handler. Route keys start with a slash and are relative to the prefix, so the route below answers on /webhooks/payments/succeeded. When you provide a schema, the handler’s payload type is inferred from the schema output — no manual type annotations needed.
import { z } from "zod";
router.register("/payments/succeeded", {
schema: z.object({
id: z.string(),
amount: z.number().positive(),
currency: z.string().length(3),
}),
handler: ({ payload }) => {
// payload: { id: string; amount: number; currency: string }
return Response.json(`processed ${payload.id}`);
},
});
The handler receives a context object with:
payload— the validated payload, typed from the schemarequest— the incoming Web APIRequest(headers, method, URL — the body stream is already consumed by the router)rawBody— the exact request body bytes as aUint8Arraypath— the matched route key (for example/payments/succeeded)
Return a Response to control the reply, or return nothing to respond with the default 200 acknowledgement (Response.json("ok")).
You can also register a plain handler without a schema. The payload is then the parsed JSON body, typed as unknown unless you declare a payload type yourself.
router.register("/ping", () => Response.json("pong"));
Handle a Request
handle takes a standard Request and returns a standard 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));
For raw Node http servers, use a fetch-to-Node bridge such as srvx or @hono/node-server.
The router responds with:
404and{ error: "not found" }when the path is missing the prefix or no route matches (the request body is never read in this case)400and{ error: "validation failed", issues: [...] }when schema validation fails500and{ error: message }when a hook, verifier, or handler throws (customizable withonError)