Skip to content
← back to profile

Payments: charging a card without charging it twice

Authorisation and capture, idempotency under retries, a ledger that must balance, and outcomes that arrive later.

01

Authorisation, capture and settlement

beginner

A card payment is not one event. Authorisation asks the issuing bank to check the card and hold the amount, and it succeeds or declines within a second or two. Capture tells the network to actually take the held money, and it can happen immediately or days later. Settlement is the movement of funds between banks, which happens in batches and takes days. Three steps, three different latencies, and three different things that can fail.

Splitting authorisation from capture is what makes normal commerce work. A shop authorises when you order and captures when the item ships, because charging for something not yet sent is both a refund waiting to happen and, in many jurisdictions, not allowed. An authorisation expires if it is not captured, typically within about a week depending on the card scheme and the merchant category, and an expired authorisation means the money was never taken and the customer saw a pending charge that vanished.

The failure that defines the domain is that the network can time out at any point and the timeout tells you nothing. A request that times out during authorisation may have been authorised, or not. There is no way to tell from the timeout itself, so the system must be able to ask afterwards, which is why every payment has an identifier the merchant chose before sending it and why looking up by that identifier is a first-class operation rather than an afterthought.

Regional rules add a second asynchronous step. In Europe, strong customer authentication under PSD2 means many payments require the cardholder to confirm through their bank, so the flow pauses, hands the customer to the issuer, and resumes on the way back. Any design that treats authorisation as a single synchronous call will meet this and have nowhere to put it.

The shape that survives all of this is a state machine with an identifier: created, requires action, authorised, captured, failed, refunded. Each transition is recorded, each is idempotent, and the current state is the answer to the question a support ticket is really asking, which is what happened to this payment and when.

why this choice

The three steps have different latencies and different failure modes, so a design that models a payment as one call has already lost. The identifier chosen before the request leaves is what makes a timeout recoverable, because it is the only way to ask afterwards what happened.

in practice

Stripe models this explicitly as a payment intent with a lifecycle, including a state for requires action, which exists because European strong customer authentication makes the flow genuinely multi-step rather than a single call that sometimes takes longer.

check yourself

Why does European strong customer authentication change the shape of the flow?

hold released or takenfunds moveif capture never comesnetwork gave no answerOrder placedmerchant id assignedAuthoriseseconds, holds fundsCaptureat dispatch, days lat…Settlementbatched, daysAuthorisation exp…never capturedTimeoutauthorised, or not
ClientServiceData storeExternaldashed = asynchronousconsidered, not chosen
Three steps, three latencies, and a timeout that answers nothing
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
02

Idempotency when the retry is inevitable

intermediate

Everything in the previous topic makes retries certain. The network times out, the client gives up, the user presses the button again, the queue redelivers, the deploy restarts a worker mid-flight. A payments API that is not idempotent will double-charge, and the only question is how often.

The mechanism is a key chosen by the client, unique per logical operation rather than per attempt, sent with the request and stored server-side alongside the outcome. A repeat of the same key returns the stored result rather than doing the work again. That distinction, per operation and not per attempt, is where implementations go wrong: a key regenerated on retry makes every attempt a new payment, which is precisely the situation it was meant to prevent.

The concurrency is the hard half, because two retries can arrive at the same instant. Recording the key after the work completes lets both pass the check and both charge. Recording it before, with a unique constraint, means the second insert collides and can be refused, which is why the standard shape is to insert the key immediately in a pending state, do the work, then update it with the result. A concurrent duplicate gets a conflict, and a caller receiving that knows to retry rather than to assume failure.

Then there is the crash between charging and recording, which no single database can prevent because the charge happened somewhere else. This is why the stored record must be written in the same transaction as the local state change, and why the result of the remote call is recovered by asking the provider about the merchant's identifier rather than by guessing. A pending key older than a few minutes is stale and must be resolved by looking it up, not by retrying blindly.

Stripe stores idempotency keys for 24 hours, which is a deliberate boundary rather than an implementation limit: retries beyond that horizon are no longer the same operation in any meaningful sense, and a key reused a week later is treated as a new request. Choosing that window is a real design decision, and choosing to keep keys forever is a slow leak with a large table at the end of it.

why this choice

A timeout is ambiguous, so the system must be able to tell a retry from a new request, and no amount of care at the call site can substitute for that. The interesting part is not storing a key, it is what happens when two retries arrive at once and what happens when the process died between charging and recording.

in practice

Stripe requires an idempotency key on payment creation and retains the record for 24 hours, returning the original result for any repeat within that window. The window is the part worth copying: it makes the guarantee finite and explicit rather than unbounded and vague.

check yourself

Why store idempotency keys for a bounded window rather than forever?

insert pendinginsert collideswinner proceedsloser waitsupdate the rowAttempt 1key abcAttempt 2key abc, concurrentIdempotency tableunique constraintCharge the cardonceConflict returnedretry, do not assume …Stored resultreturned to both
ClientData storeServiceEdge / CDNdashed = asynchronous
Insert the key first, so a concurrent retry collides instead of charging
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
03

The ledger, and why it is double entry

advanced

A balance stored as a number on a row is a design that cannot answer the questions a payments system is asked. It says what the balance is and not how it got there, so a discrepancy has no explanation, a correction destroys the evidence, and two processes updating it race. Every serious money system stores movements rather than balances, and derives the balance by summing them.

Double entry is the discipline that makes those movements checkable. Every transaction writes at least two entries that sum to zero: money leaving one account and arriving in another, with fees and taxes as their own accounts rather than as adjustments. If the sum of all entries is not zero, something is wrong, and that check can run continuously rather than being noticed by a customer.

Entries are append only. A mistake is corrected by writing a compensating entry, not by editing history, which means the record of what happened and the record of what should have happened both survive. That is a requirement in regulated environments and it is also simply how you keep the ability to explain a balance to somebody who disagrees with it.

The performance question that always follows is whether summing entries is too slow, and the answer is that you keep a materialised balance and treat it as a cache of the truth rather than as the truth. It is recomputed and compared, and a mismatch is an alert rather than a silent correction, which is exactly the property a stored-balance design cannot offer.

The last piece is that a ledger entry is not a payment. The payment is a state machine with an external dependency; the ledger is an internal record of money that has moved. Keeping them separate means an authorisation that never captures leaves no ledger entry, a refund is two entries rather than a subtraction, and a dispute writes entries of its own. Collapsing the two produces a ledger that has to model card network semantics, which is where the design stops being explainable.

why this choice

Storing movements rather than balances is what makes a discrepancy explainable, and double entry is what makes it detectable without a customer reporting it. The balance is still there as a materialised value, but it is a cache of a derivation rather than the source of truth.

in practice

Stripe has written about running a ledger as the system of record for money movement, and the pattern is near-universal in financial infrastructure for the same reason accountants adopted it centuries ago: an invariant that holds on every write is worth more than a number that is usually right.

check yourself

What does double entry give you that single entry does not?

derivedthe tempting shortcutPayment capturedone business eventDebit customer+100.00Credit merchant-97.10Credit fees-2.90Entries sum to ze…checked continuouslyMaterialised bala…recomputed and compar…Balance as a colu…no explanation, races
ClientData storeEdge / CDNdashed = asynchronousconsidered, not chosen
Movements are the truth; the balance is a derivation you check
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
04

Outcomes that arrive later

intermediate

Plenty of what happens to a payment happens after the response. A bank authorisation can be reversed. A dispute arrives weeks later. A bank transfer that looked successful can fail days afterwards. A subscription renews on a schedule nobody is watching. So the merchant needs a channel for outcomes that were not available at request time, which is what webhooks are for.

That makes webhook handling a core part of a payments integration rather than an optional convenience, and it inherits every property of delivery over an unreliable network. Events can arrive twice, out of order, or after a long delay while a receiver was down. Handlers must be idempotent by event id, must tolerate an older event arriving after a newer one, and must not assume that the absence of an event means the absence of the outcome.

The reconciliation of local state with the provider's is where the design earns its keep. A merchant's database and a payment provider's are two systems with no shared transaction, so they will disagree at some point. Treating the webhook as the only source of updates makes a missed delivery permanent, which is why a periodic sweep that asks the provider about anything in a non-final state is a required component and not a belt-and-braces addition.

Ordering deserves a specific mention because payments make it visible. A charge succeeded event and a charge refunded event delivered out of order will, in a naive handler, leave a refunded payment marked as succeeded. Handlers that check the state transition rather than applying the event blindly, or that fetch the current object rather than trusting the payload, avoid a whole family of these bugs.

The user-facing consequence is that the interface has to be honest about pending. Telling somebody their payment succeeded when it has only been accepted produces the worst possible support conversation later. Showing a state that can still change, and notifying when it does, is both more accurate and, in practice, less alarming than a success that quietly reverses.

why this choice

The response to the request is not the outcome, which means the merchant's state is a prediction until the provider confirms it. Everything else here, idempotent handlers, tolerance of reordering, and a sweep for anything still pending, is a consequence of that one fact.

in practice

Stripe signs every webhook with an HMAC and a timestamp and retries failed deliveries with backoff over a period of days, and its guidance is to treat the event as a signal to fetch the current object rather than as the state itself. That last point removes the reordering problem entirely.

check yourself

A refunded event arrives before the succeeded event for the same charge. What prevents the wrong final state?

optimisticusually arriveswhen it does notCharge requestreturns acceptedLocal statependingWebhookmay arrive twice, or …Periodic sweepasks about non-final …Confirmed stateand the user is told
ClientData storeQueue / streamServicedashed = asynchronous
The response is a prediction; the webhook and the sweep are the confirmation
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
05

Declines, retries and the cost of trying again

advanced

Declines are not errors in the usual sense. A hard decline means the card is closed, stolen or invalid and will never work; retrying is pointless and, at volume, damages the merchant's standing with the networks. A soft decline means insufficient funds or a temporary block, and a retry in a few days has a genuine chance. Treating the two identically is the most common expensive mistake in subscription billing.

The reason to care is that the response codes are the only signal, and they are coarse. Issuers deliberately do not explain much, partly for fraud reasons, so a system has to map a small set of codes into a policy: retry, retry later with a different strategy, or stop and ask the customer. Getting that mapping wrong shows up as either lost revenue or an unhappy conversation with a payment provider about retry ratios.

For recurring payments, the practice built on this is dunning: a schedule of retries spread over days, combined with messages to the customer, because many failures are fixed by the cardholder rather than by the system. The retries are timed to when they are more likely to succeed, such as after a typical payday, and the whole sequence has an end, after which the subscription is cancelled rather than retried indefinitely.

Network-level failures need the opposite instinct. Where a decline is an answer, a timeout is the absence of one, and retrying a timeout without an idempotency key is how double charges happen. So the two paths must be distinguished in code: a decline is a business outcome to be recorded and acted on, a timeout is an unknown to be resolved by lookup before anything else is attempted.

Card details also expire and change, which is a background failure mode with a documented remedy: account updater services that keep stored credentials current, and network tokens that survive a card being reissued. For a business with stored cards, that machinery recovers a meaningful share of otherwise-lost revenue, and it is invisible to anyone who has not been told it exists.

why this choice

A decline is an answer and a timeout is not, and confusing them produces either double charges or abandoned revenue. The rest is policy: which codes are worth retrying, on what schedule, and when to stop and involve the customer instead of the system.

in practice

Subscription platforms publish their retry schedules and report recovering a substantial share of failed payments through timed retries and customer messaging, which is why dunning exists as a named product feature rather than as an implementation detail.

check yourself

What is dunning?

Charge attemptHard declinecard closed or invalidSoft declineinsufficient fundsTimeoutno answer at allStop, ask the cus…Scheduled retriesdays, then give upLook it up firstnever retry blind
ClientExternalServicedashed = asynchronousconsidered, not chosen
Three outcomes, three completely different responses
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
06

Reconciliation, and the money that does not match

advanced

At the end of every day, three records exist of the same money: what the merchant's system believes, what the payment provider reports, and what the bank actually moved. They will not agree exactly, and the job of reconciliation is to explain every difference rather than to hope there are none.

Most differences are timing rather than error. A capture on one side of midnight and a settlement on the other, a refund issued today and settled in three days, a payout batching Friday and Saturday together. A reconciliation process that cannot express in flight will produce alarming numbers every single day and will therefore be ignored, which is worse than not having one.

The differences that are not timing fall into a small set: a payment recorded locally that the provider has never heard of, usually a request that timed out and was never resolved; a provider payment with no local record, usually a webhook that was missed and a sweep that never ran; and amount mismatches, usually fees or currency conversion applied somewhere the local model does not represent.

Which is why the reconciliation is also the test of everything upstream. A system with correct idempotency, a working sweep and an honest ledger reconciles with a small, explainable set of in-flight items. A system without them reconciles with a list of mysteries, and the mysteries are the same bugs described in the earlier topics arriving in aggregate at the end of the month.

The operational shape that works is a daily automated match with a named owner for the exceptions, and a small tolerance that is a deliberate decision rather than an accident. Rounding in currency conversion is real, and pretending otherwise produces an exception queue nobody can clear. What matters is that the tolerance is written down, monitored for drift, and never used to absorb a difference that is growing.

why this choice

Reconciliation is where every upstream mistake becomes visible, which makes it a test of the system rather than an accounting chore. The measure of a payments integration is not whether the numbers match, it is whether every difference has an explanation somebody can give.

in practice

Providers publish settlement reports precisely so that merchants can perform this match, and the presence of a payment in one system and not the other is the standard way an unresolved timeout is finally discovered, often weeks after the request that caused it.

check yourself

A payment exists in the merchant's system and not in the provider's. What is the usual cause?

explainablea bug upstreama bug upstreamMerchant ledgerProvider reportBank statementDaily matchwith a stated toleran…In flighttiming, expectedLocal onlyunresolved timeoutProvider onlymissed webhook, no sw…
Data storeServiceExternaldashed = asynchronousconsidered, not chosen
Three records of the same money, and the differences that need explaining
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.