If you sell credits or usage through Stripe, LemonSqueezy or Polar, your webhook handler will receive the same event more than once. Providers retry when your server is slow, when a deploy lands mid-request, when a response gets lost. If the handler does balance += 100 every time it sees checkout.session.completed, somebody eventually gets paid twice.
The fix is two checks, not one. Here is what each one does and why the second exists.
Check 1: de-dupe on the event id, inside the same transaction
Keep a table of events you have handled, keyed by provider and event id. Insert before you do any work, and skip the work if the insert did nothing:
await db.transaction(async (tx) => {
const inserted = await tx
.insert(billingWebhookEvents)
.values({ provider, eventId, type: eventType })
.onConflictDoNothing()
.returning({ eventId: billingWebhookEvents.eventId });
if (inserted.length === 0) return; // a retry of an event we already handled
// ...do the actual work with tx
});
The primary key is (provider, event_id). The detail that matters is the transaction. If the work throws, the de-dupe row rolls back with it, so the provider's next retry is processed instead of being swallowed as already handled. The common version of this bug checks first and processes after, and a delivery that failed halfway is then lost for good.
Check 2: a unique constraint on the thing being paid for
Event ids are the provider's idea of uniqueness, not yours. One checkout can in principle reach you under two different event ids, and once several billing providers sit behind one interface you can't rule it out. So the purchase itself gets a unique key, and you insert it before the balance moves:
const [purchase] = await tx
.insert(creditPurchases)
.values({ teamId, provider, providerCheckoutId, credits })
.onConflictDoNothing()
.returning({ id: creditPurchases.id });
if (!purchase) return; // this checkout already paid out
If the row already exists, the credits were granted for this checkout, whatever event id carried it. Check 1 protects you from redelivery. Check 2 protects you from your own assumptions about the provider.
Make the increment atomic
The last piece is easy to miss. Do the increment in SQL, not in JavaScript:
await tx
.insert(credits)
.values({ teamId, balance: packCredits })
.onConflictDoUpdate({ target: credits.teamId, set: { balance: sql`${credits.balance} + ${packCredits}` } });
balance + N runs inside the database, so two concurrent deliveries for the same team can't read the same starting balance and overwrite each other. A read, add, write sequence in application code can.
Test it against a real Postgres
This is the part that earned its keep. Mocks agree with whatever you wrote, because a mock doesn't enforce a unique constraint or roll back a transaction. A real Postgres does both. The tests that matter deliver the same event twice and the same checkout under two different event ids, then assert the balance moved once:
- no-ops a redelivery of the exact same event id
- doesn't double-grant when the same checkout is delivered under two different event ids
When I added tests like these against a real database, they caught two real bugs in my own billing code that I had read past more than once.
What to copy
If you only take one thing from this: put the de-dupe row and the work in one transaction, and give the paid thing its own unique key. Everything else follows from those two.
This is the pattern in Keel, the Next.js SaaS starter I'm building and selling. The webhook handler, the credit grant and the tests are all in the repo, and there is a demo with no sign-up if you want to see the product around it.
Top comments (1)
Putting the de-dupe insert in the same transaction as the work is the detail most implementations miss, so good call highlighting it. A related pitfall: providers can deliver events out of order, so something like a subscription.updated can arrive after the deleted event. Storing the event's created timestamp and ignoring anything older than what you've already applied for that object has saved us from some nasty states. Do you also prune the events table on a schedule, or keep it forever as an audit trail?