Webhook Replay

Webhook Replay

Stripe delivers webhooks at least once , not exactly once. Any handler for checkout.session.completed, invoice.paid, etc. can receive the same event twice (redelivery after a timeout, a retried delivery after your endpoint 500s, manual replay from the Stripe dashboard). A handler that isn't idempotent will double-grant credits, double-send receipt emails, or re-fire jobs on every replay.

There are two separate problems here, and they need two separate guards:

  1. The DB write must not double-apply. Granting credits or upserting a subscription twice corrupts state.
  2. Side effects that aren't naturally idempotent (sending an email, enqueuing a one-time job) must not re-fire on a replay even if the underlying write is safe to repeat.

The rule

Dedup the write with a marker row checked and inserted in the same transaction as the mutation. Gate side effects with a granted/isReplay flag computed from a fresh read inside that same transaction, so emits/emails only fire on the first delivery.

make check-webhook-idempotency enforces (at file-presence level) that src/features/billing/server/stripe-*.server.ts still contains both patterns , see the Makefile target for what it does and doesn't catch.

Note: the callback must be async, with real awaits inside , not synchronous. This repo's db.transaction() runs on drizzle-orm/node-postgres, a real Postgres connection doing network I/O over a pooled connection, not Bun's native synchronous SQLite wrapper. See CLAUDE.md's "Shared-DB Transactions Must Use Async Callbacks (reversed from base warpkit)" section , that title is not a typo, this rule is the opposite of the base template's. A sync callback (.sync()/.run(), no await) does not compile against this driver, and if you're porting a sync example from somewhere else, adapting it to await mechanically without also awaiting the whole db.transaction(...) call is a correctness bug, not just a style issue: the transaction can commit before the callback's writes actually land.

Right: dedup marker in the same transaction as the write

handleCreditsPurchase in src/features/billing/server/stripe-checkout.server.ts dedups on the Stripe payment intent ID. The marker insert and the credit increment happen inside the same db.transaction, so a crash between the two can't strand a marker without the grant (or vice versa):

let granted = false;
await db.transaction(async tx => {
  const existing = await tx.query.purchases.findFirst({
    where: eq(purchases.stripePaymentIntentId, paymentIntentId)
  });
  if (existing) return; // already granted, replay , no-op

  await tx.insert(purchases).values({ /* marker row */ });

  await tx
    .update(users)
    .set({ credits: sql`credits + ${amount}`, updatedAt: new Date() })
    .where(eq(users.id, userId));
  granted = true;
});

if (granted) {
  invalidateBootstrapCache(userId);
  await emit('credits.purchased', { userId, amount });
}

The emit only fires when granted is true , a replay short-circuits before it, so it can't be sent twice either. Note the outer await db.transaction(...) , the whole transaction is awaited before the if (granted) check runs, so the write is guaranteed committed (or rolled back on throw) before any side effect fires.

Right: isReplay flag for upserts where the write is naturally idempotent

handleSubscriptionCheckout (same file) upserts the subscription row with onConflictDoUpdate , the write itself is safe to repeat. But the subscription.activated emit (receipt email, trial-expiring job) must only fire once. The handler computes isReplay from a fresh read inside the transaction, before the upsert:

let isReplay = false;

await db.transaction(async tx => {
  if (subId) {
    const existingSub = await tx.query.subscriptions.findFirst({
      where: eq(subscriptions.userId, userId),
      columns: { stripeSubscriptionId: true }
    });
    isReplay = existingSub?.stripeSubscriptionId === subId;
  }

  await tx
    .insert(subscriptions)
    .values({ /* ... */ })
    .onConflictDoUpdate({ /* ... */ });
});

invalidateSubscriptionCache(userId);
invalidateBootstrapCache(userId);

if (userEmail && !isReplay) {
  await emit('subscription.activated', { /* ... */ });
}

Wrong: check-then-write outside the transaction

// WRONG , dedup read and write are not atomic; two concurrent redeliveries
// can both pass the findFirst check before either insert commits
const existing = await db.query.purchases.findFirst({
  where: eq(purchases.stripePaymentIntentId, paymentIntentId)
});
if (existing) return;

await db.insert(purchases).values({ /* marker row */ });
await db.update(users).set({ credits: sql`credits + ${amount}` });

Two redeliveries arriving close together can both read "no existing marker" before either write commits , the dedup check itself has a TOCTOU race unless it's inside the same transaction as the write it's guarding. See gate-ordering.md for the general version of this rule , note that doc also uses withUserTransaction now, not the old per-user lock.

Wrong: synchronous-style callback (does not compile on this driver)

// WRONG , .sync()/.run() are bun:sqlite-only APIs; drizzle-orm/node-postgres
// has no synchronous query methods, this throws a type error
db.transaction(tx => {
  const existing = tx.query.purchases
    .findFirst({ where: eq(purchases.stripePaymentIntentId, paymentIntentId) })
    .sync();
  if (existing) return;
  tx.insert(purchases).values({ /* marker row */ }).run();
});

If you're used to the base warpkit template's SQLite version of this doc, this is the one rule that inverted during the Postgres port , don't carry the sync-callback habit over.

Adding a new webhook handler

  1. Identify what must not double-apply (a DB write) vs what must not double-fire (an emit, an email, a job enqueue).
  2. Dedup the write with a marker row (or a naturally-idempotent upsert) inside an await db.transaction(async tx => { ... }), with real awaits on every tx call inside.
  3. If any side effect isn't safe to repeat, compute a granted/isReplay flag from a fresh read inside that same transaction and gate the side effect on it, after the transaction has been awaited.
  4. Run make check-webhook-idempotency to confirm the new handler file still contains both required patterns (file-presence level , see the Makefile target for what it does and doesn't catch).
  5. Reference implementation: handleCreditsPurchase and handleSubscriptionCheckout in src/features/billing/server/stripe-checkout.server.ts.