A customer taps their card at a coffee shop, and roughly 300 milliseconds later a little screen says "Approved." In that blink, a message crossed four or five separate networks, got risk-scored, checked against a balance, cryptographically verified, and logged in a dozen places. Most people never think about it. I think about it constantly, because when it goes wrong I get paged.
I want to take one authorization apart on the workbench and lay the pieces out. Not the marketing diagram with three tidy boxes, but the actual sequence, the parts that fail, and the design decisions that separate a payments platform you can sleep next to from one that keeps you up.
Authorization Is Not Payment
The first thing to get straight, because half the bugs I have seen come from teams forgetting it, is that an authorization does not move money. It is a promise. The issuing bank looks at the request and says, in effect, "yes, this cardholder is good for that amount, and I will hold it aside." The money actually moves later, during clearing and settlement, which can be a day or three afterward.
This gap matters enormously. It means an approved auth can still fail to settle. It means you can authorize $50, the customer's meal comes to $47, and you capture the smaller amount. It means a hotel can hold $500 against your card for a week and you never "paid" a cent of it. If your data model treats an authorization and a captured payment as the same object, you will eventually reconcile something wrong, and finance will find out before you do.
Every serious payments bug I have debugged eventually traced back to someone conflating the promise with the movement. Keep them as separate records, always, with an explicit link between them.
Anatomy of the Request Message
The message itself, if you are on card rails, is almost certainly an ISO 8583 variant somewhere down the stack, even if you never touch it directly because a processor wraps it in a friendlier JSON API. It carries the primary account number, the amount, a processing code that says what kind of transaction this is, the merchant category code, terminal data, and a growing pile of fields for fraud and 3-D Secure.
What surprises people new to this world is how much context is packed in and how much of it is load-bearing. The merchant category code alone can change whether a transaction is allowed, how it is priced, and whether the issuer's fraud model gets twitchy. Get the MCC wrong on a batch of merchants and you will see decline rates jump for reasons nobody can explain for two days. I have lived that specific two days.
Idempotency, or Regret
Here is the single design decision I care most about, and the one I argue about most often: every authorization request your platform sends must carry an idempotency key, and every layer must honor it. Networks are unreliable. A request times out. Did the auth go through or not? You genuinely do not know. So you retry. Without idempotency, that retry is a second hold on the customer's card, and now they have two $500 hotel holds and a very unhappy afternoon.
The key should be generated once, at the origin of the intent, and travel unchanged through every retry. Not regenerated per attempt. I have reviewed code where a well-meaning engineer put the key generation inside the retry loop, which is worse than having no key at all because it looks safe.
public async Task<AuthResult> AuthorizeAsync(AuthRequest req)
{
// Key is derived from the intent, NOT the attempt.
var key = req.IdempotencyKey
?? throw new ArgumentException("Missing idempotency key");
var existing = await _store.FindByKeyAsync(key);
if (existing is not null)
return existing.Result; // safe replay, no double hold
var result = await _processor.SendAuthAsync(req);
await _store.SaveAsync(key, result);
return result;
}
Notice the check happens before anything leaves the building. On a duplicate, you return the stored result and the customer's card is never touched twice. This is not fancy. It is just discipline, applied everywhere, with no exceptions for "this path is simple."
Where the Risk Decision Actually Lives
People assume the issuing bank makes the whole approve-or-decline call. In reality the decision is distributed across at least three parties, each of whom can veto. The merchant's own fraud rules can decline before the request even leaves. The processor or gateway may have velocity checks. And the issuer runs its own model, which is the one that ultimately says yes.
This is why "the bank declined it" is almost never a complete diagnosis. When decline rates spike, I have learned to ask which layer said no. The response codes help, but they are famously vague. A "do not honor" (code 05) is the payments equivalent of a shrug. It could be insufficient funds, it could be fraud suspicion, it could be that the card's issuer is having a bad night. You often cannot tell, and pretending you can is how you build a retry policy that hammers a genuinely dead card 40 times.
Soft Declines vs Hard Declines
The distinction that actually pays rent is soft versus hard. A hard decline means stop, permanently: lost card, stolen card, invalid account, do not retry. A soft decline means try again later, maybe with a tweak: insufficient funds right now, issuer temporarily unavailable, a transient network problem.
Enjoying this article?
Get more like it in your inbox — practical engineering leadership, fintech, and AI. No spam, unsubscribe anytime.
Treating these the same is expensive in both directions. Retry a hard decline and you annoy the customer, trip fraud flags, and sometimes get the card blocked. Give up on a soft decline and you have thrown away a sale that would have gone through in an hour. For subscription businesses this is real money. A sensible retry ladder looks roughly like this:
- Insufficient funds — retry in a few days, ideally aligned to a likely payday, not immediately.
- Issuer unavailable — retry within minutes; this is usually transient.
- Lost or stolen card — never retry; prompt the customer for a new card.
- Do not honor — retry sparingly, maybe once, then ask for another method.
- Expired card — do not retry the same card; trigger an account-updater flow.
Network tokenization and card-updater services have quietly made this a lot better in the last few years. When a customer's card is reissued, the token can follow, and a chunk of what used to be hard declines now resolve themselves. If you run recurring billing and you are not using account updater, you are simply choosing not to collect money your customers still owe you.
The Hold and Its Half-Life
An authorization is not eternal. The hold has a lifespan, and it varies by card scheme and merchant type. For a typical card purchase you might have around seven days to capture before the hold expires; for some categories it is much shorter. Miss that window and the hold falls off, the funds are released back to the customer, and your capture fails even though you were "approved" days ago.
I once inherited a service that authorized at checkout and captured at fulfillment, which was fine until warehouse delays pushed some shipments past the auth window. We were shipping goods against expired authorizations and eating the losses, and nobody had wired up an alert because the auth had, technically, succeeded. The fix was not clever. It was a scheduled job that flagged any authorization approaching expiry without a capture, plus a policy to re-authorize rather than gamble. Boring, and it saved a meaningful five-figure sum in the first quarter.
What You Must Log, and What You Must Not
Payments is one of the few domains where over-logging can literally break the law. PCI DSS is unambiguous: you do not store the full PAN in the clear, you never store the CVV after authorization, not even encrypted, not even for a minute. I have killed pull requests over a debug line that dumped a request object straight to the logs with the card number sitting right there in it.
At the same time, you need enough of an audit trail to reconstruct what happened months later when a chargeback lands or a regulator asks. The trick is logging the shape of the transaction without the sensitive core.
SELECT auth_id,
card_last_four, -- fine to keep
card_bin, -- first six, useful, allowed
amount_minor_units,
response_code,
network_ref_id, -- ties auth to settlement
idempotency_key,
created_at
FROM authorizations
WHERE merchant_id = @merchantId
AND created_at >= @from
AND response_code <> '00'; -- everything that wasn't approved
The network reference ID in that query is the unglamorous hero of reconciliation. It is the thread that ties an authorization to its eventual settlement record, and if you do not capture it at auth time you will spend miserable afternoons matching transactions by amount and timestamp and getting it wrong.
Latency Is a Feature, Not an Afterthought
The whole dance is supposed to feel instant, and that budget is brutal. You have a few hundred milliseconds end to end, and most of it belongs to networks you do not control. So the parts you do own, your fraud scoring, your data lookups, your serialization, need to be fast and, more importantly, need to fail fast. A fraud model that occasionally takes four seconds is worse than a slightly less accurate one that always answers in fifty milliseconds.
My rule is that every synchronous call in the auth path has a hard timeout tighter than the overall budget, and a defined fallback. If the fraud service is slow, you decide in advance whether you fail open or fail closed, and you make that a business decision rather than an accident of whichever exception happens to bubble up first. Fail closed on a $10,000 transaction, fail open on a $4 coffee. The dollar amount should influence the posture.

Conclusion
If you take one thing from pulling this apart, let it be this: the authorization is the easy part, and the interesting engineering lives entirely in the unhappy paths. Anyone can code the approval. The platforms worth building are the ones that handle the timeout you cannot interpret, the hold that quietly expired, the retry that must not become a double charge. Spend your design energy there. The green "Approved" screen is a lie of omission, and your job is to make the omitted part boring enough that the customer never has to learn how much was really going on underneath their thumb.
Get new posts in your inbox
Occasional, practical notes on engineering leadership, fintech, and building with AI. No spam, unsubscribe anytime.
Comments (0)
Leave a Comment
No comments yet. Be the first to comment!

