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 --> [*]
The payment moves on the class of the latest attempt result, and only an inquiry moves it out of blocked

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.

ClassExample responsesWho may act nextNext attempt
soft05 do not honour, 51 insufficient funds, 91 issuer unavailablethe systemyes, while the budget lasts, on a gateway not yet tried
hard41 lost card, 43 stolen card, 14 invalid card numbernobody, on this instrumentnever; the payment becomes needs_new_method
customer action54 expired card, CVC mismatch, 3-D Secure challenge abandonedthe customeronly with a changed method fingerprint
unknowntimeout, HTTP 5xx, malformed bodyan inquiry jobnot 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.

  1. Attempt 1 opens

    openAttempt(p_8841) finds no attempts, writes attempt 1 with key p_8841:1, picks gateway A and sends 4990 minor units of EUR.
  2. 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.
  3. Attempt 2 opens elsewhere

    The same checkout request calls openAttempt again. It sees one soft decline, budget remaining, and gateway A excluded, so attempt 2 goes to gateway B with key p_8841:2 and the same method fingerprint.
  4. 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.

TypeScript
// 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
One mapping error turns a lost response into a soft decline, and the late answer becomes a second hold

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.

Written by Md Nasim Anjum, senior full-stack engineer in Manchester. He builds payment orchestration, KYC and KYB compliance platforms and conversational AI.

Get in touchAll writingRSS

More writing