WritingPayments
A decline ends the attempt, not the payment
Model each try at a payment as its own attempt, classify the decline before you retry, and never let a lost response open a second attempt.
A customer in Lisbon pays EUR 49.90 for a pair of trainers. The checkout sends the card to gateway A, and 1.8 seconds later the response is a decline with issuer code 05, do not honour. The page shows "payment failed". The customer types the same card number again, slower this time, and the second request goes out to the same gateway at 14:02:31. This one is approved. Your database now holds two rows that look like two payments for one pair of trainers, and nothing links them except a customer id and a pair of timestamps a minute apart.
That is the shape I want to argue against. A decline is the end of one attempt, never of the payment. A payment is what the customer owes: an amount, a currency, an order. An attempt is one request to one gateway with one payment method, and a payment may need several before it is authorised or given up on. Whether a second attempt may open does not depend on how many times the customer presses the button. It depends on the class of the decline the first attempt returned, and on nothing else.
One payment, many attempts
The payment row carries amount_minor, currency, order_id and a state: open, attempting, authorised, awaiting_customer, needs_new_method, blocked or failed. It never carries a gateway reference or a response code. Those belong to the attempt row: payment_id, attempt_no, gateway, method_fingerprint, idempotency_key, sent_at, response_code, decline_class and its own state. The idempotency key sent to the gateway is derived from the payment id and the attempt number, p_8841:2 for the second try, so the gateway can never mistake attempt 2 for a replay of attempt 1, and a true replay of attempt 1 returns the stored decline instead of a fresh authorisation request.
The payment's state is derived from its attempts, the way a balance is derived from ledger entries. One attempt in authorised means the payment is authorised. An attempt in unknown means the payment is blocked, and nothing may open attempt n+1 until it resolves. The checkout, the routing engine and the subscription scheduler all go through one function, openAttempt(paymentId), which reads every existing attempt before it writes a new one.
stateDiagram-v2 [*] --> Open Open --> Attempting: open attempt Attempting --> Authorised: approved Attempting --> Open: soft decline with budget Attempting --> NeedsNewMethod: hard decline Attempting --> AwaitingCustomer: customer action Attempting --> Blocked:::accent Blocked --> Authorised: inquiry says approved Blocked --> Open: inquiry found nothing AwaitingCustomer --> Open: new method Open --> Failed: budget spent Authorised --> [*]
Classify before you decide
The gateway returns a code, and a code is not a decision. Four classes cover every response I have met, and each permits exactly one next step. Gateways report differently: one passes the issuer's code straight through, another wraps it in its own string, a third says only "declined". So the mapping lives in each gateway adapter, which stores the raw code beside the class it chose.
| Class | Example responses | Who may act next | Next attempt |
|---|---|---|---|
| soft | 05 do not honour, 51 insufficient funds, 91 issuer unavailable | the system | yes, while the budget lasts, on a gateway not yet tried |
| hard | 41 lost card, 43 stolen card, 14 invalid card number | nobody, on this instrument | never; the payment becomes needs_new_method |
| customer action | 54 expired card, CVC mismatch, 3-D Secure challenge abandoned | the customer | only with a changed method fingerprint |
| unknown | timeout, HTTP 5xx, malformed body | an inquiry job | not until attempt n resolves |
When an adapter meets a decline string it has never seen, it returns soft with the raw code, never unknown. Unknown has the strongest consequence of the four, a blocked payment, and it is reserved for the case where no answer arrived at all. A decline you cannot classify still tells you the gateway answered, and one more attempt on a different gateway is a cheap way to find out whether the card is usable. I have built routing across 15+ gateways, and the rule I apply is that the routing engine never reads a raw gateway response: it reads a class, a budget and the list of gateways this payment has already tried.
The same payment, done this way
Take the EUR 49.90 again, with a retry budget of two attempts per payment.
Attempt 1 opens
openAttempt(p_8841)finds no attempts, writes attempt 1 with keyp_8841:1, picks gateway A and sends 4990 minor units of EUR.Gateway A declines
The response is 05 after 1.8 seconds. The adapter stores response_code 05, decline_class soft, state declined. The payment goes back to open with one attempt spent.Attempt 2 opens elsewhere
The same checkout request callsopenAttemptagain. It sees one soft decline, budget remaining, and gateway A excluded, so attempt 2 goes to gateway B with keyp_8841:2and the same method fingerprint.Approved
Gateway B authorises. Attempt 2 is authorised, the payment is authorised, and the customer saw one spinner for about four seconds and no error at all.
Had gateway B also returned a soft decline, the budget would be spent, the payment would move to failed, and only then would the checkout show a message and offer another card. A new card changes the method fingerprint, which is the one thing that reopens a failed payment. Presenting the same card through a second acquirer is not a guarantee: issuers weigh who is asking, and some of those second presentations are approved and some are not. The budget caps what that costs.
// simplified: derive the payment's next action from its attempts
type DeclineClass = 'soft' | 'hard' | 'customer_action' | 'unknown';
type Attempt = {
no: number;
gateway: string;
state: 'authorised' | 'declined' | 'unknown';
declineClass?: DeclineClass;
};
type Next =
| { kind: 'done' }
| { kind: 'open'; exclude: string[] }
| { kind: 'wait_customer' }
| { kind: 'inquire'; attemptNo: number }
| { kind: 'fail'; reason: string };
export function nextAction(attempts: Attempt[], budget: number): Next {
if (attempts.some(a => a.state === 'authorised')) return { kind: 'done' };
const pending = attempts.find(a => a.state === 'unknown');
if (pending) return { kind: 'inquire', attemptNo: pending.no };
const last = attempts[attempts.length - 1];
if (!last) return { kind: 'open', exclude: [] };
switch (last.declineClass) {
case 'hard': return { kind: 'fail', reason: 'needs_new_method' };
case 'customer_action': return { kind: 'wait_customer' };
case 'soft':
if (attempts.length >= budget) return { kind: 'fail', reason: 'budget' };
return { kind: 'open', exclude: attempts.map(a => a.gateway) };
default: return { kind: 'inquire', attemptNo: last.no };
}
}Note the order of the checks: an authorised attempt wins over everything, and an unknown attempt blocks everything, before any decline is read.
Where it breaks: the timeout that looked like a decline
Attempt 1 goes to gateway A for EUR 49.90. The HTTP client gives up after 8 seconds. The adapter, written on a quiet afternoon, catches the error and returns a decline with code TIMEOUT. The engine has never seen TIMEOUT, so it classes it soft. openAttempt sees a soft decline and budget remaining, writes attempt 2 with key p_8841:2 and sends it to gateway B, which approves in 1.2 seconds. The payment is authorised and is captured at dispatch that evening.
At 14:03:05 a webhook arrives from gateway A: payment.authorised for reference p_8841:1. The handler finds attempt 1 in declined and the payment already authorised through attempt 2. What the system sees is an event for an attempt in a terminal state, logged as unexpected and dropped. What the customer sees is two holds of EUR 49.90 in their banking app. One of them falls away when the issuer's hold lapses, days later, usually after they have phoned support. If gateway A was set to capture automatically, it is two charges and a refund.
flowchart TD A[Attempt 1 to gateway A] --> T[No response in 8 s] T --> M[Adapter returns declined]:::accent M --> S[Engine classes it soft] S --> B[Attempt 2 to gateway B] B --> OK[Payment authorised] T -.-> W[Late webhook from A] W --> H[Two holds on one card] OK --> H
Three things contain it, and the first is the one that matters. An adapter never returns a decline for a transport error; a timeout, a connection reset or a 5xx becomes an attempt in unknown, which blocks the payment. Second, an inquiry job asks gateway A for the status of p_8841:1 by reference, with backoff, and most of the time has an answer within seconds; if the gateway reports no such request after the retry window, attempt 1 becomes declined with code no_record and class soft, and the payment reopens. Third, the webhook handler treats an authorised event for an attempt it believed declined, on a payment authorised elsewhere, as a defect to be corrected now: it voids the late authorisation and opens a case. The void is code, not a runbook.
Retries nobody is watching
A renewal of GBP 12.00 on a saved card declines with 51 on the first of the month, and there is no customer to try again. The scheduler owns the budget instead of the checkout, and spreads it over days rather than seconds: I would retry after one, three and seven days and stop at four attempts, because a balance that was short on the first may be fine after payday and will not change in the next minute. Some gateways pass through the issuer's own advice on whether and when to retry; when it is present, it overrides the schedule.
The class rule holds harder here, not softer. A hard decline on a renewal, lost or stolen or closed account, ends the series on that card the same hour: the payment moves to needs_new_method and the customer is asked for a new card that day. Card schemes limit how often a declined card may be presented again and charge for excess, and that charge arrives at the acquirer and then at you. A scheduler that retries a hard decline daily for a month is paying to be told the same thing thirty times.
Build the attempt table first, with decline_class as a column the routing engine is not allowed to read around. Then rewrite each gateway adapter's error path so that a missing response produces unknown and nothing else. The retry schedule, the cross-gateway rule and the void on a late webhook all become small once a payment knows how many times it has been tried and why each one stopped.