Stripe Webhooks in Next.js: Idempotency, Out-of-Order Events, and Retries (2026)
By Jake Mcdonald, founder of ShipEmber · November 5, 2026 · About a 10-minute read · Checked against Stripe's webhook docs I'm Jake Mcdonald. Most first Stripe webhook handlers look about the same: verify the signature,
By Jake Mcdonald, founder of ShipEmber · November 5, 2026 · About a 10-minute read · Checked against Stripe's webhook docs
I'm Jake Mcdonald. Most first Stripe webhook handlers look about the same: verify the signature, flip the user to Pro, return 200. Fifteen lines, and it works fine in test mode. Then you read the "Event delivery behaviors" section of Stripe's docs and notice three assumptions hiding in those fifteen lines that aren't true: that each event arrives once, that events arrive in order, and that if your code fails, nothing bad happens.
This post is the version I wish I'd read first. It covers the three delivery behaviors you have to design for in a Next.js App Router route handler (duplicates, out-of-order events, and retries), plus the signature check that has to come before all of it. Everything here was checked against Stripe's webhook docs on November 5, 2026.
The three things Stripe tells you up front
Straight from Stripe's docs, in plain words:
- Duplicates happen. "Webhook endpoints might occasionally receive the same event more than once." Stripe's advice is to log the event IDs you've processed and skip ones you've already handled. In some cases Stripe even generates two separate Event objects for the same thing, so the event ID alone won't catch everything.
-
Order isn't guaranteed. "Stripe doesn't guarantee the delivery of events in the order that they're generated." Creating a subscription can fire
customer.subscription.created,invoice.created,invoice.paid, andcharge.created, and you can receive them in any order. Stripe also says not to use the event'screatedtimestamp to figure out order, because it's in seconds and different events can share it. - Failures get retried. In live mode, Stripe keeps trying to deliver an event for up to three days with exponential backoff. In a sandbox, it retries three times over a few hours. You can also resend events by hand from the Dashboard (up to 15 days after the event) or with the Stripe CLI (up to 30 days).
Put those together and you get the real job of a webhook handler: applying the same event twice, or getting events in the wrong order, has to leave your database in the right state. Everything below is about making that true.
Step 1: Verify the signature on the raw body
Before anything else, prove the request came from Stripe. Without that check, anyone who finds your endpoint URL can POST a fake customer.subscription.updated and give themselves Pro.
Stripe signs every event in the Stripe-Signature header, and its docs are blunt about one thing: verification needs the raw body, exactly as it was sent. If anything parses and re-serializes the JSON first, the signature check fails.
In the App Router this is easy, because a route handler gets a standard Web Request. Read the body with await request.text(), not request.json():
// app/api/stripe/webhook/route.ts (signature check only)
import type Stripe from "stripe"
import { stripe } from "@/lib/stripe"
export const runtime = "nodejs"
export async function POST(request: Request) {
const body = await request.text() // raw body, untouched
const sig = request.headers.get("stripe-signature")
if (!sig) return new Response("Missing signature", { status: 400 })
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch {
return new Response("Bad signature", { status: 400 })
}
// ...handle the event (full route in Step 3)
}
A few things that commonly trip people up:
-
Each endpoint has its own secret. The
whsec_...fromstripe listen, your sandbox endpoint, and your live endpoint are all different. If it works locally and fails in production, check this first. -
Don't turn off the timestamp check. The Stripe libraries reject events whose signed timestamp is more than 5 minutes off by default, which protects you from replayed requests. Stripe's docs specifically say not to set the tolerance to
0, because that disables the check. -
Redirects count as failures. Stripe treats a
3xxresponse as a failed delivery. If your middleware (renamedproxy.tsin Next.js 16) redirects unauthenticated requests to/login, or your domain redirectsexample.comtowww.example.com, your webhook will fail even though your code is fine. Exclude the webhook path from auth middleware and register the final URL. -
If you run on the Edge runtime, use
constructEventAsyncinstead. I keep this route on the Node.js runtime, which is the default for route handlers anyway.
Step 2: An event table that actually prevents double processing
The usual idempotency advice is "store event.id and skip events you've seen." That's right, but the obvious way to write it has a gap. In my own setup checklist, I showed the simple version: look up the event ID, do the work, then insert the ID. If two deliveries of the same event land at the same time, both lookups can come back empty and both do the work. For "set plan to Pro" that's harmless. For "send a welcome email" or "add 100 credits," it isn't.
The fix is to let the database decide. Make the Stripe event ID a primary key and claim the event inside the same transaction as the work:
model StripeEvent {
id String @id // the evt_... ID from Stripe
type String
createdAt DateTime @default(now())
}
// lib/stripe-events.ts
import type Stripe from "stripe"
import type { Prisma } from "@prisma/client"
import { db } from "@/lib/db"
export async function processOnce(
event: Stripe.Event,
work: (tx: Prisma.TransactionClient) => Promise<void>,
) {
return db.$transaction(async (tx) => {
// Postgres: INSERT ... ON CONFLICT DO NOTHING
const claimed = await tx.stripeEvent.createMany({
data: [{ id: event.id, type: event.type }],
skipDuplicates: true,
})
if (claimed.count === 0) return "duplicate" as const
await work(tx)
return "processed" as const
})
}
Why this works:
- If the event was already processed, the insert does nothing,
countis0, and you skip the work. - If two deliveries race, Postgres makes the second insert wait for the first transaction. If the first commits, the second sees a conflict and skips. If the first fails and rolls back, the second one claims the event and does the work.
- If your work throws, the whole transaction rolls back, including the event row. So a failed event isn't marked as handled, and Stripe's retry gets a real second chance.
That last point is the one people miss. If you record the event ID first and commit it separately, a crash halfway through leaves you with an event you've "seen" but never actually applied, and every retry will skip it.
skipDuplicates isn't supported on every database Prisma talks to, so check your provider. On Postgres (which is what I use) it's fine.
Step 3: Make out-of-order events boring
Here's the bug out-of-order delivery causes. A customer upgrades, then cancels a minute later. You receive the cancel's customer.subscription.updated first and the upgrade's second. If your handler copies event.data.object straight into your database, the stale event wins and they're back on an active plan they canceled.
You could compare timestamps, but Stripe's docs say not to use created for ordering. What I do instead is treat the event as a notification, not the data. When anything subscription-related arrives, I fetch the subscription's current state from Stripe and write that. It doesn't matter which event shows up first, because both handlers end up writing whatever is true right now. Stripe's docs suggest this too: use the API to retrieve the objects you need.
Here's the whole route, with the signature check from Step 1 and processOnce from Step 2:
// app/api/stripe/webhook/route.ts
import type Stripe from "stripe"
import { stripe } from "@/lib/stripe"
import { processOnce } from "@/lib/stripe-events"
export const runtime = "nodejs"
function subscriptionIdFrom(event: Stripe.Event): string | null {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object
if (session.mode !== "subscription" || !session.subscription) return null
return typeof session.subscription === "string"
? session.subscription
: session.subscription.id
}
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
return event.data.object.id
default:
return null
}
}
export async function POST(request: Request) {
const body = await request.text()
const sig = request.headers.get("stripe-signature")
if (!sig) return new Response("Missing signature", { status: 400 })
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch {
return new Response("Bad signature", { status: 400 })
}
const subscriptionId = subscriptionIdFrom(event)
if (!subscriptionId) return new Response("ignored", { status: 200 })
try {
// Fetch current state *before* opening the DB transaction,
// so a slow network call doesn't hold a transaction open.
const sub = await stripe.subscriptions.retrieve(subscriptionId)
const customerId =
typeof sub.customer === "string" ? sub.customer : sub.customer.id
const isPro = sub.status === "active" || sub.status === "trialing"
await processOnce(event, async (tx) => {
await tx.user.update({
where: { stripeCustomerId: customerId },
data: {
plan: isPro ? "pro" : "free",
stripeSubscriptionId: sub.id,
subscriptionStatus: sub.status,
cancelsAt: sub.cancel_at ? new Date(sub.cancel_at * 1000) : null,
},
})
})
} catch (err) {
console.error("Stripe webhook failed", event.id, err)
return new Response("Try again later", { status: 500 })
}
return new Response("ok", { status: 200 })
}
A few notes on this:
-
The work is "set," never "add." Writing
plan: "pro"twice gives the same result as writing it once. That's what makes the "two separate Event objects" case from Stripe's docs harmless too. Anything that increments, sends email, or calls another API needs to go throughprocessOnceand nothing else. -
A missing user is a retry, not a crash. I create the Stripe customer before Checkout and save its ID on the user (with
stripeCustomerIdmarked@unique), so every event can be matched by customer ID. If an event somehow arrives before that row exists,updatethrows, the route returns 500, and Stripe tries again later. That's what you want. -
Retrieving a canceled subscription still works, so
customer.subscription.deletedgoes through the same path and lands the user on Free. - Whether
past_duekeeps Pro is a product decision. I treat onlyactiveandtrialingas Pro, but a short grace period is reasonable. - Two different events for the same subscription can still run at nearly the same time. Both fetch fresh state from Stripe, so for a Free/Pro app the last write is fine. If you're doing something heavier, lock the user row.
- Only subscribe your endpoint to the event types you actually handle. Stripe recommends this too, and it keeps the noise down.
This post uses snapshot events (the Stripe.Event objects constructEvent returns). Stripe's docs also describe newer "thin" events. I'm not covering those here, but the duplicate and ordering rules above still apply.
Step 4: Return 2xx quickly, but not too early
Stripe's docs say your endpoint should "quickly return a successful status code (2xx) before any complex logic that could cause a timeout." That's easy to misread as "always return 200 right away, then do the work."
Here's the trap. Once you return 2xx, Stripe considers the event delivered and stops retrying. If you return 200 and then do the real work afterward, for example in Next.js's after() (stable since 15.1), a database error at that point is silent. Stripe won't retry, and that customer stays on Free even though they paid. You've thrown away three days of automatic retries.
So I think of it like this:
- Small, fast work (most Free/Pro apps): do it inline like the route above, a single Stripe read and one short transaction. Return 200 only after it commits. Return 500 if it fails so Stripe retries.
-
Slow or heavy work (generating files, calling several APIs, sending lots of email): inside the request, only verify the signature and durably save the event, for example a row with a
processedAtcolumn left empty, or a message on a real queue. Then return 200, and process it in a background job that can retry on its own. Stripe recommends an async queue for exactly this, especially for spikes like the start of the month when renewals all hit at once. -
after()is fine for extras you can afford to lose, like logging or analytics. It isn't a substitute for a durable queue.
Step 5: Prove it before real customers do
Here's the test run I'd do in a sandbox before launch:
- Run
stripe listen --forward-to localhost:3000/api/stripe/webhook, put thewhsec_...it prints in your local env, and subscribe with a test card. Confirm the user becomes Pro and oneStripeEventrow appears per event. -
Duplicates: deploy to a sandbox endpoint, then resend one of those events from the event's page in the Dashboard (or
stripe events resend <event_id> --webhook-endpoint=<endpoint_id>). The second delivery should return 200, and nothing else in your database should change. - Failures: make the handler throw on purpose (for example, point it at a user that doesn't exist) and check the endpoint's Event deliveries tab. You should see failed attempts and a scheduled retry. Remember that sandbox retries only happen three times over a few hours, while live mode retries for up to three days.
-
Order: cancel and reactivate quickly in the customer portal. Whatever order the
customer.subscription.updatedevents land in, the final row should match what the Dashboard shows. -
Bad signature:
curl -X POSTyour endpoint with a junk body. You should get a 400 and no database change. - Do a real purchase in live mode with your own card and refund it. The live endpoint has its own signing secret, and that's the one people forget.
Why choose ShipEmber
None of this is secret knowledge. It's all in Stripe's docs, and if you want to own every line, this post should get you there. I built ShipEmber because I got tired of rebuilding this same webhook, plan sync, and gating loop for every project.
ShipEmber is a Next.js App Router starter that already has it working: Auth.js sign-in (Google, GitHub, magic links), Stripe Checkout, a customer portal route, signed and idempotent webhooks that keep Free/Pro in sync, Prisma with Postgres, and a dashboard gated on the server. You rename it, add your Stripe keys, and spend the weekend on the thing your customers actually pay for.
To be honest about where it doesn't fit: if you need multi-tenant teams, organizations, and per-seat billing from day one, a bigger kit will be a better fit. ShipEmber is deliberately small, built for one product with a Free plan and a Pro plan.
It's a one-time payment, $49 right now on the site, and you can use it in unlimited personal and client projects. You get 14 days to ask for a refund if it doesn't work as described.
Prefer watching over reading? There's a short walkthrough:
Watch the ShipEmber walkthrough on YouTube
See pricing or Get ShipEmber, $49.
Related: Next.js + Stripe + Auth.js setup checklist · Stripe Customer Portal in Next.js · How to add Stripe subscriptions to a Next.js App Router app
— Jake Mcdonald, Springfield, IL
Originally published on shipember.com.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.