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:
- The DB write must not double-apply. Granting credits or upserting a subscription twice corrupts state.
- 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 isReplay flag computed from a fresh read, 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: db.transaction() on the shared Drizzle/bun-sqlite client only works correctly with a synchronous callback, see CLAUDE.md's "Shared-DB Transactions Must Use Sync Callbacks" section. Every example below uses .sync() on reads and .run() on writes, not await, for that reason.
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;
db.transaction(tx => {
const existing = tx.query.purchases
.findFirst({ where: eq(purchases.stripePaymentIntentId, paymentIntentId) })
.sync();
if (existing) return; // already granted, replay, no-op
tx.insert(purchases).values({ /* marker row */ }).run();
tx.update(users)
.set({ credits: sql`credits + ${amount}`, updatedAt: new Date() })
.where(eq(users.id, userId))
.run();
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.
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;
db.transaction(tx => {
if (subId) {
const existingSub = tx.query.subscriptions
.findFirst({
where: eq(subscriptions.userId, userId),
columns: { stripeSubscriptionId: true }
})
.sync();
isReplay = existingSub?.stripeSubscriptionId === subId;
}
tx.insert(subscriptions)
.values({ /* ... */ })
.onConflictDoUpdate({ /* ... */ })
.run();
});
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.
Adding a new webhook handler
- Identify what must not double-apply (a DB write) vs what must not double-fire (an emit, an email, a job enqueue).
- Dedup the write with a marker row (or a naturally-idempotent upsert) inside a
db.transaction, using a synchronous callback (.sync()/.run(), noawaitinside). - If any side effect isn't safe to repeat, compute an
isReplayflag from a fresh read inside that same transaction and gate the side effect on it. - Run
make check-webhook-idempotencyto 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). - Reference implementation:
handleCreditsPurchaseandhandleSubscriptionCheckoutinsrc/features/billing/server/stripe-checkout.server.ts.