Hardening Stripe Webhooks in Next.js: What the Docs Don't Tell You
Hardening Stripe Webhooks in Next.js: What the Docs Don't Tell You A production build log covering the raw-body parsing problem that breaks Stripe signature verification in Next.js App Router, the idempotency pattern that prevents...
Primary Focus
webAI Tools Covered
What You'll Learn
- ✓Next.js App Router Breaks Stripe Signature Verification by Default
- ✓Disable the App Router Body Size Limit
- ✓Stripe Will Retry, and That Means Double Execution Risk
- ✓Mark Events as Processed Before the Side Effects, Not After
- ✓Your Webhook Endpoint Is a Public Write Interface
- ✓Validate the Event Type Before Acting
Guide Curriculum
The Raw Body Problem
Learn key concepts
- •Next.js App Router Breaks Stripe Signature Verification by Default1m
- •Disable the App Router Body Size Limit1m
Idempotency — Handling Stripe's Retry Behavior
Learn key concepts
- •Stripe Will Retry, and That Means Double Execution Risk1m
- •Mark Events as Processed Before the Side Effects, Not After1m
Security Headers and Endpoint Protection
Learn key concepts
- •Your Webhook Endpoint Is a Public Write Interface1m
- •Validate the Event Type Before Acting1m
Observability and Testing
Learn key concepts
- •Log the Stripe Event ID First, Always1m
- •Use Stripe CLI for Local Testing, Not Tunnels1m
Preview: First Lesson
The Raw Body Problem
Next.js App Router Breaks Stripe Signature Verification by Default
The most common Stripe webhook integration bug in Next.js App Router is a signature verification failure that appears to be random. It is not random — it is caused by body parsing.
Stripe signs its webhook payloads and sends a Stripe-Signature header. To verify the signature, you must reconstruct the HMAC using the exact raw bytes Stripe sent. If anything in your stack parses the body before your handler reads it — JSON middleware, a body parser, Next.js's own request handling — the reconstructed payload differs from what Stripe signed and verification fails.
In Next.js App Router, Request.json() returns a parsed object. Request.text() returns the decoded string. Neither gives you the original bytes. The correct approach:
// app/api/webhooks/stripe/route.ts export async function POST(request: Request) { const rawBody = await request.text() const sig = request.headers.get('stripe-signature')! let event: Stripe.Event try { event = stripe.webhooks.constructEvent( rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET! ) } catch (err) { return new Response(`Webhook signature verification failed`, { status: 400 }) } // ... }
Read the body as text, not as JSON. constructEvent handles the parsing internally after it verifies the signature. The order matters: verify first, parse after.
Start learning with this comprehensive guide
This guide includes:
About the Author
Hiram Clark is the founder and managing editor of vybecoding.ai and sets editorial direction for the guides and news published here. Articles are drafted with AI assistance and edited before publication. He works hands-on with the AI development tools, workflows, and infrastructure covered on the site.
Full Guide Content
Complete lesson text — start the interactive course above for exercises and progress tracking.
Module 1The Raw Body Problem
1.1Next.js App Router Breaks Stripe Signature Verification by Default
The most common Stripe webhook integration bug in Next.js App Router is a signature verification failure that appears to be random. It is not random — it is caused by body parsing.
Stripe signs its webhook payloads and sends a Stripe-Signature header. To verify the signature, you must reconstruct the HMAC using the exact raw bytes Stripe sent. If anything in your stack parses the body before your handler reads it — JSON middleware, a body parser, Next.js's own request handling — the reconstructed payload differs from what Stripe signed and verification fails.
In Next.js App Router, Request.json() returns a parsed object. Request.text() returns the decoded string. Neither gives you the original bytes. The correct approach:
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const rawBody = await request.text()
const sig = request.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response(`Webhook signature verification failed`, { status: 400 })
}
// ...
}
Read the body as text, not as JSON. constructEvent handles the parsing internally after it verifies the signature. The order matters: verify first, parse after.
1.2Disable the App Router Body Size Limit
Next.js App Router has a default request body size limit. Stripe payloads are small, but if you ever handle Stripe Connect events or large metadata payloads, you can hit it. Disable the limit explicitly on the webhook route:
export const config = {
api: {
bodyParser: false, // Pages Router legacy — no-op in App Router
},
}
In App Router this config block is a no-op (body parsing works differently), but documenting it prevents a future developer from adding a body parser that breaks everything. More concretely: if you are using any middleware that reads and re-emits request bodies, your webhook handler must run before it.
Module 2Idempotency — Handling Stripe's Retry Behavior
2.1Stripe Will Retry, and That Means Double Execution Risk
Stripe retries webhook delivery if your endpoint returns a non-2xx response or times out. The retry schedule is aggressive at first and extends to 72 hours. This means a single charge event can be delivered 15+ times if your endpoint is flaky.
Without idempotency guards, this means double charges, double fulfillment, or double credits. Every side effect in a webhook handler — creating a subscription record, adding credits, sending a confirmation email — must be idempotent.
The pattern we use: before processing any event, check whether its event.id has already been handled:
const existing = await convex.query(api.stripe.getProcessedEvent, { stripeEventId: event.id })
if (existing) {
return new Response('Already processed', { status: 200 })
}
Return 200 on a duplicate. Returning a non-2xx would cause Stripe to retry again, creating a loop. Acknowledge it, skip the work.
2.2Mark Events as Processed Before the Side Effects, Not After
There is a subtle ordering issue in the idempotency check. If you record the event as processed after running the side effects, a crash between the side effect and the record-write creates a window where the event will be processed again on retry.
The safer pattern: write a "processing started" record before the side effects, and update it to "completed" afterward. If you receive a duplicate while processing is in flight, the "processing started" record is present and you can skip or wait. This does not eliminate all races in a distributed system, but it eliminates the most common one.
Module 3Security Headers and Endpoint Protection
3.1Your Webhook Endpoint Is a Public Write Interface
A webhook endpoint accepts POST requests from the internet. The signature check is your primary defense, but hardening the endpoint beyond that is cheap and worthwhile.
Headers to set on the response (not the check, since Stripe controls the request):
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
})
More importantly: ensure the endpoint is excluded from your CSRF protection middleware. CSRF tokens work for browser-initiated requests; they break webhook delivery. In Next.js, your middleware matcher should explicitly exclude /api/webhooks/*.
3.2Validate the Event Type Before Acting
Stripe sends many event types. Your handler probably cares about three or four. Validate that the event type is one you handle before doing any work:
const HANDLED_EVENTS = new Set([
'checkout.session.completed',
'customer.subscription.updated',
'customer.subscription.deleted',
'invoice.payment_failed',
])
if (!HANDLED_EVENTS.has(event.type)) {
return new Response('Unhandled event type', { status: 200 })
}
Return 200 for unhandled types — not 400. Stripe will retry on 4xx responses. Silently acknowledging unknown event types prevents retry storms when Stripe adds new event types to your account.
Module 4Observability and Testing
4.1Log the Stripe Event ID First, Always
The first line inside your verified handler should log the Stripe event ID:
logger.info('stripe_webhook', { eventId: event.id, type: event.type })
When something goes wrong — double charge, missed fulfillment, Stripe showing a delivery failure — the event ID is your lookup key across Stripe's dashboard, your own logs, and your database records. Without it, correlating a user complaint to a specific webhook delivery is painful. With it, you can trace the entire lifecycle in under a minute.
4.2Use Stripe CLI for Local Testing, Not Tunnels
The standard advice is to use ngrok or a similar tunnel to expose a local port to Stripe. The Stripe CLI is better: it proxies events to your local server without exposing a public endpoint, supports event filtering, and can replay specific events from your Stripe account.
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Replay a specific past event when debugging:
stripe events resend evt_1abc... --webhook-endpoint=we_1xyz...
The replay command is particularly useful for debugging idempotency: send the same event twice to your local handler and confirm the second delivery returns 200 without re-running the side effects.