---
title: "Why a payment cannot credit you twice"
canonical: "https://router.xark.io/blog/webhook-idempotency-and-double-credit"
description: "Two Stripe events describe one payment. Which one credits, what a redelivery does, and the append-only ledger that makes a second credit unrepresentable."
section: "blog"
updated: "2026-09-08"
source: "https://router.xark.io/blog/webhook-idempotency-and-double-credit.md"
---

# Why a payment cannot credit you twice

*Published 2026-09-08. Topics: billing, credits, api-contract, security.*

Two Stripe events describe one payment. Which one credits, what a redelivery does, and the append-only ledger that makes a second credit unrepresentable.

One purchase produces two Stripe events -- checkout.session.completed and payment_intent.succeeded -- and only the session credits, because crediting on both would pay twice for one payment. Auto-recharge has no session, so it credits on the intent instead, identified by an auto_recharge flag in the metadata. The webhook claims an event before crediting rather than after, and the ledger is append-only with a unique idempotency key per event, so a redelivery is a duplicate-key collision rather than a second balance.

## One payment, two events

A Stripe Checkout that succeeds fires checkout.session.completed and payment_intent.succeeded. They describe the same money. A handler that credits on both credits twice, and the second credit is indistinguishable from the first in every log you would think to check.

So exactly one of them credits, and which one is a rule rather than an accident: the session credits, and the intent is acknowledged and settled as ignored. The exception is automatic recharge, which charges a saved card off-session and therefore has no Checkout Session at all -- it credits on the intent, and it is identified by an auto_recharge flag written into the payment intent's own metadata when the charge is created.

That single condition is the whole deduplication between the two events. It is written once and it decides, for every payment this platform will ever take, which delivery is the crediting one.

| Event | Manual top-up | Auto-recharge |
| --- | --- | --- |
| checkout.session.completed | Credits | Never fires -- there is no session |
| payment_intent.succeeded | Settled as ignored, reason superseded_by_session | Credits |
| charge.dispute.created | Freezes the gateway account | Freezes the gateway account |
| charge.refunded | Alerts an operator; no automatic decrement | Alerts an operator; no automatic decrement |
| Everything else | 200, handled: false | 200, handled: false |

## Claim before crediting, never after

The handler inserts the event as pending before it touches the balance, and marks it applied only once the gateway confirms. That ordering is chosen for the direction it fails in, which is the only thing that matters about an ordering.

Claim-then-credit: a crash mid-flight leaves a pending row. Stripe redelivers, the handler sees pending, re-attempts the credit and succeeds. Worst case, the credit lands late. Credit-then-claim: a crash after crediting leaves no row at all. Stripe redelivers, and the account is credited twice with no automatic recovery.

Under-crediting is a support ticket. Double-crediting is a refund and a hole in the books. The asymmetry is the entire argument, and it is why the slower-looking order is the correct one.

| Ordering | Crash mid-flight leaves | Redelivery does | Cost of the failure |
| --- | --- | --- | --- |
| Claim, then credit | A pending row | Re-attempts the credit | A late credit |
| Credit, then claim | No row | Credits again | Money out the door |

## Four outcomes a webhook row can hold

The row is the record of what happened, and it carries an outcome rather than a boolean, because an event that was deliberately not acted on and an event that failed to be acted on need different answers on redelivery.

The full Stripe event is stored alongside it, truncated only at 100,000 characters. A payment dispute six months out is answered by what the provider actually sent, not by whichever fields we thought to record at the time. The row also stores livemode, so a test payment can never be replayed as a real one.

| outcome | Meaning | What a redelivery does |
| --- | --- | --- |
| pending | Claimed, credit not yet confirmed | Re-attempts the credit |
| applied | Credited and confirmed | Answers 200 with duplicate: true, does nothing |
| ignored | A valid event with no ledger effect | Answers 200, does nothing |
| failed | Money taken, credit not applied | Re-attempts, and a human has already been paged |

## The ledger makes a second credit unrepresentable

Underneath the handler is a credit ledger with no way to set a balance. There is no setBalance, no updateBalance, and no path that edits or deletes a row -- a correction is a new compensating entry, so the history always explains the current number. The balance is a sum, derived on read, and never a stored column that could drift away from the rows that produced it.

Every externally triggered entry carries an idempotency key, and for a webhook the key defaults to the provider joined to the event id. A second insert with the same key is a unique-constraint violation, which the ledger reports as false rather than as an error -- because a duplicate is the expected outcome of a retry, not a failure, and treating it as one would make the provider keep retrying an event that has already been applied.

One detail in that catch is worth naming because it is the kind that quietly goes wrong. The violation is matched on the constraint's name, not on the error class. A unique violation on any other column still throws, because silently reporting an unrelated conflict as already applied would drop a real ledger entry on the floor and leave no trace that it had been dropped.

```javascript
// The retry path, from the ledger's own append.
// A duplicate key is FALSE, not an exception -- the caller acknowledges
// the delivery and the provider stops retrying.
if (isUniqueViolation(err, "credit_ledger_idempotency_key_key")) {
  return false;
}
throw err;   // any OTHER unique violation is a real problem
```

## Why crediting is a redemption code and not a write

The obvious way to add credit is to read the current quota, add to it, and write it back. That is a read-modify-write against a number the gateway is concurrently decrementing on every inference request, so a customer spending while we credit would have that spend silently erased. It is also a whole-object update, which means a stale read clobbers whatever else changed in between.

So the credit is applied through the gateway's own primitive for adding exactly N to an account: a redemption code, created by an administrator and redeemed as the owning user. The increment happens inside the gateway, atomically, and leaves a record tying the credit to a payment. Two calls instead of one, and correct instead of nearly correct.

Redeeming as the owner is what makes the credit land on their account and appear in their own top-up history, rather than on an administrator's. It is also why the credit shows up in the gateway's log as an event with a human-readable line rather than as a bare number change.

## A promotion code changes what was paid, not what is credited

Credit is deliberately the denomination the customer chose, not the amount collected. That is what a discount on a top-up means, and the difference is marketing spend we intend to make.

The same code path with a misconfigured or leaked 100%-off code mints unlimited free credit, and nothing downstream would notice: the ledger would record a clean, well-formed credit against nothing collected, over and over. So the discount is bounded at the only point in the system that sees both numbers, and the ceiling is set above any discount we intend to run and well below a giveaway.

Past the ceiling we take the money, refuse the credit, and page a human. That is the same posture as every other failure in this handler, and it follows from one fact: quietly crediting is the only outcome here that cannot be undone.

## Where this still depends on a person

Every crediting path in the gateway -- redemption, top-up, subscription -- is gated on an administrator confirming payment compliance terms in its dashboard, and that confirmation explicitly refuses an API access token. It is a deliberate human step and not something to route around.

Until it is confirmed, the credit call throws, the webhook settles the event as failed, an operator is paged, and a replay script credits the account once the gate is cleared. The property worth stating is the one that survives that gap: money taken is never money lost, only money not yet delivered, and the record that proves it is the stored event rather than a memory of what happened.

This is the least automatic part of the payment path and we would rather describe it than imply it does not exist.

## Money going the other way

Disputes and refunds are handled before anything on the crediting path, because they share none of its assumptions -- no top-up metadata, no quota to add -- and must not fall through into it. Without them a customer could top up, spend the credit, then dispute the charge: we lose the payment and the inference we already paid an upstream for, and the credit stays on the account to spend again.

A dispute freezes the gateway account rather than clawing quota back. A decrement races with concurrent spend and can drive a balance negative, and a dispute is already a human conversation, so stopping further spend is the part that has to happen in seconds and deciding the final balance is not. A refund does not freeze: suspending someone we have just refunded is the wrong reflex.

Neither event decrements automatically, and that is a real limitation rather than a design flourish. It means a refunded balance can still be spent until an operator adjusts it, and the alert exists precisely because a person has to.

## What a caller can take from this

Nothing on a path a customer can drive adds credit. Crediting happens only in the payment webhook, so a refreshed success page, a replayed checkout URL or a retried dashboard fetch cannot mint anything.

The signature is verified against the raw bytes before anything else touches the body. Any parsing before that invalidates it, and an unverified webhook is an open endpoint that credits accounts.

If a top-up is slow to appear, the answer is almost always that the event settled as failed rather than that it was lost. The receipt email is sent after the credit is recorded and deliberately cannot fail the request, so a missing receipt is not evidence of a missing credit.

And if you are building something with this shape yourself: the two decisions that carry the weight are claiming before acting, and making the duplicate a database constraint rather than a check in application code. A check can be forgotten by the next person to touch the file. A unique index cannot.