Stripe webhook duplicates and retries in Next.js can grant access twice, restore a cancelled subscription or leave billing state behind Stripe. A reliable handler must verify the raw request, store each event once, respond quickly and process business changes idempotently.
If you are debugging a production failure, check four things first: the endpoint secret, the untouched request body, a unique constraint on the Stripe event ID and whether subscription updates can safely run more than once or arrive out of order.
In my SaaS billing work, I treat a webhook as an untrusted delivery message, not as a command that should immediately run every side effect. The handler must verify where the message came from, store it once, acknowledge it quickly and let a separate processor change product access safely.
This guide shows that design in a Next.js App Router project. It covers raw-body signature verification, idempotency, queues, subscription state, recovery and testing. The goal is not simply to stop webhook errors. It is to make billing behavior explainable when real failure conditions appear.
Why reliable Stripe webhooks in Next.js need more than one route
Stripe documents several delivery behaviors that every production integration must expect. Live webhook deliveries can be retried for up to three days with exponential backoff. Stripe does not guarantee that events arrive in the order they were created, and an endpoint can occasionally receive the same event more than once.
Those rules have direct product consequences. If an invoice.paid event is processed twice, a credit balance could be increased twice. If customer.subscription.updated arrives after a newer cancellation state, careless code could restore access. If the endpoint waits for an email provider or a slow database report, Stripe may see a timeout even though part of the work completed.
A reliable design separates four responsibilities:
- Authenticate delivery: verify the Stripe signature against the untouched request body.
- Accept once: store a durable event record behind a unique constraint.
- Process safely: run repeatable business logic outside the request path.
- Reconcile state: recover from missing, delayed or unexpected events using Stripe's API as the financial source.
Verify the raw request body in a Next.js Route Handler
Stripe signature verification requires the raw request body. Parsing the body as JSON first changes the input used to calculate the signature and causes verification to fail. The current Next.js App Router supports reading a Route Handler request as text without extra body-parser configuration.
The route should also use the webhook signing secret for the exact endpoint and environment. A Stripe CLI secret, sandbox endpoint secret and live endpoint secret are different values. Keep the secret server-side and return a clear 400 response when verification fails without leaking sensitive details.
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const signature = request.headers.get('stripe-signature');
if (!signature) return new Response('Missing signature', { status: 400 });
const rawBody = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response('Invalid webhook signature', { status: 400 });
}
await db.stripeEvent.create({
data: {
stripeEventId: event.id,
eventType: event.type,
payload: event as unknown as object,
status: 'pending'
}
}).catch((error) => {
if (!isUniqueConstraint(error)) throw error;
});
return new Response('accepted', { status: 200 });
}
The important database rule is a unique index on stripeEventId. Two server instances can receive the same delivery at almost the same time, so an application-level query followed by an insert is not enough. The database must decide which insert wins.
Stripe also notes that, in some cases, two separate Event objects can describe the same underlying object and event type. For side effects that must happen only once per business object, add a second business idempotency key such as invoice.paid:in_123. The event ID protects delivery processing; the business key protects the action.
Return quickly, then process through a durable queue
Stripe recommends returning a successful 2xx response before complex logic can time out. That does not mean starting an untracked promise after the response. Serverless runtimes can stop work once the request finishes. The safe pattern is to commit the event to durable storage or a queue before acknowledging it.
For a smaller SaaS, a database-backed inbox can be enough. A worker selects pending rows, marks one as processing, applies the change in a transaction and then records success. A scheduled job can retry failed rows with bounded backoff. Larger systems can publish the verified event to a managed queue, but the core contract stays the same.
I keep webhook receipt and business processing separate because they fail for different reasons. Signature errors are delivery problems. A missing tenant mapping is an application data problem. A temporary database outage is an infrastructure problem. Separate statuses and timestamps make those failures visible instead of reducing everything to a generic 500.
Useful event-inbox fields include:
- Stripe event ID and event type;
- the related Stripe object ID and customer ID;
- received, processing and completed timestamps;
- attempt count and the latest safe error summary;
- the API version associated with the event; and
- a correlation ID for application logs.
Make subscription processing idempotent and order-independent
Idempotency means processing the same message again produces the same final state instead of repeating a side effect. A processed-events table helps, but each handler should also express the desired state rather than blindly toggling it.
For example, invoice.paid can record the invoice as paid and provision access when the associated subscription is active. invoice.payment_failed can mark the billing account as requiring attention and start a customer notification workflow. customer.subscription.deleted can end access according to the product's cancellation policy.
Do not assume those events arrive in a neat sequence. When the local record is missing or the incoming snapshot is not enough to make a safe decision, retrieve the current subscription or invoice from Stripe. Update the tenant with a guarded database transaction, and reject stale transitions when the local record already represents a newer authoritative state.
This is also why I do not grant durable access from the checkout success page. The browser redirect is customer feedback, not financial proof. The user can close the tab, refresh it or call the URL without completing payment. Verified backend events should drive durable entitlements.
For multi-tenant products, keep financial state separate from product permissions. Stripe can say which customer and subscription are paid, while the application decides which organisation, seats and roles receive access. My Stripe per-seat billing guide covers that boundary in more detail.
Choose a small event set with explicit actions
Listening to every Stripe event creates noise and increases the number of payloads the system must store and understand. Stripe recommends subscribing only to events the integration requires. Start with an event-to-action table and make every entry reviewable.
checkout.session.completed: connect a completed Checkout flow to the internal account when that matches the chosen integration.invoice.paid: record successful payment and provision or extend access when the subscription state permits it.invoice.payment_failed: record the failure, notify the customer and follow the configured recovery policy.customer.subscription.updated: synchronize plan, quantity, cancellation timing and status.customer.subscription.deleted: remove or schedule removal of paid access according to the product policy.
Payment security continues beyond the webhook route. Rate limits, entitlement checks and usage controls protect the value unlocked after payment. I explain that wider lifecycle in the Stripe fraud prevention for AI SaaS guide.
Build recovery and reconciliation before an incident
A queue can retry application failures, but it cannot prove that every expected event reached the system. Add a reconciliation job that compares recent Stripe subscriptions and invoices with local billing records. The job should report differences and repair only cases where the rule is safe and deterministic.
Operational alerts should cover more than endpoint uptime. Track signature failures, non-2xx responses, delivery latency, pending inbox age, retry counts, dead-lettered events and mismatches found by reconciliation. An alert saying “webhook failed” is less useful than one showing the event ID, type, tenant mapping and safe error category.
Stripe's Dashboard and CLI can manually resend events for limited periods. A manual resend does not replace idempotency because automatic retries may still happen. The runbook should tell an operator how to inspect the event, confirm whether the business action already happened, replay it safely and record the result.
Test duplicates, disorder and downtime
A webhook test is incomplete if it sends one successful event to a local route. Use the Stripe CLI and a sandbox to exercise the delivery boundary, then add application tests for the state machine.
I would include these cases before launch:
- a valid signed event is stored and acknowledged;
- a missing or invalid signature returns
400without storing the payload; - the same event ID is delivered twice and the business action runs once;
- two different event IDs for the same invoice do not repeat the invoice action;
- subscription events arrive in an unexpected order;
- the database is unavailable before the event is stored, causing a retryable failure;
- business processing fails after receipt and is retried by the worker;
- a canceled or unpaid subscription removes access according to policy; and
- reconciliation detects and reports a deliberately created mismatch.
Keep the webhook route narrow enough that these tests remain understandable. The broader application should still follow a complete Next.js security checklist, including secret management, authorization and safe logging.
Reliable Stripe webhook checklist
- Read the untouched body with
request.text()before parsing anything. - Verify the
stripe-signaturewith the correct endpoint secret. - Store each Stripe event behind a database unique constraint.
- Use a business idempotency key for one-time invoice or entitlement actions.
- Acknowledge only after durable storage or queue publication succeeds.
- Process events asynchronously and track every attempt.
- Do not depend on event delivery order.
- Retrieve the current Stripe object when the incoming event is insufficient.
- Grant access from verified backend state, not the success-page redirect.
- Subscribe only to event types tied to explicit business actions.
- Monitor stuck events and reconcile Stripe against the application database.
- Test duplicates, disorder, downtime and replay before production.
Practical takeaway
Reliable Stripe webhooks in Next.js are not built by adding more code to one POST handler. They come from a small delivery boundary and a durable processing system: verify the raw request, store the event once, return quickly, process idempotently and reconcile the result.
That structure protects both revenue and customer trust. A retry does not create duplicate credit, an out-of-order event does not silently restore the wrong plan, and an operator can see exactly where a billing change stopped.
If you are building Stripe subscriptions, seat-based billing or paid AI features, I can help design the webhook and entitlement flow around your actual product rules. Tell me about your SaaS project, and I will help map a reliable production path.