Events and routing

Validate payloads with schemas and route events to typed handlers.

Two options work together to replace the if (event.type === ...) chain: events declares a payload schema per event type, and on maps event types to handlers. Payloads are validated at runtime and every handler's types are inferred from its schema, so validation and types come from one place.

app/api/webhooks/stripe/route.ts
import { webhook, stripe } from "next-webhooks";
import { z } from "zod";

export const POST = webhook({
  provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
  events: {
    "invoice.paid": z.object({ id: z.string(), amount_due: z.number() }),
    "customer.subscription.deleted": z.object({ id: z.string() }),
  },
  on: {
    "invoice.paid": async (event) => {
      // event.payload is validated and typed: { id: string; amount_due: number }
    },
    "customer.subscription.deleted": async (event) => {
      // event.payload is { id: string }
    },
  },
});

Any Standard Schema library

events accepts zod, valibot, arktype, or anything else that implements the Standard Schema spec. The package vendors only the spec's TypeScript interface, so it stays zero-dependency; you pass in schema instances from the library you already use.

Three details worth knowing:

  • The schema's output becomes event.payload, so coercions and .transform() results carry through to your handler
  • A payload that fails its schema is answered with 422 and never marked as processed, so providers that retry keep redelivering, and the retry succeeds as soon as the schema (or the payload) is fixed
  • Event types you did not declare a schema for pass through unvalidated

Routing with on

on works with or without schemas. Deliveries that match no entry fall through to handler when you provide one; otherwise they are acknowledged with a 200 (unhandled: true in the body) so the provider stops retrying deliveries this endpoint would never process:

app/api/webhooks/github/route.ts
import { webhook, github } from "next-webhooks";

export const POST = webhook({
  provider: github({ secret: process.env.GITHUB_WEBHOOK_SECRET! }),
  on: {
    push: async (event) => { /* ... */ },
    issues: async (event) => { /* ... */ },
  },
  // Optional catch-all. Without it, unmatched events are ACKed and skipped.
  handler: async (event) => { /* ... */ },
});

Hearing about bad payloads

When a verified delivery fails its schema, the onInvalidPayload callback fires with the issues and the event, before the 422 goes out. Wire it to your logger so a drifting payload shape does not fail silently:

export const POST = webhook({
  provider,
  events,
  on,
  onInvalidPayload: (issues, event) =>
    console.warn("bad payload:", event.type, issues),
});

Types without validation

If you want compile-time types but no runtime checking, pass an event map as the type parameter instead. Checking event.type narrows event.payload, but the payload is whatever the provider sent:

import { webhook, stripe, type Events } from "next-webhooks";

type MyEvents = Events<{
  "invoice.paid": { id: string; amount_due: number };
  "customer.subscription.deleted": { id: string };
}>;

export const POST = webhook<MyEvents>({
  provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
  handler: async (event) => {
    if (event.type === "invoice.paid") {
      // event.payload is { id: string; amount_due: number } here
    }
  },
});