Skip to content
Keel
Engineering

A webhook retry can pay a customer twice, and two checks that stop it

MAMohsin Akram·Oct 22, 2026·3 min

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:

ts
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:

ts
const [purchase] = await tx
.insert(creditPurchases)
.values({ teamId, provider, providerCheckoutId, packId, credits, amountCents })
.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:

ts
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 you can sign up for the live app if you want to see the product around it.

MA

Mohsin Akram

Notes on how Keel is built and the decisions behind it.

Share