Implementing Webhooks in Next.js 15 App Router
Discover practical strategies for implementing efficient Next.js webhook solutions.
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. A flaky webhook handler is one of those bugs that looks fine in dev and then quietly double-charges someone in production, so this guide is deliberately thorough: routing, signature verification, replay protection, retry logic, idempotency, a complete Stripe example, and how to actually test the thing locally before you ship it. All code targets the App Router on Next.js 15.
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.Next.js 15 gotcha:
headers()(andcookies()) became async in Next.js 15 — they now return aPromiseinstead of the value directly. Code written for Next.js 13/14 that callsheaders()synchronously still runs today for backwards compatibility, but it's deprecated and will break in a future major version. Alwaysawait headers()in new code.
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 via stripe.webhooks.constructEvent, using a slightly extended format that includes the timestamp in the signed string — so you never need to manually call verifyWebhookSignature for Stripe events. constructEvent throws if the signature or timestamp doesn't check out, so a single try/catch covers both checks.
This mirrors the structure of Stripe's own official Next.js webhook example, with the idempotency wrapper and 500-on-processing-failure added so Stripe's built-in retry logic actually helps you instead of silently masking dropped events.
Testing Webhooks Locally
You can't POST to localhost:3000 from Stripe's servers, so local testing means either forwarding events through a CLI tool or exposing your dev server with a tunnel.
Option 1: Stripe CLI (recommended for Stripe integrations)
The Stripe CLI forwards real Stripe events to your local server over an authenticated connection — no public URL required.
stripe listen prints a whsec_... value the first time you run it — use that as STRIPE_WEBHOOK_SECRET in .env.local for local testing (it's different from your production signing secret). Trigger a specific event without going through checkout:
Watch your next dev terminal — you should see the console.log output from the handler, and the Stripe CLI terminal will show the response status your route returned.
Option 2: ngrok (or Vercel preview deployments) for anything else
Not every provider has a CLI like Stripe's. For GitHub, Contentful, Sanity, Twilio, etc., tunnel your local server through a public HTTPS URL:
Copy the https://<random>.ngrok-free.app URL ngrok prints and register https://<random>.ngrok-free.app/api/webhooks as the webhook endpoint in the provider's dashboard. Requests hit ngrok's edge, tunnel to your machine, and land on next dev exactly like a production request would — including real signatures from the provider, so you're testing the actual verification path, not a mock.
A couple of things that trip people up here:
- The tunnel URL changes every time you restart ngrok on the free tier, so you'll need to update the webhook URL in the provider's dashboard each session (or pin a static domain on a paid ngrok plan).
next dev's fast refresh can drop an in-flight request if you edit the route file while a webhook is mid-flight. If you see intermittent failures only in dev, retry before assuming your handler is broken.
Summary
A production-ready Next.js webhook handler needs the raw body (not the parsed JSON) for signature verification, HMAC + timestamp checks to stop forged and replayed requests, a fast 200 response with heavy work offloaded, idempotency keyed on the sender's event ID, and a local testing loop via the Stripe CLI or a tunnel like ngrok. Skipping any one of these tends to surface later as a hard-to-debug production incident — usually a duplicate charge or a silently dropped event — rather than an obvious bug.
FAQ
Do I need to disable Next.js's body parser like in the Pages Router?
No. The App Router's Route Handlers use the Web Request API, which has no built-in body-parsing middleware to disable. Just call await req.text() to get the raw body before parsing it as JSON.
Why does my signature verification fail even though the secret is correct?
The most common cause is parsing the body to JSON and then re-serializing it before hashing — JSON.stringify(JSON.parse(rawBody)) is not guaranteed to produce byte-identical output to the original payload (key order, whitespace, number formatting can all differ). Always hash the raw text/Buffer you received, not a re-serialized copy.
Is headers() still synchronous in Next.js 15?
It still works without await for backwards compatibility, but it's deprecated — headers() (and cookies()) return a Promise in Next.js 15 and you should await them in new code.
How long should I keep processed webhook event IDs for idempotency? Long enough to cover the sender's maximum retry window — Stripe and GitHub both retry for up to 3 days — plus some buffer. Prune older records on a schedule so the table doesn't grow unbounded.
Damian Hodgkiss
Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.