FailureDOMAIN-SPECIFICSTAGE-SPECIFICILLUSTRATIVE

What If Payment Fails?

"Payment failed" is not one event. The card can be declined, the provider can time out, the browser can close after the charge, the confirmation can arrive twice. The question that sorts them is: who says the payment succeeded, and how does my system find out?

The moveWorked exampleNext questions▶ Debugging Lab

The situation, the reflex, and why it stalls

Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.

The question

The frontend says "Payment failed". What actually happened, which system knows, and what should the order be marked as?

The situation

A customer emailed to say checkout showed "Payment failed" but their bank statement shows the charge. I have a screenshot of the error and a payment id from the provider dashboard and I do not know which of them to believe. My code marked the order failed because the request to the provider threw. I am not sure that means what I thought it meant.

The reflex

Look at the exception. It says the request timed out, so payment failed, so the order is failed — the code did the right thing. Reply to the customer that the charge will drop off, and add a retry around the provider call so that next time it does not time out.

Why it stalls

The retry makes the problem worse. If the first request charged the card and the response was lost, the retry charges it again. The exception was true — the request failed — and the conclusion drawn from it was false — the payment did not fail. Those are different claims and the code treated them as one.

What the reflex produces — and fails to produce
  • The retry makes the problem worse. If the first request charged the card and the response was lost, the retry charges it again. The exception was true — the request failed — and the conclusion drawn from it was false — the payment did not fail. Those are different claims and the code treated them as one.
  • The customer's statement and the provider dashboard were evidence, and the exception was also evidence, and nobody laid them side by side. The debugging stalled at the first explanation that fit the code instead of the first that fit all the facts.
  • The question "who is authoritative for whether this order is paid?" was never asked, so the system has three answers — the browser's, the backend's and the provider's — and they disagree.
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

Precisely enough to apply it to a problem you have never seen — not a slogan.

  • Separate "our request failed" from "the payment failed". They coincide on a declined card and diverge on a timeout, a dropped connection, a closed browser and a backend crash. The first is something that happened to us; the second is something only the provider knows, and the move is to ask the provider rather than infer from our own error (External Systems Fail).
  • Decide, and write down, which system is authoritative for each fact: the provider for "was the card charged", our database for "does an order exist", nobody's browser for anything. Every state transition in the order lifecycle then has a named source, and "the request threw" is not a source (Source of Truth).
  • Find out how the authoritative system tells you. Providers usually offer both a synchronous response and an asynchronous notification — a webhook — and the notification can arrive before, after, twice or never. The order's paid state should be driven by the notification and confirmed by a query, and the synchronous response treated as a hint (Which Dependency Must Answer Before the User Can Be Told Anything?).
  • When a failure report arrives, gather evidence from every layer before choosing a story: what the browser saw, what our backend logged, what the provider records, what the database holds. The story has to fit all four; the debugging lab is built on exactly this (Debugging Is Problem Solving).

Four witnesses, one story

The picture shows why "payment failed" is ambiguous. There are four places the truth could be recorded and two of the arrows can fail independently of the charge. The browser only sees the last arrow; the backend only sees whether its own request returned; the provider sees whether the card was charged; the database holds whatever the backend decided to write.

The board underneath is the unknowns the incident surfaced, each rewritten as a question with an experiment. The first one is the lesson: it is the question the whole design had skipped.

submit checkoutcreate charge (can time out)notification (can repeat, can be late)write order stateresult (browser may be gone)BrowserCheckout backendPayment providerOrders DB
UserLLMAgentToolDataDecisionHumanGuardrail
What the incident revealed we did not know
known
  • Our backend marks an order failed when the provider call throws.
  • The provider has a dashboard showing the charge and a payment id.
  • The customer saw "Payment failed" and was charged.
assumed
  • ~A timed-out request means no charge was made — the assumption the incident falsified.
unknown → question → experiment
  1. ? Who decides the order is paid?

    becomes Which system is authoritative for "this card was charged", and through which channel — response, notification or query — does it reach my backend reliably?

    experiment Read the provider's documentation for the payment lifecycle; then stub the provider to hang and observe what my backend records with no notification handler.

  2. ? Webhooks are confusing.

    becomes Can the provider's notification for one payment arrive more than once, before my synchronous call returns, or not at all — and what does my handler do in each case?

    experiment Send the same test notification twice to the handler and check the order and the payment table afterwards; then send it before the checkout request completes.

  3. ? What if the browser closes?

    becomes Does any state transition in the order lifecycle depend on a request the browser makes after paying?

    experiment Trace the code for the confirmation page; if it triggers a write, cancel the request in the browser and see whether the order still reaches paid.

The question, asked three ways

The debugging lab asks you to gather evidence across the layers before choosing a cause. The question ladder is the thinking version: the same worry sharpened until it names a system and a channel, at which point it has an answer and the answer has consequences for the design.

Question quality
vagueHow do payments work?
betterWhat does the provider return when a payment fails, and how should I handle it?
bestWhich system is authoritative for whether an order has been paid, through which channel does that fact reach my backend, and what state is the order in while I have not heard?

why The best form makes three things answerable that the others do not: it forces a named source, which rules out the browser and the exception; it forces a named channel, which surfaces the notification and its delivery guarantees; and it forces a state for the gap, which is where payment-unknown comes from.

What "payment failed" can mean
TriggerSymptomCauseResponse
Declined cardProvider responds with a refusalThe bank said noOrder to payment-failed, reason shown, cart kept.
Charge succeeded, response lostOur call times out; provider shows the chargeNetwork failure after the provider committedOrder to payment-unknown; status query by stored reference; then paid.
Notification arrives twiceTwo paid transitions, possibly two emails or two stock decrementsProvider retries notifications it cannot confirm were receivedHandler records the notification id and ignores a repeat; transition to paid is a no-op if already paid.
Browser closes after chargeNo confirmation seen; customer may retryConfirmation depended on a client requestPaid state comes from the notification; a returning customer sees the order in their account and the cart is already empty.

How to do it

Most important first.

  • Write the list of things that can be true after a checkout attempt: charged and order paid; charged and order failed; not charged and order pending; charged twice. For each, name which system knows it first.
  • Read the provider's documentation for the specific question "how do I find out the final state of a payment I started?" — not the getting-started guide. The answer is usually a status query plus a notification, and the notification's delivery guarantees are the part to read twice (A Reading Strategy for an Unfamiliar Library).
  • Make the order's paid transition happen in exactly one place, fed by the provider's notification or a status query, never by the browser's confirmation request.
  • Store the provider's payment id on the order before the charge is attempted, so that a lost response can be looked up rather than guessed at.
  • For the timeout case specifically: query, do not retry the charge. Retrying a charge whose result you did not see is how double charges happen (Duplicate Requests).

Worked on a concrete problem

The move has to produce something. This is what it produced.

  • The customer's case, with the evidence side by side: browser shows "Payment failed"; backend log shows the provider call timed out after the configured wait; provider dashboard shows the charge succeeded a moment before the timeout; database shows the order in payment-failed. Only one story fits all four: the charge went through, the response was lost, and our code inferred failure from silence.
  • The authoritative-source table for the store: "card charged" → the provider, learned via its notification, confirmed via status query. "Order exists" → our database. "Customer saw confirmation" → the browser, and nothing depends on it. The order lifecycle now has payment-unknown for the case where our request failed and the provider has not yet told us, and the transition out of it is a status query result, not a timer.
  • The fix, in that order: add payment-unknown; on timeout, mark the order unknown and store the reference; a job queries the provider for unknown orders; the notification handler marks paid and is written to accept the same notification twice (Duplicate Requests). The retry around the charge call is removed, because it was the most dangerous line in the file.

How you know it worked

What now exists that did not before, and what question you can now ask.

  • Every transition in the order's payment state names the system that caused it, and none of them is "the HTTP call threw".
  • A lost response leads to a query, not a guess, and the order has a state that means "we do not know yet".
  • Given a failure report, you gather browser, backend, provider and database evidence before proposing a cause — and the cause fits all of it.

The questions you can now ask

The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.

Next questions
  • ?Which system is authoritative for this fact, and how does it tell mine?
  • ?If my request to it fails, does that mean the thing did not happen, or that I do not know whether it happened?
  • ?Can its answer arrive twice, late, or out of order — and what does my system do on each?
  • ?What do the browser, my backend, the external system and my database each say about this failure, and what single story fits all of them?

What can go wrong

How the move itself fails
  • Every payment fact is routed through the provider and the checkout page waits for a webhook to render anything, so a slow notification means a blank screen. The synchronous response is still useful as a hint for what to show; it is just not the source of the state.
  • Payment-unknown becomes a place orders go to die. The state needs a job that resolves it, an admin view that lists it, and a bound on how long an order may stay there; a state with no exit is an illegal state with a nicer name (States That Must Be Unrepresentable).
  • The evidence-gathering is applied to every error, including ones whose cause is obvious from one log line. The full four-layer sweep is for the cases where the layers disagree.
What the move costs
  • Driving state from notifications means building and securing a webhook endpoint, handling its retries and living with the delay between charge and confirmation. A store that polls the provider on a timer is simpler and a little slower.
  • A four-layer evidence sweep per incident is slow, and most incidents have a one-layer cause. The discipline pays for itself on the ones that do not.
Misreads
  • "The provider is authoritative, so I should trust its synchronous response." The provider is authoritative; its synchronous response is one channel to it, and that channel can fail. Trust the provider by asking it again through a channel that did not fail.
  • "Idempotency keys solve this." They stop the retry from charging twice; they do not tell you the state of the first attempt. You need both the key and the query.
  • "So the browser should never be trusted." The browser is trustworthy about what the customer did — clicked, closed the tab — and untrustworthy as a source of what the provider did. It is not distrust; it is knowing what each layer can witness.

Where this applies

Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.

  • DOMAIN-SPECIFICSpecific to boundaries where the external action has a lasting effect — a charge, an email, a file written. For a read-only dependency such as a currency-rate service, a failed request really does mean no rate was obtained, and the distinction collapses.
  • STAGE-SPECIFICOn a prototype with only test-mode payments, marking timeouts as failed costs nothing and the whole apparatus can wait. It has to exist before the first real card, because after that the gap costs money on every timeout.
  • ILLUSTRATIVEThe customer email, the log lines and the provider dashboard are invented to show the shape of the evidence; real providers differ in what they expose and how notifications behave.

Where the depth lives

This domain asks the question and hands the answer off by name.