Skip to content
Zap Studio
webhooks
Esc
navigateopen⌘Jpreview
On this page

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 zod
yarn add @zap-studio/webhooks zod
pnpm add @zap-studio/webhooks zod
bun add @zap-studio/webhooks zod
deno add jsr:@zap-studio/webhooks npm:zod

Create 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 schema
  • request — the incoming Web API Request (headers, method, URL — the body stream is already consumed by the router)
  • rawBody — the exact request body bytes as a Uint8Array
  • path — 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:

  • 404 and { error: "not found" } when the path is missing the prefix or no route matches (the request body is never read in this case)
  • 400 and { error: "validation failed", issues: [...] } when schema validation fails
  • 500 and { error: message } when a hook, verifier, or handler throws (customizable with onError)

Last updated on September 15, 2026

Was this page helpful?