Webhooks in the Next.js App Router: Raw Bodies, Signature Verification, and Returning 200 Fast
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗A webhook is an HTTP POST a provider sends to your server when something happens on their side — a payment settles, a repository receives a push, a subscription cancels. Our team has wired webhooks into several Next.js App Router applications this year, for payments and Git-hosting integrations, and every integration broke in the same three places first: verifying a signature against an already-parsed body, doing too much work before responding, and processing the same event twice. These are the field notes we now start every webhook integration from.
Key takeaways
- Webhook signatures — Stripe's Stripe-Signature header, GitHub's X-Hub-Signature-256 header — are HMACs computed over the raw request bytes. Verify against await req.text(), never against a re-serialized JSON.parse result.
- Next.js App Router route handlers do not pre-parse request bodies, so the Pages Router bodyParser: false escape hatch is unnecessary — req.text() already returns the raw payload.
- Return a 2xx response quickly. Providers treat a slow response as a failed delivery and retry it, so a slow handler ends up racing its own retries.
- Webhook delivery is at-least-once and unordered: deduplicate by event ID with a database unique constraint, and fetch the current object from the provider's API instead of trusting payload state.
- Compare signatures with crypto.timingSafeEqual and enforce a timestamp tolerance so a captured request cannot be replayed later.
Why does a signature check fail on a genuine request?
Because the signature was computed over the exact bytes the provider sent, and the handler is verifying different bytes. A webhook signature is a keyed hash — an HMAC — of the raw request body, produced with a shared signing secret. If a handler calls JSON.parse on the body and re-serializes it to verify, key order, whitespace, and unicode escaping can all change, and the HMAC no longer matches even though the request is genuine. The failure is silent and total: every event gets a 400, the provider retries, and the retry queue fills while the code looks correct.
The App Router makes the correct version easy. A route handler receives the standard web Request object, and Next.js does not pre-parse it — await req.text() returns the payload byte-for-byte. The Pages Router needed the bodyParser: false export to get the same access; that configuration does nothing in the App Router and is not needed.
How do we verify a webhook signature in a route handler?
When the provider ships a verification helper, we use it. stripe.webhooks.constructEvent(rawBody, signatureHeader, secret) checks both the HMAC and the signed timestamp in one call and throws on either failure, so the handler returns 400 from the catch block and 200 otherwise.
Without an SDK helper, we compute the HMAC ourselves with createHmac('sha256', secret).update(rawBody).digest('hex') and compare it to the header value. Two rules are non-negotiable. First, compare with crypto.timingSafeEqual after checking buffer lengths, because an ordinary string comparison returns early at the first differing character and leaks timing information an attacker can use to probe signatures byte by byte. Second, enforce a timestamp tolerance — most providers include a signed timestamp, and rejecting anything older than about five minutes stops a captured request from being replayed later.
One structural note: a webhook endpoint must be a route handler, not a Server Action. A Server Action is an RPC mechanism for your own application's frontend, addressed by framework-generated identifiers. A webhook needs a stable public POST URL with byte-level body access, and a route handler at app/api/webhooks/provider/route.ts is exactly that.
Why should the handler return 200 before doing the real work?
Because the provider treats a slow response as a failed delivery. Delivery timeouts are measured in seconds, and a handler that exceeds one gets marked failed and retried — so the slow handler ends up running concurrently with its own retry. Fulfillment logic that takes ten seconds guarantees that every real event arrives at least twice.
The shape we ship now is verify, record, acknowledge, then work. On Vercel, waitUntil from @vercel/functions keeps the function instance alive after the response has been sent, so the handler records the event ID, calls waitUntil(processEvent(event)), and returns 200 in milliseconds.
There are three places the real work can run. Inline before the response: acknowledgment is slow, but a crash is covered by the provider's retry — acceptable only for trivial work like setting a flag. Inside waitUntil after the response: acknowledgment is fast, but work lost in a crash was already acknowledged with a 200 and will never be resent — acceptable for losable side effects like cache warming and notifications. In a queue or job row processed by a separate worker: acknowledgment is fast and the job survives crashes and deploys — the only option we trust for money, entitlements, or any state that cannot be lost.
How do we handle retries and out-of-order events?
By assuming at-least-once delivery and no ordering, because both are documented provider behavior, not edge cases. Retries produce duplicates by design, and two events that happened in sequence can arrive reversed.
Deduplication: every provider event carries a stable ID — an evt_ identifier on a Stripe event, the X-GitHub-Delivery header on a GitHub delivery. We insert that ID into a table with a unique constraint before processing; if the insert violates the constraint, the event was already handled, so the handler returns 200 and stops. A SELECT-then-INSERT check is a race under concurrent retries — the constraint is the lock.
Ordering: we do not build state by applying payloads in arrival order. The event is a notification that something changed, not the change itself. For anything stateful we fetch the current object from the provider's API before writing, so a stale payload cannot overwrite newer state.
How do we test webhooks locally?
A provider cannot reach localhost, so local testing needs a bridge. Three options cover our work: a provider CLI that forwards events — stripe listen --forward-to localhost:3000/api/webhooks/stripe prints a temporary signing secret for the session; a tunnel such as cloudflared or ngrok plus the provider dashboard's manual redelivery button; and signed fixtures in automated tests.
The fixtures are the ones that pay rent. We capture one real payload, compute its HMAC with a test secret, and assert three things: the verifier accepts the valid pair, rejects a mutated body, and rejects an expired timestamp. That test catches the raw-body regression — someone adding a JSON middleware or moving the parse above the verify — before it ships and silently rejects every event.
FAQ
**Q:** Can a Server Action be a webhook endpoint?
**A:** No. A Server Action is invoked through framework-generated identifiers and is designed for your own application's components. A webhook provider needs a stable public POST URL with raw-body access, which is a route handler.
**Q:** Is bodyParser: false still needed in the App Router?
**A:** No. That option configures Pages Router API routes. An App Router route handler leaves the body untouched until you call req.text(), req.json(), or req.formData(), so the raw payload is always available.
**Q:** What should a handler return for event types it does not handle?
**A:** Return 200. A non-2xx response tells the provider the delivery failed, so it retries events that will never be processed, and some providers disable an endpoint that keeps failing. Reserve 400 for signature verification failures.
**Q:** What happens to events sent while a deployment was down?
**A:** Providers retry failed deliveries with backoff — Stripe retries for days — so short downtime is usually absorbed. For critical state we also run a periodic reconciliation job that lists recent objects from the provider's API and repairs anything a webhook missed.
**Q:** How is a webhook secret rotated without downtime?
**A:** Verify incoming signatures against both the old and the new secret during the rotation window. Providers like Stripe allow an old signing secret to stay active for an overlap period for exactly this reason.
Further Reading
Full-Stack Engineering
Database Connections in Serverless: Pool Math, PgBouncer Transaction Mode, and the Flags That Survive It
A connection pool in a serverless deployment is per-instance, not per-application, so the real ceiling is concurrent instances multiplied by pool size. Our team's field notes on why PostgreSQL starts rejecting clients, what Fluid Compute changed, and the driver settings that survive transaction-mode pooling.
Full-Stack Engineering
Environment Variables in Next.js: Build-Time Inlining, Real Leak Paths, and Failing the Build with Zod
A NEXT_PUBLIC_ variable is not read at runtime — Next.js inlines it into the bundle at build time. Our field notes on the three ways this bites production apps: stale values that survive redeploys, the real paths a server secret takes to the browser, and a Zod schema that fails the build instead of the first request.