---
title: Getting Started
description: Install @zap-studio/webhooks and build your first schema-validated webhook router step by step.
type: package
package: "@zap-studio/webhooks"
---

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](https://zod.dev), but Valibot, ArkType, or any other compatible library works the same way.

<CodeGroup>

```bash npm
npm install @zap-studio/webhooks zod
```

```bash yarn
yarn add @zap-studio/webhooks zod
```

```bash pnpm
pnpm add @zap-studio/webhooks zod
```

```bash bun
bun add @zap-studio/webhooks zod
```

```bash deno
deno add jsr:@zap-studio/webhooks npm:zod
```

</CodeGroup>

## Create a Router

`createWebhookRouter` configures global behavior once: the required path prefix, request verification, and lifecycle hooks.

```ts
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.

```ts
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.

```ts
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:

```ts
// 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`](https://srvx.h3.dev) or [`@hono/node-server`](https://github.com/honojs/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`)

:::note

The router reads the request body exactly once and keeps the exact bytes in `rawBody`. Signature verification recomputes the HMAC from those bytes, so pass the `Request` through untouched — any middleware that consumes or re-serializes the body first breaks verification.

:::
