Launch deal — $49 lifetime · usually $99

May 5, 2026

Idempotent Stripe Webhooks in Next.js — The Pattern

Stop Stripe webhook double-grants in Next.js 15. Pattern: unique-indexed natural keys, lookup-before-insert, race-tolerant ON CONFLICT. Code you can ship today.

Your Stripe webhook fires. You insert a payment row. You grant 400 credits. You return 200 OK. Twenty seconds later, Stripe fires the same webhook again. Your handler runs a second time. The user now has 800 credits.

This is the most expensive bug you can ship in a Next.js SaaS app — silent, hidden behind happy-path tests, and the fix is mechanical once you understand it. Three lines of schema, one findFirst, one onConflictDoNothing. But you have to know why Stripe retries before the fix makes sense.

This post walks through the pattern we use in Vibestrap — a Next.js 15 SaaS scaffold — to make every payment webhook strictly idempotent. The pattern is provider-agnostic: it's the same shape for Stripe, Creem, NOWPayments, or anyone else who sends signed webhooks with retry semantics.

Why Stripe retries (even when you returned 200)

Stripe's webhook delivery is a queue with at-least-once semantics. Three things you cannot fully prevent will cause retries:

  • Non-2xx response — Stripe retries with exponential backoff for up to 3 days. The first retry is within minutes; later retries are spaced hours apart.
  • >30 second handler — Stripe severs the connection at 30 seconds and treats it as a failure, even if your handler eventually completes successfully. So a slow database query that succeeds on attempt one can still trigger a retry.
  • Manual resend — anyone with dashboard access can hit "Resend" on a delivered event. That issues a brand new HTTP request to your endpoint, with a new event.id. Idempotency must hold against this too.

Your handler must produce the same outcome regardless of how many times the event fires. That's idempotency, and it's a property of your handler, not Stripe.

The naïve approach — "I'll just check if the user already has credits before granting" — has races. Two replicas processing concurrent retries can both check, both find nothing, both grant. The dedupe must be enforced by the database, not application code.

The three layers

Three independent dedupe layers, each catching a class of failure the others miss. Combined, they collapse every retry path to "no double-insert, no double-grant."

Layer 1 — Unique index on the natural key

Every Stripe event has identifiers you can use as natural keys. Pick the one that's unique per logical event, not per delivery attempt:

  • invoice.id — unique per invoice. Right key for invoice.paid events.
  • checkout_session.id — unique per checkout. Right key for checkout.session.completed.
  • subscription.id — unique per subscription. Right key for status-change events.

In Vibestrap's schema (src/db/app.schema.ts):

export const payment = pgTable(
  'payment',
  {
    id: text('id').primaryKey(),
    userId: text('user_id').notNull(),
    invoiceId: text('invoice_id'),    // stripe invoice id (idempotency key)
    sessionId: text('session_id'),    // stripe checkout session id
    subscriptionId: text('subscription_id'),
    // ... other fields
  },
  (t) => [
    uniqueIndex('payment_invoice_id_unique').on(t.invoiceId),
    uniqueIndex('payment_session_id_unique').on(t.sessionId),
    index('payment_subscription_id_idx').on(t.subscriptionId),
  ]
);

If anything tries to insert a row with an invoiceId that already exists, Postgres rejects it with ERROR: duplicate key value violates unique constraint. The database is saying "this thing already happened" — no application logic can subvert it. This is the safety net under everything else.

A subtlety: invoiceId and sessionId are nullable because not every webhook event carries both. The unique index treats NULL values as distinct (Postgres default), so you can have many rows with invoice_id = NULL — which is what you want. The index only deduplicates real, non-null values.

Layer 2 — Lookup before insert

The unique index is the safety net. The primary path is "lookup, decide, write." Here's the actual code from src/payment/handlers/core.ts:

async function findExistingPayment(p: NormalizedPayload) {
  if (p.invoiceId) {
    return db.query.payment.findFirst({
      where: eq(payment.invoiceId, p.invoiceId),
    });
  }
  if (p.sessionId) {
    return db.query.payment.findFirst({
      where: eq(payment.sessionId, p.sessionId),
    });
  }
  return null;
}

Before inserting, the handler asks: "do we already have a row for this invoice or session?" If yes, skip the insert and proceed to the credit-grant check (covered below). If no, attempt the insert.

Why bother with the lookup if the unique index protects you anyway? Two reasons:

  1. Friendlier control flow. Handling a "row already existed" branch is trivial; handling a unique-constraint exception means catching PostgresError with code 23505, parsing the constraint name, and continuing. Lookup-first keeps the happy path readable.
  2. You may want to update the existing row. A delayed subscription.created arriving carrying a subscriptionId that wasn't on the original checkout.session.completed is a real case — you want to patch the existing payment row, not skip silently. The lookup gives you the existing row to update.

Layer 3 — Race-tolerant insert

Even with the lookup, two webhook deliveries can hit your two server replicas at the same millisecond. Both findFirst queries return null. Both replicas attempt to insert. One wins; one hits the unique-constraint violation.

Drizzle's .onConflictDoNothing() turns the violation into a silent no-op — and .returning() then tells you whether the insert actually wrote a row:

const inserted = await db
  .insert(payment)
  .values({
    id: paymentId,
    userId: p.userId,
    invoiceId: p.invoiceId ?? null,
    sessionId: p.sessionId ?? null,
    priceId: p.priceId ?? '',
    status: p.status,
    amount: p.amountCents,
    currency: p.currency,
    // ... rest of the fields
  })
  .onConflictDoNothing()
  .returning({ id: payment.id });

if (inserted[0]) {
  paymentId = inserted[0].id;
} else {
  // ON CONFLICT fired — a concurrent insert won the race.
  // Re-fetch by the natural key to get the canonical row.
  const raced = await findExistingPayment(p);
  if (!raced) return;
  paymentId = raced.id;
}

inserted[0] is undefined only when the ON CONFLICT clause fired. That's the signal that a concurrent insert won. The recovery: re-fetch by the same natural key, take the existing id, continue with that.

The end-state is the same whether you won the race, lost the race, or were the only writer: there is exactly one payment row for this invoice, and you have its id. From here on, downstream code (credit grants, affiliate commissions) keys off paymentId.

The fourth layer — dedupe each side effect separately

There's a subtler bug the three layers above don't catch. Imagine your handler:

  1. Inserts the payment row successfully (Layers 1–3 all pass).
  2. Calls grantCredits(), adding 400 credits.
  3. Crashes before returning 200. Maybe a downstream service times out, maybe your container is killed mid-execution.
  4. Stripe retries the webhook.
  5. The retry's handler finds the existing payment row (Layer 2 hit), so it skips the insert. Good.
  6. But it then calls grantCredits() again. The user now has 800 credits. Bad.

The fix is an independent idempotency check on the credit grant itself, keyed by (userId, type='GRANT', sourceId=paymentId):

async function dispatchCreditGrant(paymentId: string, p: NormalizedPayload) {
  if (!p.userId) return;
  const existingGrant = await db.query.creditTransaction.findFirst({
    where: and(
      eq(creditTransaction.userId, p.userId),
      eq(creditTransaction.type, 'GRANT'),
      eq(creditTransaction.sourceId, paymentId)
    ),
  });
  if (existingGrant) return;

  await grantPlanCredits({ userId: p.userId, planId: p.scene, paymentId });
}

Now the handler is idempotent at every step, not just at the payment-row level. The credit grant fires at most once per paymentId, regardless of how many times the webhook delivers or where it crashes mid-flight.

The general principle: every side effect in your handler needs its own idempotency key. Inserting a payment row is one side effect. Granting credits is another. Recording an affiliate commission is a third. Sending a receipt email is a fourth. Each one keys on something stable (the payment ID, in this case) and checks before acting.

The cost is one extra findFirst per side effect — typically under 1ms with a proper index. The benefit is your handler is now safe to re-run any number of times.

How to verify it actually works

Two ways to confirm idempotency on a real handler before shipping:

1. Stripe CLI replay. Install the Stripe CLI, then trigger a checkout completion against your dev tunnel:

stripe trigger checkout.session.completed
# → one webhook fires, handler runs, payment row created, credits granted

# Capture the event id from the CLI output, then resend it:
stripe events resend evt_XXXXXXXX
# → second webhook fires with a DIFFERENT event.id
# → handler runs again, finds existing payment row, skips insert
# → credit grant dedupe check catches it, skips grant
# → Verify: still 1 payment row, still N credits

2. Dashboard resend. From Stripe Dashboard → Developers → Events, find the event in the list, click "Resend", target your dev tunnel. This is the closest match to a real upstream-issued retry — same event id, same body, same signature.

If your handler is correctly idempotent, the second invocation:

  • creates zero new payment rows
  • creates zero new creditTransaction rows
  • returns 200 in under 100ms (every step short-circuits)

If any of those three are wrong, one of the four layers is missing or the lookup keys aren't matching what you think. The fastest way to debug: log the result of findExistingPayment and the result of the credit-grant dedupe findFirst — one of them should be hitting on the second run.

What this pattern does not solve

Idempotency is one of three webhook concerns. The other two are separate problems with separate solutions — don't conflate them:

  • Signature verification. Every Stripe webhook arrives signed in the Stripe-Signature header. Your handler must verify the signature against the raw body before parsing — never trust the parsed payload as authentic. Idempotency assumes the request is authentic; signature verification is what makes it authentic. Vibestrap's webhook routes (e.g. src/app/api/webhooks/stripe/route.ts) verify the signature first, then call into the provider-agnostic core only if verification passes.
  • Out-of-order delivery. Two events for the same subscription (subscription.created followed by subscription.updated) can arrive in the wrong order — Stripe's queue does not guarantee ordering. Idempotency stops them from double-applying, but ordering bugs still bite. If your business logic depends on order, compare event.created timestamps against payment.updatedAt and skip if you'd be applying an older update over a newer one.
  • Permanent handler failures. If your code throws on every retry, Stripe will keep retrying for 3 days, then give up — and your DB drifts from Stripe's records. Always return 200 for events you've durably received, even if you can't process them yet (move processing to a job queue and acknowledge the webhook immediately).

The full code for all three concerns ships in Vibestrap — provider-agnostic core, separate handlers for verification, normalization, idempotency, and side effects.

Why this matters more than it looks

A payment double-grant is not just a credit-balance bug. It corrupts revenue reporting (the second payment row makes ARR look 2× the real number until reconciled). It pollutes the affiliate commission ledger if you record commissions per payment. It triggers customer-support tickets when users notice they have 2× the credits they paid for and panic-think you'll claw them back. And every one of these costs more to fix retroactively than to prevent at the schema level.

The pattern is mechanical: a unique index, a lookup, an ON CONFLICT, and a separate dedupe per side effect. Thirty minutes of work the first time you wire a webhook handler. After that it's table stakes — every webhook in the codebase follows the same shape.

Next steps

  1. Get a Vibestrap license — the full webhook code (Stripe + Creem + NOWPayments, all idempotent, all signature-verified) ships in the box for $49 launch / $99 standard, lifetime updates.
  2. Read the payment docs — the architecture page covers the normalized-event pipeline and how to add a new payment provider in ~50 lines without touching the idempotency core.
  3. Replay your last five production webhooks today. Find them in the Stripe Dashboard → Events, click Resend on each. If anything double-grants, this pattern is the fix. Run it before next Friday and you'll never ship the bug.

The bug you don't have today is the bug your accountant finds three months from now. Idempotency takes a single afternoon to wire up and saves you a refund spreadsheet at year-end.

Idempotent Stripe Webhooks in Next.js — The Pattern · Vibestrap