Stripe webhooks in Next.js: handle duplicate and out-of-order events
By VibeBoot ·
Short on time? The SaaS Starter already has this wired up: $49 one-time, 14-day money-back guarantee.
Stripe is upfront about two things most AI-generated webhook handlers ignore. An endpoint "might occasionally receive the same event more than once", and Stripe "doesn't guarantee the delivery of events in the order that they're generated". Both lines are in Stripe's webhook docs. In live mode, a failed delivery is retried for up to three days with exponential backoff, and anyone on your team can press Resend in the Dashboard.
None of that shows up when you run stripe trigger on your laptop. It shows up in production: a customer who cancelled but still has Pro, or a welcome email sent twice.
This guide covers the fixes that make a Next.js App Router webhook safe against both, and a test that proves it.
The short version
- Treat the event as a hint, not the truth. The event tells you which subscription changed. Fetch that subscription from Stripe and write its current state.
- Write full state, never deltas. Set status, price and period end to what Stripe says now. Don't increment, toggle or append.
- Keep an event-ID ledger for side effects only. Emails, credits and Slack pings can't be undone, so guard them with a unique row per
event.id. - Don't sort events by timestamp.
event.createdhas one-second resolution, distinct events can share it, and Stripe says not to use it for ordering.
Already seeing an error like No signatures found, a 308 redirect or a 401 on your endpoint? Paste it into the free Stripe webhook fixer for the cause and the fix. For the signature error specifically, see "No signatures found" in the App Router.
What actually goes wrong
| What happens | Typical cause | What your user sees |
|---|---|---|
| The same event arrives twice | Your endpoint timed out or returned a 5xx once, so Stripe retried. Or someone pressed Resend | Two welcome emails, credits granted twice |
| An older event arrives after a newer one | Retries, and no ordering guarantee | A cancelled user flips back to active |
| An event arrives before the row it needs | Your app links the Stripe customer to the user too late | The update matches 0 rows and the change is silently lost |
The second row is the one that bites subscription apps. Here is the handler most tutorials (including our own Stripe subscriptions guide, before we fixed it) end up with:
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object; // a snapshot from when the event was created
await db.user.updateMany({
where: { stripeCustomerId: sub.customer as string },
data: { subscriptionStatus: sub.status },
});
break;
}event.data.object is a snapshot of the subscription at the moment the event was created. Say a user's customer.subscription.updated event (status active) fails its first delivery, and the user cancels shortly after. If the retry lands after customer.subscription.deleted (status canceled), this handler writes active last. Stripe has stopped billing them, but your app still gives them Pro.
Duplicates are harmless here, because writing the same status twice changes nothing. Out-of-order deliveries are not.
Fix 1: re-fetch, then write the current state
Stripe's docs point at the fix: you can "retrieve the API resource from the Stripe API to access the latest and up-to-date object definition". Do that for every subscription event and delivery order stops mattering, because every write is the newest state. Cancelled subscriptions can still be retrieved. They come back with status: "canceled".
// src/lib/billing.ts
import type Stripe from "stripe";
import { db } from "@/lib/db";
import { getStripe } from "@/lib/stripe";
export async function syncSubscription(subscriptionId: string) {
const sub = await getStripe().subscriptions.retrieve(subscriptionId);
const customerId = typeof sub.customer === "string" ? sub.customer : sub.customer.id;
const item = sub.items.data[0];
await db.user.updateMany({
where: { stripeCustomerId: customerId },
data: {
stripeSubscriptionId: sub.id,
stripePriceId: item?.price.id ?? null,
subscriptionStatus: sub.status,
currentPeriodEnd: item?.current_period_end ? new Date(item.current_period_end * 1000) : null,
},
});
}
export async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const s = event.data.object;
if (s.mode === "subscription" && s.subscription) {
await syncSubscription(typeof s.subscription === "string" ? s.subscription : s.subscription.id);
}
break;
}
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
// Use only the id from the event. Ask Stripe for the current state.
await syncSubscription(event.data.object.id);
break;
}
}// src/app/api/stripe/webhook/route.ts
import type Stripe from "stripe";
import { handleStripeEvent } from "@/lib/billing";
import { getStripe } from "@/lib/stripe";
export async function POST(req: Request) {
const signature = req.headers.get("stripe-signature");
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!signature || !secret) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
// Raw body. req.json() would break the signature check.
event = getStripe().webhooks.constructEvent(await req.text(), signature, secret);
} catch {
return new Response("Invalid signature", { status: 400 });
}
await handleStripeEvent(event);
return Response.json({ received: true });
}Three details that matter:
- Link the customer before checkout. The third row of the table goes away if your checkout route creates the Stripe customer and saves
stripeCustomerIdon the user before redirecting to Stripe. Then every later event can find its user, whatever order it arrives in. - Let failures fail. If the
retrievecall throws, don't catch it and return 200. Next.js returns a 500, Stripe retries later, and the retry fetches fresh state anyway. - Keep it fast. One API read per subscription event is cheap, and subscription events are low volume. If you add slow work (reports, provisioning), push it to a queue and return 2xx first, as Stripe recommends.
Fix 2: an event ledger for side effects
Re-fetching makes the state correct. It does not stop a duplicate checkout.session.completed from sending the welcome email twice. For anything you can't undo, record the event ID under a unique key and skip events you've already claimed.
model StripeEvent {
id String @id // evt_...
type String
createdAt DateTime @default(now())
}import { Prisma } from "@prisma/client";
import type Stripe from "stripe";
import { db } from "@/lib/db";
/** True the first time an event id is seen, false on every repeat. */
export async function claimEvent(event: Stripe.Event) {
try {
await db.stripeEvent.create({ data: { id: event.id, type: event.type } });
return true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") return false;
throw e;
}
}
// inside handleStripeEvent, case "checkout.session.completed":
// await syncSubscription(...); // safe to repeat
// if (await claimEvent(event)) await sendWelcomeEmail(s.customer_details?.email);The primary key does the real work, so two deliveries racing in parallel can't both win (the loser gets Prisma's P2002 unique constraint error, which is why the catch checks for it; the free Prisma error fixer explains that one and other Prisma errors). Two honest caveats:
- Choose at-most-once or at-least-once. Claiming before sending (as above) means a crash between the two skips the email. Claiming after sending means a crash can send it twice. For a welcome email, at-most-once is usually fine. For granting paid credits, do the claim and the credit write inside one database transaction so they succeed or fail together.
- Same change, two event IDs. Stripe's docs note that it sometimes generates two separate events for one change. If a side effect must happen once per subscription rather than once per event, key the ledger on the object id plus
event.typeinstead.
Keep ledger rows for at least 30 days. The Stripe CLI can resend an event for up to 30 days after it was created, and the Dashboard for up to 15.
Why not just compare timestamps?
It's tempting to store the created time of the last event you processed and ignore anything older. Stripe's docs warn against exactly this: event created is recorded in seconds, so distinct events can share a timestamp, and you shouldn't use it to decide order or whether you've already processed an event. Re-fetching gives you correct ordering without any bookkeeping.
Test it: deliver twice, then backwards
You don't need Stripe running to prove this. Inject the database and Stripe client so the handler can take fakes, then replay events in the worst orders. Here, handleStripeEvent(event, deps) is the same function as above with db and stripe passed in instead of imported.
// tests/webhook-order.test.ts
import { describe, expect, it } from "vitest";
import { handleStripeEvent } from "@/lib/billing";
function fakes(stripeNow: string) {
const row = { status: "none" };
const db = { user: { updateMany: async ({ data }: any) => { row.status = data.subscriptionStatus; return { count: 1 }; } } };
const stripe = { subscriptions: { retrieve: async (id: string) => ({ id, customer: "cus_1", status: stripeNow, items: { data: [] } }) } };
return { row, deps: { db, stripe } as any };
}
const sub = (status: string) => ({ id: "sub_1", customer: "cus_1", status, items: { data: [] } });
const updated = { id: "evt_1", type: "customer.subscription.updated", data: { object: sub("active") } } as any;
const deleted = { id: "evt_2", type: "customer.subscription.deleted", data: { object: sub("canceled") } } as any;
describe("stripe webhook", () => {
it("stays canceled when an older event arrives last", async () => {
const { row, deps } = fakes("canceled"); // what Stripe says right now
await handleStripeEvent(deleted, deps);
await handleStripeEvent(updated, deps); // late retry of an older event
expect(row.status).toBe("canceled");
});
it("does nothing new when the same event arrives twice", async () => {
const { row, deps } = fakes("active");
await handleStripeEvent(updated, deps);
await handleStripeEvent(updated, deps);
expect(row.status).toBe("active");
});
});We ran this test against both versions of the handler. The payload-trusting one fails the first case (it ends active). The re-fetching one passes both.
Then check the real thing once it's deployed: open a delivered event in the Stripe Dashboard and press Resend, or run stripe events resend <event_id> --webhook-endpoint=<endpoint_id>. The second delivery should return 200 and change nothing.
A Cursor prompt that applies all of this
Paste this into Cursor or Claude Code with your webhook route open:
Make the Stripe webhook safe against duplicate and out-of-order events.
- For checkout.session.completed and customer.subscription.created/updated/deleted,
retrieve the subscription from Stripe by id and write its full current state
(status, price id, current period end). Never write from event.data.object directly.
- Keep reading the raw body with req.text() and verifying with constructEvent.
- Don't catch Stripe API errors and return 200; let them return 500 so Stripe retries.
- Add a StripeEvent model keyed by event id and use it only to guard side effects
like emails. Don't order events by event.created.
- Move the logic into src/lib/billing.ts with db and stripe injected, and add Vitest
tests that deliver the same event twice and deliver "updated" after "deleted".
- Run the tests and npm run build, and fix anything that fails.Checklist
- Raw body with
await req.text(), verified withconstructEvent - Subscription events re-fetch from Stripe before writing
- Writes set full state, nothing is incremented or toggled
- The Stripe customer is saved on the user before checkout
- Irreversible side effects are guarded by a unique event-ID row
- Stripe API failures return 500, not 200
- A test replays an event twice and out of order
If you'd rather not wire this up yourself, the VibeBoot SaaS Starter ships a verified Stripe webhook with Auth.js v5 and Prisma, and you can click through the live demo before you buy.