# What You'll Build#
This guide shows a production-ready implementation of Next.js Stripe webhooks using the App Router. The focus is reliability first: strict signature verification, raw body handling, idempotency, safe retries, and strategies for event ordering.
You’ll also get copy-pasteable code, Stripe CLI local testing, and a troubleshooting checklist for common failure modes.
If you’re building subscriptions and SaaS billing, pair this with our deeper billing guide: Next.js Stripe subscriptions for SaaS billing.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| Next.js | 14 or 15 | App Router assumed |
| Node.js | 18 or 20 | LTS recommended |
| Stripe account | Any | You need a webhook signing secret |
| stripe npm package | 14+ | Use the official SDK |
| Database | Postgres recommended | For idempotency and state updates |
| Stripe CLI | Latest | For local webhook testing |
You should also be comfortable with basic API routes and database writes.
ℹ️ Note: Webhooks are a security boundary. Treat them like a public endpoint and follow hardening best practices from our web application security checklist.
# Architecture: Reliability-First Webhook Design#
A webhook handler has two jobs: prove the request is from Stripe and apply side effects safely.
A robust architecture typically looks like this:
- 1Verify Stripe signature using the raw request body.
- 2Enforce idempotency using
event.idas a unique key. - 3Persist the event and minimal data required for auditing and retries.
- 4Apply changes in a transaction, or enqueue work to background processing.
- 5Return
200quickly so Stripe stops retrying.
Why this matters: Stripe will retry failed deliveries for up to several days, and retries are not rare in real systems. You want retries to be safe and boring.
Stripe retry and ordering realities#
Stripe does not guarantee ordering across event types, and retries can cause older events to arrive after newer ones. If your code assumes order, you will eventually corrupt billing state.
Design principles that prevent data corruption:
- Use database constraints for idempotency.
- Prefer updating state from Stripe object fields that represent current state, not just “delta” assumptions.
- When order matters, compare
event.createdand ignore stale updates.
🎯 Key Takeaway: Treat every webhook delivery as “maybe duplicated” and “maybe out of order”.
# Step 1: Install Stripe and set environment variables#
Install Stripe:
npm i stripeAdd environment variables:
| Variable | Example | Where |
|---|---|---|
STRIPE_SECRET_KEY | sk_live_... | Server runtime only |
STRIPE_WEBHOOK_SECRET | whsec_... | Webhook endpoint secret |
STRIPE_API_VERSION | 2024-06-20 | Optional but recommended |
In Next.js, keep these in .env.local for development and in your hosting provider secrets for production.
💡 Tip: Pin a Stripe API version explicitly. It reduces unexpected payload changes when Stripe upgrades defaults.
# Step 2: Create the webhook route in the App Router#
Create app/api/stripe/webhook/route.ts.
Key reliability decisions:
- Force Node.js runtime.
- Read the raw body as bytes.
- Verify signature with
stripe.webhooks.constructEvent.
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: (process.env.STRIPE_API_VERSION as any) || "2024-06-20",
});
export async function POST(req: Request) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) return new Response("Missing webhook secret", { status: 500 });
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing stripe-signature", { status: 400 });
const rawBody = Buffer.from(await req.arrayBuffer());
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return new Response(`Webhook signature verification failed: ${message}`, { status: 400 });
}
// Acknowledge only after durable processing decisions are made.
// We'll implement idempotency + processing next.
return new Response("OK", { status: 200 });
}Why raw bytes matter#
Stripe signs the exact payload it sends. If you parse JSON first and re-serialize, whitespace and ordering can change and signature verification will fail.
Using req.arrayBuffer() keeps it exact.
⚠️ Warning: Do not call
req.json()before verification. Once you read the body stream, you cannot read it again, and verification will fail.
# Step 3: Add idempotency with an events table#
Stripe retries on non-200 responses, timeouts, network failures, and transient errors. If your handler applies side effects twice, you can grant duplicate entitlements, double-send emails, or incorrectly extend subscriptions.
The simplest robust pattern is:
- Insert the Stripe event ID in a table with a unique constraint.
- If insert fails due to conflict, return
200immediately.
A minimal schema:
| Column | Type | Why it exists |
|---|---|---|
id | text primary key | Store event.id and enforce uniqueness |
type | text | Debugging and filtering |
created | integer | Stripe event created timestamp |
livemode | boolean | Separate test and live auditing |
payload | jsonb | Optional, for audit and reprocessing |
processed_at | timestamptz | Track completion |
processing_error | text | Track failures |
Example SQL for Postgres:
CREATE TABLE stripe_events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
created INTEGER NOT NULL,
livemode BOOLEAN NOT NULL,
payload JSONB,
processed_at TIMESTAMPTZ,
processing_error TEXT
);In code, implement a “try insert” step. Below is pseudo-DB code you can adapt to Prisma, Drizzle, or raw SQL.
async function insertStripeEventOnce(event: Stripe.Event) {
// Return true if inserted, false if already exists
// Implement using your DB with a unique constraint on id
return true;
}A concrete example using Postgres-style conflict handling:
async function insertStripeEventOnce(event: Stripe.Event) {
const res = await db.query(
`INSERT INTO stripe_events (id, type, created, livemode, payload)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO NOTHING`,
[event.id, event.type, event.created, event.livemode, event]
);
// Some clients return rowCount; adapt for your DB driver
return res.rowCount === 1;
}# Step 4: Process only the events you need, safely#
Stripe emits many event types. Don’t process everything. Decide which events are authoritative for your business state and ignore the rest.
Common “must handle” events for SaaS billing:
| Use case | Recommended events | Notes |
|---|---|---|
| Subscription state | customer.subscription.created, customer.subscription.updated, customer.subscription.deleted | Treat these as the source of truth |
| Checkout completion | checkout.session.completed | Often used to link user to customer |
| Invoice lifecycle | invoice.paid, invoice.payment_failed | Useful for access gating and dunning |
| Disputes | charge.dispute.created | Compliance and risk workflows |
Implement a processing switch with defensive parsing#
Stripe payload shapes vary by event type. Use type narrowing carefully and treat unknown shapes as non-fatal.
function isStripeObject<T extends Stripe.Event.Data.Object>(obj: any): obj is T {
return obj && typeof obj === "object" && typeof obj.object === "string";
}A processing skeleton:
async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await onCheckoutSessionCompleted(event, session);
break;
}
case "customer.subscription.updated":
case "customer.subscription.created":
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await onSubscriptionChanged(event, sub);
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
await onInvoicePaid(event, invoice);
break;
}
default:
// Ignore unneeded events, but keep them recorded for audit if you store payloads
break;
}
}# Step 5: Make processing durable and fast#
Stripe expects a timely response. If you do slow work inside the webhook, you increase timeouts and retries.
A reliability-first pattern is:
- Insert event record.
- Do a small transaction that updates core state.
- Return
200. - Offload expensive work to a job queue or background worker.
If you run on Vercel, background jobs and queues matter a lot. See: Next.js background jobs, queues, and cron on Vercel.
Full route example with idempotency and safe completion tracking#
This example returns 200 for duplicates and records processing errors for visibility.
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: (process.env.STRIPE_API_VERSION as any) || "2024-06-20",
});
export async function POST(req: Request) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
const signature = req.headers.get("stripe-signature");
if (!webhookSecret) return new Response("Missing STRIPE_WEBHOOK_SECRET", { status: 500 });
if (!signature) return new Response("Missing stripe-signature", { status: 400 });
const rawBody = Buffer.from(await req.arrayBuffer());
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return new Response(`Invalid signature: ${message}`, { status: 400 });
}
const inserted = await insertStripeEventOnce(event);
if (!inserted) {
// Duplicate delivery. Safe to acknowledge.
return new Response("Duplicate", { status: 200 });
}
try {
await handleStripeEvent(event);
await markStripeEventProcessed(event.id);
return new Response("OK", { status: 200 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
await markStripeEventFailed(event.id, message);
// Returning 500 asks Stripe to retry. Only do this for transient errors.
return new Response("Webhook processing failed", { status: 500 });
}
}
// Implement these with your DB layer
async function insertStripeEventOnce(event: Stripe.Event) {
return true;
}
async function markStripeEventProcessed(eventId: string) {}
async function markStripeEventFailed(eventId: string, message: string) {}
async function handleStripeEvent(event: Stripe.Event) {}⚠️ Warning: Returning
500for permanent errors can create retry storms. Only return500for errors you expect could succeed on retry, like temporary database downtime.
# Step 6: Handle event ordering explicitly#
Two ordering problems show up in production:
- 1Out-of-order subscription updates: An older
customer.subscription.updatedarrives after a newer one. - 2Race conditions between events: For example,
invoice.paidarrives before your system has linked the Stripe customer to your internal user.
Strategy A: store a “last applied Stripe event timestamp” per entity#
When updating subscription state, store the last processed event.created for that subscription ID. Only apply changes if the incoming event is newer.
| Field | Where | Purpose |
|---|---|---|
stripe_subscription_id | subscriptions table | Entity key |
last_stripe_event_created | subscriptions table | Guard against stale events |
Pseudo-logic:
async function applySubscriptionUpdateSafely(event: Stripe.Event, sub: Stripe.Subscription) {
const incoming = event.created;
const current = await db.getSubscriptionByStripeId(sub.id);
if (current && current.lastStripeEventCreated && incoming <= current.lastStripeEventCreated) {
return; // stale, ignore
}
await db.upsertSubscriptionFromStripe(sub, incoming);
}Strategy B: prefer “source of truth” objects over assumptions#
Instead of “add one month to access”, set access based on Stripe’s current state:
- Subscription status
- Current period end
- Cancel at period end
- Price ID and quantity
This makes retries and reordering much less dangerous.
# Step 7: Idempotency for downstream side effects#
Even if the webhook event itself is idempotent, side effects might not be.
Common side effects:
| Side effect | Risk | Fix |
|---|---|---|
| Provisioning features | Double provisioning | Unique constraints or “already provisioned” flags |
| Sending emails | Duplicate emails | Email outbox table keyed by event.id |
| Posting to Slack | Spam | De-dup based on event.id |
A practical pattern is an outbox table:
- Write outbox records in the same DB transaction as state updates.
- A background worker delivers them once.
This reduces your webhook’s complexity and keeps response times low.
# Step 8: Local testing with Stripe CLI#
Stripe CLI makes webhook testing predictable and fast.
Install and authenticate:
stripe loginForward webhooks to your local Next.js server:
stripe listen --forward-to http://localhost:3000/api/stripe/webhookThe CLI prints a webhook signing secret for that session. Put it in .env.local as STRIPE_WEBHOOK_SECRET.
Trigger common events:
stripe trigger checkout.session.completedMore useful triggers for subscription flows:
stripe trigger customer.subscription.created
stripe trigger invoice.paid
stripe trigger invoice.payment_failed💡 Tip: Keep one terminal running
stripe listenand another runningnpm run dev. When signature verification fails, the CLI output plus your server logs usually reveal the mismatch immediately.
# Step 9: Production configuration checklist#
Webhooks often fail in production due to deployment differences, not code.
Hosting and runtime#
| Concern | Recommendation | Why |
|---|---|---|
| Runtime | Node.js | Most predictable raw body handling |
| Timeouts | Keep handler under a few seconds | Reduces retries |
| Logging | Log event ID and type | Fast incident response |
| DB connectivity | Use pooled connections | Avoid connection storms on retries |
Security basics#
- Only accept
POST. - Verify Stripe signature for every request.
- Do not expose secret keys to the client.
- Consider rate limiting, but be careful not to block Stripe IP ranges by mistake.
For a broader security pass, use: web application security checklist.
# Troubleshooting: Common pitfalls and fixes#
Signature verification fails intermittently#
Typical causes:
| Symptom | Likely cause | Fix |
|---|---|---|
| Always failing locally | Wrong STRIPE_WEBHOOK_SECRET | Copy from Stripe CLI or Dashboard endpoint |
| Failing only in prod | Different endpoint secret per environment | Use separate secrets for test and live |
| Failing after code refactor | Body parsed before verification | Use req.arrayBuffer() and verify first |
Also confirm you are forwarding to the correct path. In the App Router, the file path maps directly to the route URL.
Webhook works locally but not on Vercel#
Common reasons:
- The route runs on Edge runtime and raw body handling differs.
- Missing environment variables in production.
- Database connection limits hit during retry storms.
Fixes:
- Ensure
export const runtime = "nodejs"; - Verify
STRIPE_WEBHOOK_SECRETis set in the deployment environment. - Add idempotency and fast responses so retries don’t multiply load.
Duplicate grants or duplicated emails#
If you see duplicated side effects, one of these is missing:
- Event ID uniqueness enforcement
- Side-effect idempotency keys
Fix:
- Enforce uniqueness on
stripe_events.id. - Add outbox tables keyed by
event.idfor notifications.
Wrong subscription state in your DB#
If your subscription table flips between states, it’s usually event ordering plus “delta” logic.
Fix:
- Track and compare
event.createdto ignore stale updates. - Update state from Stripe subscription fields as the source of truth.
Stripe keeps retrying for hours#
Stripe retries when your endpoint returns non-200 or times out.
Fix:
- Return
200for duplicates. - Only return
500for transient errors. - If the error is permanent, record it and return
200to stop retries, then alert your team.
ℹ️ Note: A “permanent error” is something like “user not found for customer ID” that won’t fix itself on retry. Record it for manual remediation instead of making Stripe hammer your endpoint.
# Key Takeaways#
- Verify Stripe signatures using the raw request body bytes via
req.arrayBuffer()andstripe.webhooks.constructEvent. - Enforce idempotency with a database table keyed by
event.id, then return200for duplicates. - Design for retries and out-of-order delivery by comparing
event.createdand using Stripe objects as the source of truth. - Keep the webhook handler fast and durable, and offload slow side effects to background jobs or an outbox pattern.
- Use Stripe CLI to test locally with
stripe listenandstripe trigger, and validate environment-specific webhook secrets.
# Conclusion#
A reliable Next.js Stripe webhooks implementation is less about “handling an event” and more about guaranteeing correctness under retries, duplication, and reordering. If you implement raw-body signature verification, database-backed idempotency, and ordering guards, your billing system becomes dramatically harder to break.
If you want Samioda to implement or audit your Next.js Stripe webhooks, subscription flows, and background processing, reach out via our website and we’ll help you ship a production-grade billing pipeline.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →Feature Gating in Next.js (App Router): Permissions, Entitlements, and Billing-Aware Access
Learn Next.js feature gating patterns for App Router: gate UI, server actions, and APIs by role, plan, entitlements, and Stripe subscription status with consistent checks.
Supabase RLS + Next.js App Router: End-to-End Authorization Without Footguns
A practical 2026 guide to Supabase RLS with Next.js App Router — multi-tenant policy design, secure server enforcement patterns, and a checklist to avoid service-role leaks, unsafe RPC, and client-side filtering.
React Error Handling in 2026: Error Boundaries, Retries, and UX Patterns That Scale
A practical layered guide to React error handling patterns in 2026: component and route error boundaries, TanStack Query and SWR retries, and UX messaging that scales across teams.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Implementing Stripe Subscriptions in Next.js: Billing Portal, Webhooks, and Entitlements
A production-ready guide to Next.js Stripe subscriptions: plans and trials, Billing Portal, webhook verification, idempotent processing, entitlement mapping, and Stripe CLI testing.
Next.js SaaS Onboarding Checklist: Accounts, Permissions, Emails, and Trials (App Router, 2026)
A production-ready Next.js SaaS onboarding checklist covering authentication, organizations, invites, RBAC, transactional emails, and trial-to-paid conversion with practical patterns, libraries, and pitfalls.
Next.js + Supabase SaaS Starter Architecture (App Router): Auth, RLS, Billing, and Multi-Tenancy
A production-ready blueprint for a Next.js App Router + Supabase SaaS starter architecture: auth, Postgres data model, RLS policies, Stripe billing, and multi-tenant organization design with concrete examples.