Frontend Checkout UX & Dunning Recovery Flows

The checkout and recovery surface is where a SaaS billing system meets the messiest part of reality: flaky networks, issuer declines, expired cards, 3DS step-up challenges, and customers in a hundred jurisdictions paying in a dozen currencies. Get it wrong and you bleed revenue twice — once when a legitimate charge is declined and again when a customer abandons a confusing checkout. This subsystem spans the browser (Payment Element Integration), the vault (Secure Card Vaulting & Tokenization), the recovery engine (Grace Period & Retry Logic), the trust layer (Fraud Prevention & Dispute Management), the localization layer (Multi-Currency Checkout & Localization), and the account UI (Customer Portal & Self-Service).

The governing principle is that the frontend renders state but never owns it. The browser is hostile, lossy, and replayable; the only authoritative source of truth is the server-side ledger reconciled against gateway settlements. Every flow below is designed so that a dropped connection, a double-tapped button, or a retried webhook converges to exactly one correct financial outcome. This reference targets full-stack engineers, fintech developers, and product teams building revenue-critical checkout and recovery infrastructure.

The second governing principle is that this surface has two masters that pull in opposite directions: conversion and correctness. Product wants the fewest fields, the fastest path, and the most optimistic UI; finance wants every amount reconciled and every attempt logged. The architecture resolves the tension by putting all the optimism in the presentation layer and all the pessimism in the money layer. The browser can show a spinner and an instant “you’re subscribed!” the moment the customer taps, because the actual truth is being established asynchronously server-side and the UI will reconcile to it. This split lets you tune conversion aggressively — one-tap wallets, saved cards, localized pricing — without ever putting revenue correctness at risk, because no UI affordance is allowed to be the thing that decides whether money moved.

A third principle governs the recovery side specifically: recovery is a revenue channel, and like any channel it has a marginal cost that eventually exceeds its marginal return. Every retry consumes gateway approval-rate reputation, every dunning email consumes sender reputation and customer goodwill, and every day of extended grace consumes service you may never be paid for. The engineering instinct to “try harder” — more retries, more emails, longer grace — is exactly wrong past a point that data, not intuition, must locate. The architecture supports this by making every recovery action a logged, attributable event, so you can measure recovered revenue against reputational and goodwill cost per action and stop where the curve turns negative. A recovery system that cannot tell you the marginal return of its third retry is flying blind, and the default failure mode of flying blind is over-trying, because the cost is diffuse and the occasional recovery is visible.

These three principles — state lives on the server, optimism lives in the UI, and recovery is a cost-bounded channel — are not independent rules to memorize but three faces of one discipline: keep the authority for money on the server, and treat everything the customer sees or the retry engine does as a tunable surface that can be aggressive precisely because it is never authoritative. Every specific technique in the pages below is an application of that discipline to one corner of the surface.

There is a measurement discipline that keeps this whole surface honest: instrument the funnel end to end and attribute every drop-off to a cause you can act on. Checkout has a conversion funnel (view → card entered → 3DS challenge → authorized → subscribed) and recovery has one too (failed → notified → card updated → re-charged → recovered), and each step is a place customers and revenue leak. The failure of most billing frontends is not that any single step is bad but that no one measures where the leak is, so effort goes to redesigning the button while the real loss is a 3DS challenge firing on trusted customers or a dunning email landing in spam. Wire per-step analytics from day one, segment by card BIN, country, and device, and the surface tells you where to spend — a drop concentrated at the 3DS step points at over-aggressive step-up routing, a drop at card-entry points at a UX or trust problem, a drop at re-charge points at decline-code routing. The architecture makes this measurable precisely because every step is a discrete, logged server event rather than an opaque browser interaction.

The last thing worth internalizing is that this surface is adversarial in a way the backend is not. The browser is a hostile environment: customers double-tap, lose signal, run extensions that mangle the DOM, and — a small fraction — actively try to test stolen cards against your checkout. Every design choice here has to hold up not just under the happy path but under a client that is buggy, flaky, or malicious. That is why authority lives on the server, why idempotency keys are generated before the first attempt and reused across retries, why fraud scoring gates the risk tail, and why no UI state is ever trusted as a financial fact. Treat the frontend as an untrusted input source that you render state to and accept intents from, never as a participant you can rely on to behave, and the whole surface becomes robust against the messiness that is the browser’s native condition rather than fragile in the face of it.

Architecture Overview

A payment moves through six logical stages: capture in the browser, tokenization at the vault, authorization at the gateway, settlement, ledger posting, and — on failure — recovery. The frontend touches only the first stage and the read-only projection of the last. Everything financially meaningful happens server-side behind a signed webhook bus and an idempotency gate.

Checkout and recovery flow Card data is tokenized in the browser, authorized at the gateway, posted to the ledger, and failed charges route into a dunning recovery loop. Browser Payment Element Vault + tokenization Gateway authorization Webhook bus + idempotency Double-entry ledger Dunning + retry engine Self-service portal
Card data is tokenized before it leaves the browser; failed authorizations loop through the dunning engine and back to the customer via the self-service portal.

The data flow decouples synchronous user actions from asynchronous settlement. The table below summarizes which service owns each responsibility and what it must never do.

Service Owns Must never
Browser / Payment Element UI state, token request Hold raw PAN, decide charge outcome
Vault Token-to-PAN mapping, network tokens Expose PAN to app servers
Gateway Authorization, capture, SCA routing Be trusted without signature checks
Webhook bus Ordered, deduplicated delivery Mutate ledger directly
Ledger Authoritative balances, double-entry Accept un-reconciled writes
Dunning engine Retry scheduling, decline routing Retry hard declines

Reading the table as a whole, the pattern is that each service is trusted with exactly one authority and explicitly forbidden the authority that belongs to the next layer in. The browser may request a token but may not decide an outcome; the gateway may authorize but may not be believed without a signature check; the webhook bus may deliver but may not touch the ledger. This layering is what lets the frontend be as fast and optimistic as product wants while keeping the blast radius of a compromised or buggy browser at zero — the worst a malicious client can do is request tokens and lie about what it saw, neither of which moves money, because the money-moving decisions all live behind the signed webhook boundary. When you add a new capability to this surface, the design question is always “which single service owns this, and which services must be forbidden from it?”

Core Data Model

The domain centers on three entities: the tokenized payment method (no PAN), the payment attempt (one row per authorization try), and the dunning campaign that sequences retries. Storing every attempt, not just the final outcome, is what makes recovery analytics and dispute defense possible. The relationships below show the attempt as the audit spine.

Checkout core data model Payment methods hold tokens not PANs, each invoice generates payment_attempts keyed by an idempotency key, and a dunning campaign sequences retries. payment_methods gateway_token brand, last4 (no PAN) payment_attempts idempotency_key UNIQUE decline_code, advice dunning_campaign retry schedule email touchpoints
The payment attempt is the audit spine — one row per try, keyed by an idempotency key that makes a double submit a no-op.

Storing every attempt rather than a single mutable status on the invoice is the decision that pays dividends across three later problems. Recovery analytics needs to know which retry on which day with which decline code eventually succeeded, so you can tune the schedule — impossible if you overwrite. Dispute defense needs the full authorization history, including the 3DS outcome and the device signals, to contest a chargeback — impossible if you kept only the last row. And debugging a “why was this customer charged twice?” ticket needs to see both attempts side by side with their idempotency keys, which is trivial when every try is a row and maddening when it is not. The attempt table is therefore append-mostly: rows are inserted and their terminal status is set once, never recycled.

The network_token_id column carries a subtlety that quietly protects your recovery rate: network tokens (and the card-network account-updater services behind them) keep a saved card working across re-issues and expiries. When a customer’s bank sends a new card, the updater refreshes the token behind the scenes, so a subscription that would otherwise fail at renewal with an expired_card decline simply keeps charging. Storing the network token reference — not just the raw gateway token — is what lets you benefit from this, and it is one of the highest-leverage things you can do for involuntary-churn reduction, because a meaningful fraction of dunning failures are nothing more sinister than a reissued card.

-- Tokenized payment instruments; no PAN ever stored here.
CREATE TABLE payment_methods (
  payment_method_id  UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id        UUID         NOT NULL REFERENCES customers(customer_id),
  gateway_token      TEXT         NOT NULL,          -- vault reference only
  brand              TEXT         NOT NULL,          -- visa, mastercard, amex
  last4              CHAR(4)      NOT NULL,
  exp_month          SMALLINT     NOT NULL,
  exp_year           SMALLINT     NOT NULL,
  is_default         BOOLEAN      NOT NULL DEFAULT false,
  network_token_id   TEXT,                           -- updater-maintained
  created_at         TIMESTAMPTZ  NOT NULL DEFAULT now()
);

-- One row per authorization attempt; the recovery audit trail.
CREATE TABLE payment_attempts (
  payment_attempt_id UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  invoice_id         UUID         NOT NULL REFERENCES invoices(invoice_id),
  payment_method_id  UUID         NOT NULL REFERENCES payment_methods(payment_method_id),
  idempotency_key    TEXT         NOT NULL UNIQUE,
  amount_minor       BIGINT       NOT NULL,          -- integer cents
  currency           CHAR(3)      NOT NULL,
  status             TEXT         NOT NULL,          -- pending|succeeded|failed
  decline_code       TEXT,                           -- e.g. insufficient_funds
  network_advice     TEXT,                           -- issuer retry hint
  attempted_at       TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX idx_attempts_invoice ON payment_attempts(invoice_id, attempted_at);

Money is always an integer minor unit (amount_minor BIGINT) — never a float. The idempotency_key unique constraint is the single hardest guarantee in the schema: it makes a double-submitted checkout a no-op at the database layer rather than a duplicate charge. The decline_code and network_advice columns feed the retry scheduler covered in Smart Retry Timing With Card Issuer Decline Codes.

Key Design Patterns

Four patterns carry the checkout-and-recovery surface: idempotency keys on every charge, a recovery FSM, optimistic UI reconciled against the server, and decline-code routing. The matrix maps each to the failure it prevents.

Checkout design patterns Idempotency keys prevent double charges, the recovery FSM prevents illegal state, optimistic UI reconciliation prevents trusting the click, and decline routing prevents retrying hard declines. Idempotency key prevents double charge every charge Recovery FSM prevents illegal state race safety Optimistic UI prevents trusting click reconcile Decline routing prevents retry hard decline soft vs hard
Four patterns, four prevented failures — idempotency and decline routing are the two that directly protect revenue.

Idempotency keys on every charge

Every authorization carries a client-or-server-generated key persisted before the gateway call. A unique constraint turns retries into idempotent no-ops.

async function chargeOnce(invoiceId: string, key: string, amountMinor: number) {
  const inserted = await db.query(
    `INSERT INTO payment_attempts (invoice_id, idempotency_key, amount_minor, status)
     VALUES ($1, $2, $3, 'pending')
     ON CONFLICT (idempotency_key) DO NOTHING
     RETURNING payment_attempt_id`,
    [invoiceId, key, amountMinor]
  );
  if (inserted.rowCount === 0) return; // ✅ already in flight or done — no double charge
  await gateway.authorize({ idempotencyKey: key, amountMinor });
}

Use whenever a network round-trip to a payment gateway can be retried — which is always. The key must be generated and persisted before the gateway call, not after, because the failure you are defending against is precisely the one where the gateway charged the card but the response never reached you — a timeout after authorization. If you generate the key only on success, that timeout leaves you with a charged customer and no record, and the “retry” creates a genuine double charge. Persist first, call second, and pass the same key to the gateway so its own idempotency layer collapses the duplicate on its side too. Scope the key to the specific charge intent (invoice plus attempt number), never to the whole session, so a legitimate later retry of the same invoice reuses the key while a genuinely new charge gets a fresh one.

Finite state machine for recovery

Subscription and invoice recovery is a strict FSM: active → past_due → (retrying)* → recovered | canceled. Invalid jumps are rejected at the API edge before they reach the ledger.

const TRANSITIONS: Record<string, string[]> = {
  active:    ['past_due'],
  past_due:  ['retrying', 'recovered', 'canceled'],
  retrying:  ['retrying', 'recovered', 'past_due', 'canceled'],
};
function canTransition(from: string, to: string) {
  return TRANSITIONS[from]?.includes(to) ?? false; // ✗ reject anything else
}

Use whenever concurrent webhooks and portal actions can race on the same subscription. The recovery FSM has a property the billing-lifecycle FSM does not: it is driven by two uncoordinated inputs — asynchronous gateway webhooks and synchronous customer portal actions — that can fire within milliseconds of each other. A customer updating their card in the portal at the exact moment a scheduled retry fires is not a rare race; at scale it happens constantly. Guard every transition with the same optimistic version check the subscription row carries, so whichever write lands second re-reads state and re-decides rather than blindly applying. The states themselves should be few and unambiguous — resist adding “retrying_but_also_updating” hybrids — because every state you add multiplies the transition table you must reason about, and an ambiguous state is where the double-charge and lost-recovery bugs hide.

Optimistic UI with server reconciliation

The portal reflects an action instantly, then reconciles against the authoritative result, rolling back the view on mismatch.

setView('updating');                                  // optimistic
const res = await api.updatePaymentMethod(token);
setView(res.confirmed ? 'saved' : 'error');           // ⚠️ reconcile, never trust the click

Use for portal interactions where perceived latency matters but correctness is server-owned. The discipline that makes optimism safe is that the optimistic view is always reversible and never financial. Showing “saving…” then “saved” for a card update is fine because the worst case is a rollback to “please try again.” Showing “subscription active” before the charge confirms is not fine, because you cannot un-tell a customer they were charged. The rule of thumb: optimistic UI may run ahead of any state the server can contradict without money having moved, and must wait for confirmation on any state that asserts money moved. In practice this means the checkout button can show instant progress, but the “you’re subscribed” moment waits for the webhook-confirmed success — a distinction customers never notice but auditors and support tickets very much do.

Decline-code routing

Soft declines (insufficient_funds, try_again_later) schedule retries; hard declines (stolen_card, do_not_honor) suspend and notify without retry.

HARD = {"stolen_card", "lost_card", "fraudulent", "do_not_honor", "pickup_card"}
def should_retry(decline_code: str) -> bool:
    return decline_code not in HARD  # ✗ never retry a hard decline

Use to keep retry pressure off issuers and avoid fraud-flag escalation.

The distinction is not cosmetic — it is the difference between recovering revenue and manufacturing fraud flags. A soft decline like insufficient_funds means “not right now,” and a retry two days later (after payday, statistically) has a real chance of succeeding. A hard decline like stolen_card means “never, and stop asking”; retrying it hammers the issuer with what looks like card-testing behavior, escalates your account’s risk score with the card networks, and can get your merchant account reviewed. Some codes are ambiguous (do_not_honor is a catch-all issuers overload), so the safe policy treats unknown codes as soft but caps their retry budget hard. The routing table should be data, not code — a decline_code_policy row per code with retry_allowed, max_attempts, and backoff — so a bank changing its behavior is a config edit, not a deploy, and so the policy is auditable when a network asks why you retried.

Compliance & Regulatory Boundaries

The checkout surface sits inside four regimes: PCI-DSS (kept at SAQ A by tokenization), PSD2/SCA (3DS2 step-up plus MIT/COF exemptions for retries), GDPR (minimized card metadata), and VAT/GST at checkout. The map shows each and how the architecture satisfies it.

Checkout compliance regimes PCI-DSS stays at SAQ A via tokenization, PSD2 SCA uses 3DS2 and MIT/COF exemptions, GDPR minimizes card metadata, and VAT/GST resolves at checkout. PCI-DSS tokenize, no PAN stay SAQ A PSD2 / SCA 3DS2 step-up MIT/COF exemptions GDPR brand/last4/exp only erasure vs retention VAT / GST at checkout before finalize
Four regimes at the checkout edge — tokenization and SCA exemptions are the two the architecture must get right.

PCI-DSS scope is minimized by keeping raw PAN out of application servers entirely — hosted fields and tokenization keep most SaaS deployments at SAQ A. PSD2/SCA in the EEA requires 3DS2 step-up for many transactions; off-session retries rely on merchant-initiated-transaction (MIT) and credential-on-file (COF) exemptions so the dunning engine does not trigger a customer challenge it cannot satisfy. GDPR mandates data minimization on the stored card metadata (brand, last4, expiry only) and right-to-erasure on non-financial customer data, while financial records remain on an immutable audit trail for the statutory retention window. VAT/GST obligations attach at checkout based on the customer’s location, which is why localized pricing and tax must resolve before invoice finalization — see Multi-Currency Checkout & Localization.

The exemption mechanics deserve care because they carry liability. When you charge a saved card off-session during dunning, you claim an MIT/COF exemption to skip the SCA challenge the customer is not present to complete. That claim is only valid if the original on-session payment established the mandate with proper authentication and you stored the reference proving it. Skip that setup and your off-session retries either get declined for missing authentication or, worse, succeed but leave you holding liability for any dispute because you cannot demonstrate the mandate. The practical rule: treat the first payment as a compliance event, capture and persist its authentication artifacts (the mandate ID, the 3DS result, the exemption basis), and carry them forward so every subsequent renewal can prove its right to run unchallenged. This is the connective tissue between the checkout surface and the subscription billing engine that records which authentication path each charge took.

Scalability & Failure Modes

The scale dangers are a renewal thundering herd, a synchronized retry wave that trips issuer velocity limits, and a slow-gateway cascade that starves checkout. Each has a containment — jittered renewals, per-issuer retry jitter, and a circuit breaker with a secondary processor. The map pairs them.

Checkout scale failures Renewal herds need jittered scheduling, retry waves need per-issuer jitter and advice codes, and gateway degradation needs a circuit breaker with a secondary processor. Renewal herd all at midnight UTC gateway overload → spread + jitter Retry wave synchronized retries issuer velocity trip → per-issuer jitter Gateway cascade slow processor pool exhaustion → breaker + secondary
Three scale failures — the circuit breaker with a secondary processor is what bounds the gateway-cascade blast radius.

At 10k active subscriptions a naive renewal job that fires all charges at midnight UTC creates a thundering herd against the gateway; spread renewals across the billing day and add jitter. At 100k subscriptions the dunning retry queue becomes the bottleneck — a synchronized retry wave can exceed issuer velocity limits and trip card-network fraud heuristics, so retries must carry per-issuer jitter and respect network advice codes. Gateway degradation is the classic cascade: a slow processor causes request pileup, exhausts connection pools, and starves unrelated checkout traffic. A circuit breaker that opens above a 15% failure rate in a rolling 5-minute window, combined with a secondary processor for fail-open routing, contains the blast radius. Failover to a secondary processor is only safe if you designed for it before the outage: the two processors must share (or reconcile) the vault so a token created at one is chargeable at the other, or you keep network tokens that both can present. Cutting over to a processor that cannot see your saved cards turns a degraded-checkout incident into a total-outage incident. Rehearse the failover on a normal day — route a small percentage of live traffic through the secondary weekly — so the path is warm and its quirks (different decline-code vocabularies, different 3DS behavior) are known before you depend on it. A secondary you have never actually charged through is a comforting diagram, not a contingency.

There is a subtler scale property worth naming: checkout traffic and recovery traffic have opposite shapes, and they contend for the same gateway. New-customer checkout is spiky and latency-sensitive — a customer is watching a spinner — while dunning retries are batchy and latency-tolerant, since no one is waiting on a background retry. If both share one undifferentiated worker pool, a nightly retry wave can starve interactive checkout of gateway concurrency at exactly the wrong moment. Separate them: give interactive checkout a reserved, higher-priority path to the gateway, and run recovery through a rate-limited queue that yields to live traffic. This is the same reasoning that puts the thin, latency-critical work on one side of a boundary and the thick, deferrable work on the other — applied to gateway concurrency rather than to your own database. Out-of-order and duplicate webhook delivery is the steady-state condition, not the exception — sequence validation plus idempotency keys keep the ledger correct regardless of delivery order.

There is a scale failure unique to recovery that pure throughput thinking misses: the retry budget is a shared, finite resource with the card networks, not just with your own infrastructure. Every failed authorization you send counts against your merchant account’s approval-rate reputation, and networks penalize merchants whose decline ratio runs high by lowering approval rates for everyone on that account — including your healthy first-time charges. So the dunning engine must be tuned for total recovered revenue net of reputational cost, not for maximum attempts. Concretely: cap attempts per invoice, widen the backoff for repeat failures, suppress retries entirely for codes that will never recover, and monitor your overall approval rate as a first-class metric that a too-aggressive retry schedule can silently degrade. A recovery engine that “tries harder” past this point makes less money, not more.

Operational Runbook

The signals that matter are per-BIN authorization rate, the soft-decline spike, the recovery-rate drop, and webhook lag — each with a page-worthy threshold. The panel summarizes the on-call view before the detail.

Checkout operations signals Per-BIN authorization rate, soft-decline spike over baseline, recovery-rate drop, and webhook lag are the four monitored signals with thresholds. Auth rate per BIN drop in one BIN = issuer issue Soft-decline spike alert > 3× 7-day baseline Recovery rate alert below 30-day average Webhook lag page > 60s at p95
Four signals — the per-BIN auth rate is the one that most often reveals an issuer-side problem before your own code.

Monitor authorization success rate per gateway, per card brand, and per BIN — a sudden drop in one BIN range usually means an issuer-side problem, not your code. Alert when the rolling soft-decline rate exceeds its 7-day baseline by 3x, when the dunning recovery rate falls below its trailing 30-day average, and when webhook processing lag exceeds 60 seconds at p95. Run a daily reconciliation job that matches gateway settlement reports against payment_attempts and flags any settled transaction without a corresponding ledger entry (and vice versa) above a configurable tolerance. The two directions of this diff catch different bugs and both matter. A settlement with no matching ledger entry means money arrived that your books do not know about — usually a dropped webhook — and it is found revenue you must post. A ledger entry with no matching settlement means you recorded a charge the gateway never actually settled — usually an optimistic write that skipped confirmation — and it is phantom revenue you must reverse. A team that reconciles only one direction catches half its errors and trusts a number that is quietly wrong. Alert on the count and the total value of unmatched items separately, because a hundred one-cent mismatches (a rounding bug) and one large mismatch (a dropped high-value charge) are different incidents with different urgency. Keep a runbook entry for “circuit breaker open”: confirm the secondary processor is healthy, drain the retry backlog gradually, and never replay the entire failed queue at once.

Treat recovery rate as a cohort metric, not a single number. Group failed invoices by the week they first failed and track what fraction each cohort eventually recovers over the following 30 days; a healthy engine shows most recovery in the first two retries and a long thin tail after. When a deploy or a schedule change quietly hurts recovery, the cohort curve bends before the headline number moves, giving you an early warning that an aggregate rate hides. Pair it with a decline-code breakdown so you can see why a cohort underperformed — a spike in expired_card points at a broken account-updater integration, a spike in do_not_honor at an over-aggressive retry cadence tripping issuer heuristics. These two views turn “recovery is down” from a mystery into a diagnosis.

Frequently Asked Questions

How do I guarantee a customer is never double-charged when checkout is retried? Persist a unique idempotency key before calling the gateway and pass the same key on every retry. The database unique constraint makes the second attempt a no-op, and the gateway’s own idempotency layer collapses duplicate authorizations. The browser never decides the outcome — it polls the server for the authoritative status.

Should I build my own dunning engine or use the gateway’s built-in retries? Start with the gateway’s smart retries for the common case, but own the scheduling once recovery rate becomes material to revenue. A custom engine lets you route on decline codes, coordinate retries with email touchpoints, and respect per-issuer velocity — see Grace Period & Retry Logic. The one trap when running your own is forgetting to turn off the gateway’s built-in dunning emails and retries, which otherwise run in parallel and double-message customers or double-attempt charges. Own it fully or defer fully; running both is worse than either.

Why store every payment attempt instead of just the invoice’s final paid/unpaid status? Because the history is the asset. Recovery tuning needs to know which retry, on which day, with which decline code, eventually succeeded — so you can shorten or lengthen the schedule with evidence rather than guesswork. Chargeback defense needs the full authorization trail, including 3DS outcomes and device signals, to contest a dispute. And “why was I charged twice?” tickets are answered in seconds when both attempts and their idempotency keys are visible rows. A single mutable status column throws all of that away to save a few bytes.

Where does fraud scoring fit in the checkout flow without hurting conversion? Score at authorization time and reserve step-up authentication for the risk tail rather than every transaction. Risk-based 3DS2 routing keeps friction off trusted customers — covered in Fraud Prevention & Dispute Management.

How do I support multiple currencies without corrupting the ledger? Store every amount in its transaction currency as integer minor units and never convert in the ledger. Display localized prices at checkout but post the settled currency exactly as the gateway reports it. See Multi-Currency Checkout & Localization.

What state should the frontend show during an SCA challenge? Transition to an explicit authentication_required state and hand control to the gateway’s 3DS flow. Do not optimistically mark the charge successful — wait for the webhook-confirmed result before updating the subscription.

How do network tokens and the account updater affect involuntary churn? They quietly recover a large share of it. When a customer’s bank reissues a card, the card network’s account-updater service refreshes the network token behind your saved payment method, so a renewal that would have failed with expired_card keeps succeeding without the customer doing anything. Storing the network-token reference and enabling the updater is one of the cheapest, highest-leverage reductions in dunning volume available — a meaningful fraction of “failed” cards are simply reissued ones.

Should the browser ever call the gateway directly, or always go through our server? The browser talks to the gateway only for the operations designed for it — tokenizing card data in a hosted field and completing a 3DS challenge — so raw card data never touches your servers and you stay at SAQ A. Every financial decision (creating the charge, deciding success, updating the subscription) goes through your server, which owns the idempotency key and the ledger. The split is deliberate: the browser handles the PCI-sensitive capture, your server handles the money-moving truth.

How should checkout behave on a flaky mobile connection? Assume the network will drop mid-request and design so that a drop is never ambiguous. The client generates the idempotency key before the first attempt and reuses it on every retry, so a customer who taps, loses signal, and taps again cannot create two charges. On reconnect, the client polls the server for the authoritative status of that key rather than re-submitting blind. The UI should show a determinate “confirming your payment…” state that resolves from the server, never an optimistic success that a failed charge then has to walk back. Mobile is where double-charges and phantom successes are born, and the cure is the same idempotency-key discipline the rest of the system already uses — extended out to the client so the retriable unit is the whole checkout intent, not just the server-side charge.