$5 free credits when you sign up
← All posts
Engineering

Idempotent Stripe Webhooks for Credit Grants

Webhooks arrive at-least-once, sometimes twice. For a credit grant, a double delivery means double the money. Here is how idempotent webhook handling and atomic ledger writes make payment events safe.

Nemo Team8 min read

Stripe delivers webhooks at-least-once. That phrasing is doing a lot of work: it means a payment_succeeded event can — and eventually will — arrive twice. For most handlers a duplicate is harmless. For the handler that grants credits, a duplicate is free money walking out the door. Payment-event handling is where idempotency stops being best practice and becomes a money-correctness requirement. Here's how NemoRouter makes it safe.

At-least-once means plan for twice

Stripe retries webhooks on any non-2xx, on timeouts, and occasionally just because. So the same event id can land multiple times:

evt_123 payment_succeeded → handler grants $100 credits  ✅
evt_123 payment_succeeded → (retry) grants $100 AGAIN    ❌ double grant

If your handler isn't idempotent, the second delivery double-grants. The fix isn't "hope it doesn't happen" — it's to make processing the same event id twice a no-op, by design.

Step 1: verify the signature

Before anything, the webhook's signature is verified against the signing secret. An unverified webhook endpoint is an open door: anyone who finds the URL could POST a fake payment_succeeded and mint themselves credits. Signature verification is the gate that proves the event actually came from Stripe — no valid signature, no processing.

Step 2: dedup on the event id

Each Stripe event carries a unique id. We record processed event ids and check before acting:

on webhook(evt):
  verify signature                      # or reject
  if evt.id already processed: return 200   # idempotent no-op
  in ONE transaction:
    grant credits (balance += amount)
    write ledger row
    mark evt.id processed
  return 200

The dedup check plus the "mark processed" write — both inside the same transaction as the grant — are what make a replay safe. The second delivery sees the id already processed and returns 200 without touching the balance. Stripe is satisfied (it got its 2xx), and no second grant happened.

The dedup and the grant must be one transaction

If you grant credits in one transaction and mark the event processed in another, a crash between them re-opens the double-grant window — the grant committed, the "processed" flag didn't, and the retry grants again. Dedup mark, balance change, and ledger row commit together or not at all. Idempotency that isn't atomic isn't idempotency.

Step 3: atomic grant + ledger

The grant itself follows the same credit-safety discipline as every other balance mutation: the balance change and the ledger entry are written in one transaction. So a payment grant is reconstructable from the ledger like any other movement, and the books reconcile — sum(ledger) == balance holds across top-ups, spend, and refunds alike. A credit grant isn't a special path that bypasses the ledger; it's a ledger entry that happens to be triggered by Stripe.

Step 4: don't lose lagging events

The flip side of "don't double-grant" is "don't drop." If the webhook is slow or the endpoint was briefly down, the grant must still land once Stripe retries. Because processing is idempotent, retries are safe to accept — the system is designed to welcome redelivery, not fear it. A monitoring check watches for ledger lag (payments seen by Stripe but not yet reflected in credits) so a stuck event surfaces instead of silently stranding a customer's top-up.

Map the customer correctly

One more correctness point: the event has to grant credits to the right org. The customer→org mapping is resolved deterministically from the event's metadata, using the same UUID that flows everywhere — no separate lookup table to get out of sync. A grant to the wrong org is as bad as a double grant; same-UUID identity keeps the attribution exact.

The takeaway

Payment webhooks are the one place "probably fine" isn't good enough, because the failure mode is money. Verify the signature so only real Stripe events process, dedup on the event id so replays are no-ops, and commit the dedup mark, the balance change, and the ledger row in a single transaction so a crash can't reopen the window. Do that and at-least-once delivery becomes a feature — retries are safe — instead of a liability. It's the Stripe-to-credits path that every top-up depends on.

Written by Nemo TeamEngineering, product, and company posts from the Nemo Router team — code-first, cost-honest, no vendor-marketing fluff.

More from Engineering

All posts →
Engineering

Hydration-Safe Rendering for Money and Time

new Date() and Math.random() in a React render body cause hydration mismatches — and on a billing dashboard, a flicker on a number erodes trust. Here is the pattern that keeps server and client agreeing.

Nemo Team
8 min
Engineering

Canary Deploys and Auto-Rollback by SLO

A deploy shouldn't need a human watching a dashboard. Here is how a 5% canary, a fixed observation window, and SLO-gated auto-rollback let changes ship and self-heal without a 3 a.m. page.

Nemo Team
9 min
Engineering

Credit Ledger Parity Checks: Catching Drift Early

If a balance and its ledger ever disagree, money is wrong somewhere. Here is how continuous parity checks compare balance to ledger sum and surface a reservation leak before it becomes a billing incident.

Nemo Team
8 min