Implementing Webhooks in Next.js 15 App Router
Discover practical strategies for implementing efficient Next.js webhook solutions.
Implementing Webhooks in Next.js 15 App Router
Webhooks let your Next.js app react to external events the moment they happen — a payment succeeds, a subscription is cancelled, a CMS publishes new content — without polling. This guide walks through every layer: routing, signature verification, error handling, retry logic, idempotency, and local testing, with complete TypeScript code for the App Router.
What You'll Build
| Layer | What we cover |
|---|---|
| Routing | app/api/webhooks/route.ts with raw-body access |
| Security | HMAC-SHA256 signature verification + timestamp replay protection |
| Reliability | Exponential backoff retry with jitter |
| Idempotency | Deduplication via stored event IDs |
| Real-world example | Full Stripe webhook handler |
| Local testing | Stripe CLI + ngrok workflows |
Understanding Webhooks
A webhook is an HTTP POST request that a third-party service sends to your server when something noteworthy happens. You register a public URL (the webhook endpoint), and the service calls it with a JSON body describing the event.
Why not polling? Polling burns requests asking "anything new?" on a fixed schedule. Webhooks flip that: the service calls you, so your app only does work when there's something to do.
Common use cases in Next.js
- Payment processing — Stripe, Paddle, or Lemon Squeezy fire events for
payment_intent.succeeded,subscription.deleted, refunds, disputes. - CMS publishing — Contentful, Sanity, and Payload CMS can trigger a webhook to revalidate Next.js cache when content changes.
- Repository events — GitHub sends
push,pull_request, andreleaseevents that CI pipelines consume. - Communication — Twilio and SendGrid push delivery receipts and inbound messages via webhook.
Setting Up the Webhook Endpoint
In the App Router, every file at app/api/**/route.ts becomes an HTTP endpoint. The key constraint for webhooks: you need the raw request body as a string or Buffer before JSON-parsing it, because signature verification hashes the exact bytes that arrived.
Project prerequisites
Add your secrets to .env.local:
Basic webhook route
App Router vs Pages Router: In the Pages Router you had to disable the built-in body parser with
export const config = { api: { bodyParser: false } }and then readreqas a stream. The App Router uses the WebRequestAPI — there is no equivalentbodyParserconfig at all. Simply callawait req.text()beforeJSON.parse()and you get the raw bytes intact, no extra config or middleware required.
Implementing Webhook Security
A public HTTP endpoint that triggers side-effects is an attractive target. Three layers of defence cover the main attack vectors.
1. HMAC-SHA256 signature verification
The sending service signs the raw payload with a shared secret using HMAC-SHA256. You recompute the signature server-side and compare. If they don't match, the request didn't come from the expected sender.
Why timingSafeEqual? A naive === comparison short-circuits as soon as bytes differ, leaking timing information an attacker can exploit to forge signatures one byte at a time. timingSafeEqual always runs in constant time regardless of where the strings diverge, making it impossible to iteratively brute-force the correct signature via response latency.
2. Timestamp / replay-attack protection
Even a valid signature can be replayed if an attacker captures a legitimate request and re-sends it later. Include a timestamp in the signed payload and reject requests outside a tolerance window (typically 5 minutes).
Call both checks before processing:
3. Supporting defences
| Defence | Implementation |
|---|---|
| HTTPS only | Vercel and most hosts enforce TLS. Never accept webhooks over plain HTTP in production. |
| IP allowlisting | Some services (Stripe, GitHub) publish their IP ranges. Add a middleware check if your infra supports it. |
| Rate limiting | Use Vercel's Edge Middleware or an upstream proxy (Cloudflare) to cap requests per minute per IP. |
| Audit logging | Log every webhook attempt — signature pass/fail, event type, processing outcome — to aid debugging. |
Error Handling and Retry Logic
Webhook senders expect a 2xx within their timeout window (Stripe's default is 30 seconds). If they get anything else — or no response — they retry. Your handler should:
- Respond
200immediately when the payload is valid, even if processing is still in-flight. - Offload heavy work to a background queue so you don't race the timeout.
- Implement idempotency so retried events don't create duplicate side-effects.
Common failure modes
| Cause | Symptom | Fix |
|---|---|---|
| Network timeout | Sender retries; handler runs twice | Idempotency keys (store processed event IDs in DB) |
| Unhandled event type | Unhandled switch branch throws | Add a default case that logs and returns 200 |
| Database connection error | 500 response; sender retries | Retry logic with backoff inside the handler |
| Payload parse error | JSON.parse throws | Wrap in try/catch; return 400 |
| Signature mismatch | Reject with 401 | Check secret env var matches the sender's configured secret |
Exponential backoff with jitter
When your handler calls downstream services (a database, an email API) that may fail transiently, wrap those calls in retries — not the entire webhook response. The jitter spreads retry storms across time, preventing every failed call from hammering the downstream service at the same millisecond.
Usage inside your webhook handler:
Idempotency pattern
Webhook senders retry on any non-2xx response, and occasionally retry even after receiving a 2xx (e.g., due to network errors on their side). Without idempotency, a single successful payment can trigger two database writes, two confirmation emails, or two provisioning calls.
The safest approach is a two-step deduplication: check for the event ID before processing, then record it before doing any work. Using a database transaction or an upsert with a unique constraint on eventId prevents a rare race condition where two concurrent retries slip through simultaneously.
Use it in your route handler:
Schema tip: Add a unique index on
eventIdand aprocessedAttimestamp. Periodically prune records older than your provider's maximum retry window (Stripe retries for up to 3 days; GitHub retries for 3 days too) to keep the table lean.
Stripe Webhook Handler — Complete Example
Stripe is the most common webhook integration for Next.js apps. Their SDK handles signature verification internally, using a slightly extended format that includes the timestamp in the signed string — so you never need to manually call verifyWebhookSignature for Stripe events.
apiVersionnote: The string'2023-10-16'pins your integration to a known-good version. When you're ready to upgrade, test against your Stripe Dashboard's "Upgrade" preview, then bump the string and redeploy. The stripe-node SDK always accepts the version your account was created on plus any version you've explicitly tested.
Local Testing
You need a public URL to receive webhooks from services like Stripe during development. Two tools cover this well.
Option 1 — Stripe CLI (recommended for Stripe)
The Stripe CLI forwards events directly to your local server without exposing a public URL:
The CLI prints a webhook signing secret (starts with whsec_) — use that as STRIPE_WEBHOOK_SECRET in .env.local while developing. It's different from your production secret.
Trigger a test event in a second terminal:
Option 2 — ngrok (any service)
ngrok tunnels your local port to a public HTTPS URL that any webhook sender can reach:
Copy the https://xxxx.ngrok-free.app URL and register it as your webhook endpoint in the third-party service's dashboard. Remember to update it each time ngrok restarts (unless you're on a paid plan with a static domain).
Checklist before going to production
-
STRIPE_WEBHOOK_SECRET(or equivalent) is set in your production environment variables — not the CLI's development secret. - The endpoint returns
200within the provider's timeout window (30 s for Stripe). - Idempotency is implemented so duplicate deliveries are harmless.
- Unhandled event types return
200, not500, so the provider doesn't keep retrying them. - Signature verification runs before any business logic.
- Audit logging captures every event ID, type, and outcome.
Frequently Asked Questions
Do I need a special Next.js config to read the raw body in route handlers?
No. App Router route handlers receive a standard Web Request object. Call await req.text() and you get the raw string — no bodyParser: false config, no custom middleware, no req.pipe() gymnastics. This is one of the cleaner ergonomic improvements over the Pages Router.
What HTTP status should I return when I don't recognise an event type?
Return 200. Returning 400 or 500 signals to the sender that delivery failed, which triggers retries. Since you'll never recognise that event type (it's intentionally unhandled), retrying it wastes both parties' resources. Log it and move on.
Can I use Edge Runtime for webhook handlers?
With caution. The crypto module's timingSafeEqual is available in the Edge Runtime via the Web Crypto API (crypto.subtle), but the Node.js crypto import used in the examples above requires the Node.js runtime. Add export const runtime = 'nodejs' to your route file to be explicit, or rewrite the HMAC check using SubtleCrypto if you need the Edge Runtime.
How do I verify webhooks from GitHub?
GitHub signs with HMAC-SHA256 using the secret you set when creating the webhook, and sends the signature in the x-hub-signature-256 header as sha256=<hex>. Strip the sha256= prefix before comparing with timingSafeEqual.
Damian Hodgkiss
Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.