Verified, typed, idempotent webhook receivers for the Next.js App Router.
Zero dependencies. Built on Web Crypto, so the same code runs on the Node and Edge runtimes. No provider SDKs needed, not even Stripe's.
Docs, guides, and a live playground: next-webhooks.clawdiu.xyz
Every webhook endpoint has to do the same four things, and each one is easy to get wrong:
- Read the raw body. The signature covers the exact bytes that were sent. If anything parses the body first, verification breaks.
- Verify the signature. Without it, anyone who finds your URL can send you fake events.
- Skip duplicates. Providers deliver "at least once", which in practice means "sometimes twice".
- Return the right status code. The status code is what tells the provider whether to retry.
next-webhooks turns those four steps into one small route handler.
pnpm add next-webhooksNo peer dependencies, not even next.
// app/api/webhooks/stripe/route.ts
import { webhook, stripe } from "next-webhooks";
export const POST = webhook({
provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
handler: async (event) => {
if (event.type === "invoice.paid") {
// event.payload is the parsed JSON body
// event.rawBody is the exact string that was signed
}
},
});That's the whole endpoint. Verification, parsing, deduplication, and retry-friendly status codes are handled for you.
Copy-paste recipes for Shopify, Clerk, Lemon Squeezy, Redis, testing, and more live in examples/.
Declare the events you handle as schemas, using any Standard Schema library (zod, valibot, arktype, and others). Each payload is validated at runtime, and its type is inferred from the schema, so validation and types come from one place:
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 }
},
},
});This stays zero-dependency: the package vendors only the Standard Schema interface (types, nothing at runtime), and you pass in schema instances from whichever 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. Providers that retry will keep redelivering, and the retry succeeds as soon as the schema (or the payload) is fixed. The
onInvalidPayloadcallback is called so you hear about it. - Event types you did not declare a schema for pass through unvalidated.
on replaces the if (event.type === ...) chain, with or without schemas. Deliveries that match no entry fall through to handler if you provide one; otherwise they are acknowledged with a 200 so the provider stops retrying deliveries this endpoint would never process:
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) => { /* ... */ },
});If you want types but no runtime checking, give webhook a map of event names to payload shapes, and checking event.type narrows event.payload:
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
}
},
});The plain form webhook<T> still types every payload as T. Unlike events, this form is a compile-time promise only: the payload at runtime is whatever the provider sent.
Pick the one that matches who is calling you:
stripe({ secret })
Stripe. Verifies the stripe-signature header and rejects timestamps older than 5 minutes, which blocks replayed requests.
github({ secret })
GitHub. Verifies the x-hub-signature-256 header. The delivery id and event name come from GitHub's headers.
svix({ secret })
Anyone who delivers through Svix, plus the compatible Standard Webhooks scheme. Accepts both the svix-* and webhook-* header spellings. The secret is the whsec_... value from the provider's dashboard.
clerk({ secret }), resend({ secret }), polar({ secret })
Clerk, Resend, and Polar, which all deliver that scheme. Verification is svix()'s, down to the replay window and secret rotation; what differs is the name, so their dedupe keys read clerk:<id> instead of svix:<id>. That matters once two Svix-backed endpoints share one Redis store: under the bare svix() name, a message id one provider has already handled looks like a duplicate to the other, and the second delivery is ACKed without ever running. Reach for these whenever you have more than one such endpoint, and use svix({ name }) for a Svix sender that has no named provider here.
slack({ secret })
Slack Events API and interactivity requests. Verifies the x-slack-signature header against the request timestamp, with replay protection.
paddle({ secret })
Paddle Billing. Verifies the paddle-signature header (ts=...;h1=...), with replay protection. The event id and type come from the payload.
shopify({ secret })
Shopify. Verifies the x-shopify-hmac-sha256 header. The event type is the topic header (orders/create) and deliveries dedupe by x-shopify-webhook-id.
lemonsqueezy({ secret })
Lemon Squeezy. Verifies the x-signature header; the event type comes from x-event-name.
vercel({ secret })
Vercel account and integration webhooks. Verifies the x-vercel-signature header; the event id and type come from the payload.
discord({ publicKey })
Discord interactions and webhook events, verified with Ed25519 (the application's public key from the developer portal), with replay protection. Discord validates your endpoint with PINGs that expect a specific answer, so return it from the handler: Response.json({ type: 1 }) for interactions, new Response(null, { status: 204 }) for webhook events.
hmac({ secret, header, ... })
Everyone else. The generic building block: an HMAC of the raw body, carried in one header. You choose the algorithm (sha1/sha256/sha512), the encoding (hex or base64), and an optional prefix like sha256=. Most providers not listed above fit in a few lines.
token({ header, secret })
Internal traffic where the sender just includes a shared secret in a header, compared timing-safely. Prefer a signed scheme for anything public, since a signature also covers the body.
Every provider accepts one secret or an array (secret: [oldSecret, newSecret]), so both stay valid while you rotate.
Providers decide whether to retry based on the status code, so each situation gets a deliberate one:
| What happened | Status | What it means for you |
|---|---|---|
Body larger than maxBodyBytes |
413 | Dropped before the body is read or hashed. Only when you set a limit. |
| Signature invalid or missing | 401 | The request is dropped. The response body says why (reason), which makes misconfigured secrets easy to spot. |
| Verification threw an error | 400 | Something unexpected broke during verification. onError is called. |
Payload failed its events schema |
422 | onInvalidPayload is called. The event is not marked processed, so a provider retry succeeds once the schema or payload is fixed. |
No on entry or handler for the event type |
200 | Acknowledged and skipped, with unhandled: true in the body. |
| Duplicate delivery | 200 | Acknowledged without running your handler again. |
| Handler succeeded | 200 | Done. Your handler can also return its own Response instead. |
| Handler threw an error | 500 | The provider will retry, and the retry will be processed (see below). |
| Idempotency store threw an error | 500 | The provider retries later. Your handler is not run without dedup protection. |
| Method is not POST | 405 |
Capping the body: a webhook URL is public, so anything on the internet can post to it, and the raw body has to be buffered and hashed before a signature can prove it was junk. maxBodyBytes puts a ceiling on that: an oversized delivery is answered with 413 straight away, and a Content-Length that already exceeds the limit is rejected before the body is read at all.
export const POST = webhook({
provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
maxBodyBytes: 1_000_000,
handler,
});There is no limit by default, because real payloads vary too much for one safe number: most events are a few kilobytes, but a GitHub push or a Stripe event with a large object can run into megabytes. Pick a ceiling your provider stays under, since a delivery over it will retry and fail until the provider gives up.
Why retries work after a failure: when your handler throws, the event id is released from the idempotency store before the 500 goes out. Without that, the provider's retry would look like a duplicate and be acknowledged without ever being processed, a classic webhook bug. It's handled for you.
Events are remembered by their provider event id for 24 hours. If the same id arrives again, it's acknowledged with a 200 and your handler is not called.
The default store lives in memory, which is fine on a single long-running server. On serverless, each instance has its own memory, so bring a shared store.
One is included for Upstash Redis, including databases provisioned through the Vercel Marketplace. It talks to the REST API over fetch, so it is Edge-safe and adds no dependencies:
import { upstash } from "next-webhooks/stores";
export const POST = webhook({
provider,
// Reads UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN,
// or the Vercel KV_REST_API_* pair. Or pass { url, token }.
idempotency: upstash(),
handler,
});Any other backend is just two methods away:
import type { IdempotencyStore } from "next-webhooks";
const redisStore: IdempotencyStore = {
// Return true if the id is new, false if it was already seen.
// Redis SET NX does exactly this, atomically.
add: async (id, ttlMs) => (await redis.set(id, "1", { nx: true, px: ttlMs })) !== null,
// Called after a handler failure, so the provider's retry gets processed.
remove: async (id) => { await redis.del(id); },
};
export const POST = webhook({ provider, idempotency: redisStore, handler });Pass idempotency: false to turn deduplication off. Events that have no id are never deduplicated.
Providers retry when you respond too slowly (Stripe allows about 30 seconds). Acknowledge fast and push heavy work past the response:
import { after } from "next/server";
export const POST = webhook({
provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
handler: async (event) => {
after(() => doTheHeavyThing(event));
},
});One caveat: code inside after() runs once the 200 is already sent, so if it fails, the provider will not retry. Keep anything that must never be lost inside the handler itself.
Optional callbacks cover logging and alerting:
export const POST = webhook({
provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
handler,
// Verification or handler errors. Send these to your error tracker.
onError: (error, event) => console.error("webhook failed:", error, event?.id),
// Deliveries that failed the signature check (forged or misconfigured).
onInvalid: (reason) => console.warn("invalid webhook:", reason),
// Verified deliveries whose payload failed its schema in `events`.
onInvalidPayload: (issues, event) => console.warn("bad payload:", event.type, issues),
});next-webhooks/testing builds correctly signed headers, so route tests need no crypto code:
import { githubHeaders } from "next-webhooks/testing";
import { POST } from "../app/api/webhooks/github/route";
it("handles a push", async () => {
const body = JSON.stringify({ ref: "refs/heads/main" });
const res = await POST(
new Request("http://localhost/api/webhooks/github", {
method: "POST",
body,
headers: await githubHeaders({ secret: process.env.GITHUB_WEBHOOK_SECRET!, body }),
}),
);
expect(res.status).toBe(200);
});There is a builder per provider: stripeHeaders, githubHeaders, svixHeaders, slackHeaders, paddleHeaders, shopifyHeaders, lemonsqueezyHeaders, vercelHeaders, discordHeaders (with discordKeys() to generate a test key pair), and hmacHeaders for custom schemes. svixHeaders covers Clerk, Resend, and Polar too; pass spelling: "webhook" for the Standard Webhooks header names Polar sends.
Webhook routes are hard to poke at by hand because unsigned requests are rejected. The package ships a small CLI for exactly that (Node 20+, still zero dependencies):
# Scaffold a verified route and its env var
npx next-webhooks init stripe
# Send a realistic, correctly signed test event to your route
npx next-webhooks fire stripe invoice.paid
# Check every webhook route's secret resolves; exits 1 in CI when one is missing
npx next-webhooks doctor
# Catch real deliveries: point your tunnel (ngrok, cloudflared) at this port.
# Every delivery is appended to .webhooks/captures.jsonl and forwarded to
# your app, which still decides the response the provider sees.
npx next-webhooks dev
# Re-fire captured deliveries at your app, any time, no tunnel needed
npx next-webhooks replay --provider stripe --last 1fire ships canned fixtures per provider (--list shows them) and reads the signing secret from the same env files Next.js loads (.env, .env.development, .env.local, .env.development.local): STRIPE_WEBHOOK_SECRET, GITHUB_WEBHOOK_SECRET, SLACK_SIGNING_SECRET, and friends. When the route answers 401, the CLI prints the route's reason plus where the secret it signed with came from, which is usually the whole debugging session.
To point an event at your own data, override any payload field by dot path with --set (repeatable), or pass a whole payload with --body:
# "this specific customer just paid": your handler runs against your own DB row
npx next-webhooks fire stripe invoice.paid --set data.object.customer=cus_YourUser42
# and the unhappy path for the same user
npx next-webhooks fire stripe invoice.payment_failed --set data.object.customer=cus_YourUser42--secret, --url, and --id override the remaining defaults, and fire hmac --header x-signature covers custom schemes.
replay re-signs each captured raw body with your local secret and a fresh timestamp before sending. That is the part you cannot do with curl: replaying the original headers fails twice, because the original signature was made with the production secret, and because replay protection rejects the old timestamp. Already-processed deliveries are acknowledged as duplicates; pass --fresh-ids to rewrite event ids so your handler runs every time.
Captures can contain real customer data, so add .webhooks/ to your .gitignore.
The full CLI guide, with the reasoning, every flag, and recipes for the common workflows, lives in docs/cli.md.
send() signs a payload in the Standard Webhooks format and POSTs it, so your app can emit webhooks too:
import { send } from "next-webhooks";
await send({
url: "https://partner.example/api/webhooks/you",
secret: process.env.OUTBOUND_WEBHOOK_SECRET!,
payload: { type: "report.ready", data: { reportId: "rep_1" } },
});The receiver verifies it with this package's svix() provider, the svix SDK, or any Standard Webhooks implementation.
By default send() makes one attempt and returns the Response. Pass retry for in-process retries with exponential backoff and jitter:
await send({ url, secret, payload, retry: { attempts: 5 } });Retries cover network errors and transient statuses (408, 425, 429 honoring Retry-After, and 5xx); other non-2xx responses come back immediately, since resending an unauthorized delivery cannot succeed. Every attempt reuses the same id with a fresh signature, so the receiver deduplicates if an "attempt" actually arrived. For delivery that must survive a process crash, also check response.ok and call again later with the same id option.
- Runnable example projects in
examples/ - Adapters for other frameworks built on web Request/Response: SvelteKit, Nuxt, Hono, TanStack Start
MIT. See LICENSE.