How to Receive Webhooks in Next.js (App Router)
A Next.js webhook handler is about eight lines, and two of them are usually wrong. Raw bodies, signature checks, and the serverless timeout that quietly eats your events.
Jason Warner
July 21, 2026
How to Receive Webhooks in Next.js (App Router)
The first webhook handler I wrote in Next.js took about four minutes. The second one, the one that actually worked in production, took most of a day.
The gap between those two is worth explaining, because almost everyone hits the same two problems in the same order.
The Four-Minute Version
With the App Router, a webhook endpoint is a POST export in app/api/webhooks/route.ts:
// app/api/webhooks/route.ts
export async function POST(req: Request) {
const payload = await req.json();
console.log('event', payload.type);
return new Response('ok', { status: 200 });
}
Events show up. You feel good. Then you go to add signature verification, and nothing you do makes the signature match.
Problem One: req.json() Destroys the Evidence
Providers sign the exact bytes they sent you. Not the meaning of the JSON — the bytes.
So when you call req.json(), parse it into an object, and then re-serialize that object to check the signature, you are hashing a different string. Key order shifted. Whitespace vanished. A float got normalized. The payload is semantically identical and the hash is completely different, which is why the error message "invalid signature" sends people looking for a wrong secret when their secret was fine all along.
Read the body as text first. Verify against that string. Parse afterward.
import crypto from 'crypto';
export async function POST(req: Request) {
const raw = await req.text(); // the actual bytes
const signature = req.headers.get('x-signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(raw)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response('bad signature', { status: 401 });
}
const payload = JSON.parse(raw);
return new Response('ok', { status: 200 });
}
Two details in there that people skip. Use timingSafeEqual instead of ===, because a normal string comparison bails at the first wrong byte and the timing difference leaks how much of the signature you got right. And check the lengths yourself first, because timingSafeEqual throws on mismatched buffers rather than returning false — which turns a forged signature into a 500 instead of a 401.
Also worth knowing: if you're on the Edge runtime, crypto isn't there. Either pin the route to Node with export const runtime = 'nodejs' or rewrite the HMAC using Web Crypto. Stripe's header format has its own timestamp-and-version scheme on top of all this; I walked through it in verifying Stripe webhook signatures.
Problem Two: You Have Less Time Than You Think
This one only shows up in production, which is what makes it annoying.
Most providers wait somewhere around ten seconds for your 200. Your serverless function has its own timeout, and on some plans it's shorter than that. Now consider a handler like this:
export async function POST(req: Request) {
const payload = await req.json();
await syncToCrm(payload); // 8s
await generateThumbnail(payload); // 6s
return new Response('ok'); // nobody is listening anymore
}
Fourteen seconds of work. The sender gave up at ten, logged a failed delivery, and scheduled a retry. Your function, meanwhile, finished the job perfectly. Then the retry lands and does all of it a second time.
The fix is to make the handler's only job be "accept and acknowledge":
export async function POST(req: Request) {
const raw = await req.text();
if (!verify(raw, req.headers)) return new Response('bad signature', { status: 401 });
await queue.enqueue(raw); // durable, fast
return new Response('ok', { status: 200 });
}
Verify, store, return. Everything else belongs in a worker.
Duplicates Are Normal, Not a Bug
Once retries are in play, the same event will arrive twice. That's not a provider malfunction — it's the contract. Every delivery is at-least-once, so store the event ID and check it:
const seen = await db.processedEvents.findUnique({ where: { eventId: payload.id } });
if (seen) return new Response('ok', { status: 200 });
Return 200 on the duplicate. It's tempting to return an error because you didn't do anything, but a 4xx or 5xx tells the sender to try again, and now you're in a loop. There's more on getting this right in idempotent webhook handling.
What a Correct Handler Still Can't Do
Here's the uncomfortable part. You can get every line above right and still lose events.
Your build broke for eleven minutes and every delivery in that window got a 500. Whether you lose them depends entirely on how long that particular provider retries, and you don't control that. Worse, you have no record of what was sent, so when someone asks what happened to Tuesday's orders, you genuinely cannot answer. And none of this is testable locally, because localhost isn't reachable from the internet — see forwarding webhooks to localhost for the workarounds there.
That's the case for not letting your Next.js app be the first thing that touches the event. Point the provider at Bluejay Relay instead. It captures the event, keeps the payload and headers permanently, and delivers to your route handler with retries when your app is having a bad deploy. When you eventually ship the fix for that bug you had on Tuesday, you filter the log by date and replay the affected events rather than emailing support to ask nicely.
Your route handler stays the eight lines above. It just stops being the only thing standing between you and a dropped event.
Stop losing webhooks to deploys and timeouts. Try Bluejay Relay free — capture, retry, and replay every event.
Receive webhooks without a server.
Capture, store, and forward webhooks from a hosted intake URL — nothing to deploy or keep running. Free to start.