Skip to content
billingcreditsapi-contractsecurity

Why a payment cannot credit you twice

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.

EventManual top-upAuto-recharge
checkout.session.completedCreditsNever fires -- there is no session
payment_intent.succeededSettled as ignored, reason superseded_by_sessionCredits
charge.dispute.createdFreezes the gateway accountFreezes the gateway account
charge.refundedAlerts an operator; no automatic decrementAlerts an operator; no automatic decrement
Everything else200, handled: false200, 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.

OrderingCrash mid-flight leavesRedelivery doesCost of the failure
Claim, then creditA pending rowRe-attempts the creditA late credit
Credit, then claimNo rowCredits againMoney 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.

outcomeMeaningWhat a redelivery does
pendingClaimed, credit not yet confirmedRe-attempts the credit
appliedCredited and confirmedAnswers 200 with duplicate: true, does nothing
ignoredA valid event with no ledger effectAnswers 200, does nothing
failedMoney taken, credit not appliedRe-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.

One month of the same work, by the model that spends the credit
One month of the same work, by the model that spends the creditA credit is denominated in dollars; what those dollars reach is decided entirely by the rate behind them. At 20M input and 4M output tokens a month, DeepSeek V4 Flash costs $2.52 a month and Kimi K3 $73.00 — a spread of 29.0x for the same work.DeepSeek V4 Flash$2.52DeepSeek V4 Pro$7.80Kimi K2.6$20.20GLM-5.2$26.60Kimi K3$73.00
A credit is denominated in dollars; what those dollars reach is decided entirely by the rate behind them. At 20M input and 4M output tokens a month, DeepSeek V4 Flash costs $2.52 a month and Kimi K3 $73.00 — a spread of 29.0x for the same work.Our published rates, computed on the stated volume.
// 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.

The most a promotion code may take off a $100 top-up
$100 of credit, from at least $60 collected
The most a promotion code may take off a $100 top-upCredit is granted on the denomination the customer chose, so the ceiling is on the discount rather than on the credit.
  • Must actually be collected$60.0060%
  • Most a promotion code may discount$40.0040%
Credit is granted on the denomination the customer chose, so the ceiling is on the discount rather than on the credit.The 40% discount ceiling enforced in the payment webhook, applied to the $100 rung.

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.

A month of DeepSeek V4 Pro at our rate against DeepSeek's own
Official rate
$52.20
Our rate, DeepSeek V4 Pro
$33.50
A month of DeepSeek V4 Pro at our rate against DeepSeek's ownThe crediting path is the same for every dollar, so what a top-up is worth comes down to the rate it is spent at. At 100M input and 10M output tokens a month on DeepSeek V4 Pro, that is $33.50 a month against $52.20 at the official rate, a difference of $18.70.
The crediting path is the same for every dollar, so what a top-up is worth comes down to the rate it is spent at. At 100M input and 10M output tokens a month on DeepSeek V4 Pro, that is $33.50 a month against $52.20 at the official rate, a difference of $18.70.Computed from this site's published rates and the model publisher's own.

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.

AI Token Router is an OpenAI-compatible gateway for open-weight models. Every rate on the pricing page is printed next to the model’s official rate, so the numbers in this post are checkable rather than claimed.

Get an API key

Related

  • Tool calling on open-weight models

    The API validates the shape of your tools block and forwards the rest untouched. What that guarantees, what it cannot, and the failures a good SDK will not catch.

  • What a spending ceiling protects you from

    The balance is the only hard limit here. Four things that look like spend controls and are not, and the exact arithmetic of how fast a ceiling can go.

  • Reconciling an API bill, token by token

    From usage on the response, through the ratios the gateway actually multiplies, to the integer on the ledger row -- and the three places rounding is allowed to land.