← Blog

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

  1. 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.
  2. Write full state, never deltas. Set status, price and period end to what Stripe says now. Don't increment, toggle or append.
  3. 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.
  4. Don't sort events by timestamp. event.created has 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 happensTypical causeWhat your user sees
The same event arrives twiceYour endpoint timed out or returned a 5xx once, so Stripe retried. Or someone pressed ResendTwo welcome emails, credits granted twice
An older event arrives after a newer oneRetries, and no ordering guaranteeA cancelled user flips back to active
An event arrives before the row it needsYour app links the Stripe customer to the user too lateThe 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:

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:

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

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.