Routing and reliability
Understand payment-method cascading, classify outcomes, and recover safely from checkout errors.
Routing and reliability
Use FloPay's cascade to try eligible saved payment methods in a controlled order, then handle terminal outcomes and SDK errors without leaving the buyer or an unattended workflow in an ambiguous state.
Who this is for
This guide is for developers building interactive checkout recovery, saved-method rebills, unattended payment jobs, and operational tooling around payment failures.
Design for reliable outcomes
Start by understanding how FloPay selects and charges each eligible method. Then map cascade outcomes and FloPayError values to an explicit retry, buyer action, or terminal state. Keep interactive and unattended recovery paths separate because they have different opportunities for authentication and user input.
New-card failover in Full checkout
In Full checkout, the buyer enters the card once. FloPay can then walk that new card from Stripe to Worldpay or from Worldpay to Stripe when the frozen route contains another eligible gateway. The buyer is not shown the first refusal while a safe walk continues.
The public checkout lifecycle uses Session_process to process the session, Session_getStatus to read its progress, and Session_threeDsComplete after a challenge. Moving to another processor requires provider evidence that the refused payment is closed and unpaid.
This new-card walk has its own stop rules. A success or a 3DS challenge stops the walk. It also stays on the current route for any pending or uncertain outcome, card data the buyer must fix, failed authentication, insufficient funds, card limits, risk blocks, or card-wide refusals. These rules are separate from the saved-method decline table below.
When the newest outcome is a decline or error and another safe route remains, Session_getStatus can withhold that attempt during a short, bounded hold that lapses on its own. A success or a 3DS challenge surfaces at once.
Failover boundaries
Legacy Stripe Elements and wallet payments do not cascade. There is no separate merchant-facing Full-mode kill switch; per-gateway cascadeEnabled is the only control. New-card first attempts can be divided between processors by share; see Traffic split.
Route a payment attempt
When a returning customer checks out with their saved payment methods, FloPay doesn't bet the whole sale on a single card. The auto-checkout cascade walks the customer's saved payment methods, preferred card first and across the eligible gateways before it falls back to an older card, retries the same card when that makes sense, self-heals a stale saved card by refreshing it through the vault, and stops the moment a charge succeeds: or the moment continuing would do more harm than good.
This guide explains how that works: how saved methods are chosen, the two ways each one is charged, exactly which declines move on to the next card versus stop the run, the interactive and unattended modes, and what you can observe when a cascade runs.
Availability. The interactive cascade described here is the live path for returning-customer auto-checkout: creating a session with checkoutMode=auto walks the customer's saved methods through the cascade in production: it is no longer a single attempt on one saved card. unattended mode remains reserved (see Modes).
Overview
The cascade is a single run over a returning customer's vaulted payment methods:
- Select the customer's active, vaulted payment methods, ordered by their preferred card first, then the card used successfully most recently, then the newest.
- For each method, attempt the charge: using the saved provider token directly first, then, for cards, refreshing the token through the vault as a self-heal. The first card is walked across the eligible gateways before the run moves on to an older card.
- Classify the result. A success ends the run. Some declines mean "this card is a lost cause, try the next one"; others mean "stop the whole run." A 3DS challenge pauses the run and hands a redirect URL back to the SDK. A transient error retries the same card.
- Stop on the first success, a terminal decline, a 3DS pause, or once every eligible method has been tried.
Selection
The cascade only considers a customer's saved payment methods that are:
- Theirs: tied to the checkout session's signed-in customer. A guest session with no customer has nothing to cascade over and ends immediately.
- Active: methods the customer has removed, or that have been revoked, are skipped.
- Vaulted: only methods held in the vault can be charged again off-session.
Eligible cards are ordered by the customer's preferred card first, then the card used successfully most recently, then the newest. Only a handful of cards are walked in a single cascade; a customer with a long history of saved cards won't have every one tried on a single checkout.
The cascade then walks that first card across the eligible gateways before it moves on to older cards. After a decline that is definitively closed and safe to retry, the same card is attempted on the next still-eligible gateway from the order the payment snapshotted, reusing the binding already prepared on that account where one exists. Older cards are considered only once that walk is done, and each older card is tried on the first still-eligible gateway only.
A gateway that was snapshotted but has since become ineligible is skipped, never charged, and never appended outside the frozen order. When you name a specific saved card, the cascade walks that card across the order and does not fall through to older cards. A 3DS challenge, a failed authentication, and a non-retryable or card-wide decline still stop the run, whichever gateway raised them.
Cards saved before gateway-agnostic identity that have not been adopted keep their released binding-first selection. See Saved cards are gateway agnostic.
Gateway selection
FloPay enables gateway-agnostic saved cards per account, so the behavior described in this section and in Preference order applies once your account is enabled. Until then your saved-card payments keep routing to the account each card is already on. See Saved cards are gateway agnostic.
A saved card is one Flo card, not a card tied to the gateway account that first took it. Choosing the card and choosing the gateway are two separate decisions, and FloPay makes the gateway decision fresh for every new payment. This section covers saved cards. A new card the buyer enters in checkout can be divided between card processors first: see Traffic split.
FloPay first removes every gateway that cannot serve this payment at all:
| Excluded when | Why |
|---|---|
| The gateway belongs to another merchant | A payment never crosses a merchant boundary. |
| Its environment does not match the payment's environment | A stage gateway never serves a production payment, and the reverse. |
| It is not active | Draft and inactive gateways are never charged. |
| Its provider is not supported for this payment | Only providers certified for the payment's card path stay in the pool. |
| It is explicitly restricted for this payment | A restriction recorded against the payment removes the gateway. |
Its session mode (sessionMode) is wrong for the pool | Buyer-present payments use both or on_session; merchant-initiated payments use both or off_session. See Gateway settings. |
It would take a later attempt and cascadeEnabled is false | The first-attempt gateway stays in the route regardless. Every later gateway must be enabled for failover. |
| Its enabled-method list does not offer cards | A gateway configured without cards cannot take a card payment. |
| It holds no provider credential | A gateway with no stored secret credential cannot be charged. |
The frozen route always keeps the first-attempt gateway, regardless of its cascadeEnabled value, then keeps only later gateways whose cascadeEnabled value is true. A new gateway's default is off, so a merchant who has not enabled another gateway for failover gets a single charge attempt today. Configure the setting under Use for cascading (failover) or through Failover eligibility.
The survivors are then ordered. Payment context comes first, merchant preference second:
- Context tier. A gateway matching both the payment's country and currency ranks ahead of one matching only the country, which ranks ahead of one matching only the currency, which ranks ahead of one matching neither. When the payment carries neither a country nor a currency, every survivor sits in the same tier.
- Merchant preference. Within a tier, the gateway's
prioritydecides: lower ranks first, and a gateway that has never been given one ranks 0. Set it in Settings > Gateways or through the gateway settings API. - Tie-breaks. Equal tier and equal
priorityfall back to the oldest gateway first, then to the lower gateway UUID, so the order is stable across requests.
The country and the currency are read from the payment itself, never inferred from each other:
- The country comes from the persisted interactive checkout address. A one-off API payment has no checkout address, so it carries no country and routes without one.
- A Flo renewal uses its originating checkout snapshot when it has one, and otherwise routes without a country.
- Currency never implies a country, and a country never implies a currency.
Card selection is independent of gateway selection. A saved-method UUID identifies its Flo card; it does not select the gateway that card was first saved on. The chosen gateway reuses that card's existing binding when it has one, and otherwise prepares one from the reusable credential FloPay already holds, so the customer never re-enters the card. The billing API owns the gateway decision end to end: there is no header, field, prop, or SDK setting that lets a client choose, filter, or declare one.
Cross-processor card reuse
Once a first consented charge is on record, the same Flo card can be charged on Stripe or Worldpay when the selected account and purpose are approved. FloPay carries the card network's transaction reference forward. When Worldpay needs a binding for a card first charged elsewhere, FloPay prepares it from the PCIVault credential it already holds, with no provider call and no Worldpay tokenization.
Quoting an external transaction reference to Worldpay requires externalSchemeReferenceApproved. Operations records it only after a Secure Test exercise on that Worldpay merchant code; omission keeps it off. Without recorded reuse consent, a Stripe-saved card prepared on Worldpay is buyer-present only.
For the reverse Worldpay to Stripe off-session route, the Stripe gateway needs stripeMitExemptionApproved. Only Operations may set it, and omission keeps it off. These two approvals are independent. See Saved cards and Flo-managed subscriptions for the Worldpay purpose and certification gates.
Eligibility is checked again immediately before the charge reaches the provider. If the selected gateway has become ineligible in the meantime, FloPay does not charge it and does not silently move the payment to another account: it reconciles the operation on the account it already committed to.
Preference order
Your preference order is a ranking of the gateway accounts you have already connected, set in Settings > Gateways or with priority on the gateway settings API. It decides between accounts that are equally eligible for a payment; it never promotes an account that the payment context has already ruled out. When a buyer enters a new card and you run more than one card processor, a traffic split can decide the processor first.
A preference or failover eligibility change takes effect on the next new payment. Before FloPay makes any charge-capable request to a provider, it durably snapshots that payment's routing decision: the frozen order keeps the first-attempt gateway plus only cascade-enabled gateways, along with the Flo card and the selected account, which is the account committed for the provider operation currently in progress. Every retry, duplicate request, recovery worker, and late completion for that same payment resumes the snapshot it was given.
That gives four guarantees worth stating plainly. Changing your preference order:
- does not re-route a payment already in flight. A payment that has started keeps the snapshot it started with. When a definitively closed, safe-to-retry decline advances it, it advances only to the next gateway in that frozen order, so reordering mid-attempt cannot introduce an account outside the snapshot, and only the committed account is ever charge-capable at a time.
- does not rewrite historical attribution. Payments already processed stay reported against the account that processed them. Reporting, payouts, and reconciliation for past payments are unaffected.
- does not migrate provider-owned subscriptions. A subscription the provider bills directly keeps its provider lifecycle and keeps charging where it lives. Preference order applies to payments FloPay initiates.
- does not bypass 3DS. Prior setup on one account is friction reduction on that account only, never portable approval. If an issuer asks for a challenge on the newly preferred account, the customer is challenged.
Captures, voids, refunds, inquiries, and replays are continuations, not new payments, so they stay on the account that took the original charge whatever your preference order says.
Traffic split
When you run more than one card processor, a traffic split divides new-card checkouts between them. It decides which processor takes the first attempt when a buyer enters a new card in checkout. The saved-card routing described above does not change: saved-card payments, renewals, dunning, and POST /v1/payments charges are never split. PayPal is not a card processor, so it never takes a share.
You set the split with trafficSplitPercent on each gateway, in Settings > Gateways or through the gateway settings API. FloPay then allocates each new checkout in three steps:
- One account for each card processor. For every processor, FloPay picks the one gateway that best matches the checkout, using the same context tier,
priority, and tie-breaks as Gateway selection. Your country lists choose which account represents a processor. They never give a processor a second share. A processor with no account in the checkout's currency competes through its best-matching fallback account when it can charge that currency. - The share on those accounts. Each processor's weight is the share on the account that represents it. Shares are relative: 9 and 1 divide traffic exactly as 90 and 10 do, and FloPay does not require them to total 100. The Dashboard warns when a currency's shares do not total 100 or when only one processor holds a share. Those warnings are advisory and never block a save.
- The choice. The checkout is allocated to one processor in proportion to those weights.
A gateway whose share is 0, null, or not set takes no weighted traffic. It is fallback only while another processor holds a positive share. When no competing processor holds a positive share, nothing is split and your preference order applies as before.
For a merchant that gives every Stripe account a share of 90 and every Worldpay account a share of 10:
| Checkout | Stripe is represented by | Worldpay is represented by | First attempt |
|---|---|---|---|
| GBP | Stripe GBP account, share 90 | Worldpay GBP account, share 10 | Divided 90 to 10 |
| EUR | Stripe EUR account, share 90 | Worldpay EUR account, share 10 | Divided 90 to 10 |
| USD | Stripe USD account, share 90 | Worldpay USD account, share 10 | Divided 90 to 10 |
| AUD, where Stripe has no AUD account | Stripe's best-matching fallback account, share 90 | Worldpay AUD account, share 10 | Divided 90 to 10 |
Each currency is divided by the shares on its own accounts, so you can change the split for one currency without touching the others.
One checkout, one first processor. The allocation is fixed for the checkout session. Every step of that session resolves the same processor: creating the session, loading the card form, and charging the card. Which of that processor's accounts takes the charge still follows the account rules. A returning buyer who has already paid on one of your Stripe accounts is charged on that account again, and a change of billing country can move the charge to another of your Stripe accounts. Neither rule moves a checkout between processors. Changing a share affects new checkout sessions, never one already in progress.
Failover is separate. The split sets the first attempt. Whether a processor also receives the other processor's declines is controlled on each gateway by cascadeEnabled, described in Failover eligibility. A processor given a share of 10 with failover off receives its share of first attempts and none of the other processor's declines.
The billing API makes this choice on the server. There is no header, field, prop, or SDK setting that lets a client select or influence the processor, and the checkout response does not say which processor was allocated.
Two ways each method is charged
Each saved method can be charged two ways. The direct attempt is the default; the vault refresh is a fallback the cascade reaches for only when a saved card's token has gone stale.
Direct: saved provider token
The first attempt always uses the token already saved with the payment method to charge the provider directly, with no vault round-trip.
- Card: uses the saved provider token to confirm the charge directly. This is fast and doesn't touch the vault.
- PayPal: the PayPal vault token is resolved server-side and doesn't go stale the way a saved card token can, so PayPal never needs a vault refresh.
Vault refresh: self-heal (cards only)
If a saved card's token has gone stale, the cascade refreshes it: it mints a fresh token from the card's vaulted details, saves the new token onto the payment method, and retries the charge so the next attempt uses the fresh token.
The vault refresh is card-only and happens in two situations:
- Self-heal after a stale token. When a direct attempt on a card reports a stale token, the cascade refreshes once and retries before giving up on that card.
- First charge with no saved token. A card that has never been charged off-session has no saved provider token yet, so it is refreshed through the vault before its first attempt.
The vault refresh is a self-heal, not the default path. It runs at most once per card. If the refresh fails, or the card is still reported stale after a fresh token, the cascade moves on to the next method rather than looping. PayPal methods never refresh and simply move on if their token is stale.
Decline classification
The rules in this section apply only to the saved-method cascade used by Auto and Confirm checkout, renewals, and dunning. They do not define the new-card Full checkout walk or POST /v1/payments.
When an attempt comes back as a decline, the saved-method cascade decides what to do next based on the decline reason. The reason is gateway-agnostic: Stripe, Worldpay, and PayPal flows translate their provider response into the same set of reasons, so the behavior below is the same whichever gateway charged the method.
| Decline reason | Action | Why |
|---|---|---|
USER_ERROR | Next card | Wrong CVV / PIN: the bank already gave this card a chance; try the next method. |
AVS_FAIL | Next card | Address mismatch: re-charging the same card won't pass AVS. See AVS. |
INSUFFICIENT_FUNDS | Next card | This card is short; another card may clear. |
DO_NOT_HONOUR | Next card | Generic issuer decline: try the next method. |
EXCEEDS_CARD_LIMIT | Next card | Over the card's limit; another card may clear. |
RETRYABLE_DECLINE | Next card | The issuer signalled the charge is reasonable to retry elsewhere. |
PAYMENT_GATEWAY_ERROR | Next card | A gateway-side error on this charge; the next method may route cleanly. |
TECHNICAL_ERROR | Next card | A technical failure on this attempt; try the next method. |
UNKNOWN_DECLINE_REASON | Next card | An unclassified decline: move on rather than stop the run. |
3DS_FAIL | Stop | The cardholder failed or declined the 3DS challenge: burning through more cards won't help and risks tripping fraud alarms. |
TOO_MANY_ATTEMPTS | Stop | The bank has rate-limited this customer: stop. |
CLIENT_ERROR | Stop | An unclassified client-side error from the gateway: stop and surface it. |
UNSUPPORTED_CARD_PURCHASE | Stop | The card or purchase type isn't supported: stop. |
Conservative by default. Any decline reason not listed above moves on to the next card rather than stopping the run. A decline only stops the cascade if it is one of the explicit "Stop" reasons above.
Non-decline results
Not every attempt comes back as a decline. An attempt can also resolve one of these ways, which the cascade handles before the decline table applies:
| Result | Action | Behavior |
|---|---|---|
| Success | Not applicable | The charge cleared. The cascade returns success with the method that worked. |
| 3DS required | Pause | The issuer wants a 3DS challenge. The cascade pauses with three_ds_required and surfaces a redirect URL for the SDK to render. Interactive mode only. |
| Stale card | Vault refresh | The saved token is stale. On a card this triggers a single vault refresh and retry; PayPal moves on instead. |
| Transient error | Retry | A blip. The cascade waits briefly and retries the same method. |
Cascade outcomes
A whole run resolves to exactly one outcome:
| Outcome | Carries | Meaning |
|---|---|---|
success | the payment method that worked | A method charged successfully. |
three_ds_required | the payment method, a redirect URL | Paused for a 3DS challenge; hand the redirect URL to the SDK. Interactive mode only. |
halted | the payment method, the decline reason | A "Stop" decline ended the run. The classified reason is surfaced. |
exhausted | the reason it ran out | No method cleared: either there were no eligible methods, or every eligible method was tried without success. |
Modes
The mode parameter is part of the contract from day one, so it stays stable as more capabilities land and callers don't have to refactor later.
interactive(live): the buyer is on the page (full and auto checkout). 3DS challenges are recoverable: the cascade pauses withthree_ds_requiredand surfaces a redirect URL for the SDK overlay.unattended(reserved): for off-session charges such as subscription renewals, where a 3DS challenge can't be resolved on the spot and the retry policy differs. The mode is reserved but not yet implemented.unattended_payment(live): server-initiated one-off charges with no checkout session at all, used byPOST /v1/paymentsto charge an existing customer's saved methods. Gateway eligibility and preference still decide the route, but its endpoint-specific outcome rules are documented in the payments reference. Do not apply the saved-method decline table to this mode.
Don't route real traffic through unattended mode yet. It is reserved so SDK and dashboard authors can see the surface, but it isn't implemented and will reject calls.
Observability
When a charge ultimately fails, the cascade surfaces the outcome to the SDK as a FloPayError or a decline event carrying the classified decline reason: so you can tell whether the run was halted by a terminal decline or exhausted every eligible method. See Error Handling for the shapes returned to the SDK.
Related
- Automatic Payment Buttons: the saved-payment /
autocheckout surface the cascade backs. - Charge an existing customer: the
unattended_paymentsurface the cascade backs. - Stripe Payment Methods: how the Stripe gateway resolves and charges methods.
- Error Handling: the
FloPayErrorand decline shapes returned when a charge fails. - AVS: why an
AVS_FAILdecline moves on rather than retrying the same card.
Handle checkout errors
All errors in the FloPay SDK use the FloPayError class from @flopay/shared. It extends Error with structured fields that mirror Stripe-style error responses.
FloPayError
class FloPayError extends Error {
readonly type: FloPayErrorType;
readonly code?: string;
readonly declineCode?: string;
readonly param?: string;
}Error Types
| Type | Description |
|---|---|
validation_error | A required field is missing or invalid (e.g., no publishable key) |
api_error | The upstream provider or billing API returned an error |
authentication_error | Invalid API key or credentials |
rate_limit_error | Too many requests |
network_error | Network connectivity failure (fetch failed) |
Additional Fields
code-- Provider-specific error code (e.g.,card_declined)declineCode-- Card decline reason (e.g.,insufficient_funds,lost_card)param-- The parameter that caused the error (e.g.,publishableKey,sessionId)
Session Lifecycle Codes
Some codes describe the state of the checkout session rather than the payment itself.
| Code | Status | Meaning |
|---|---|---|
checkout_session_data_attachment_required | 409 | The session is a shell whose buyer and catalog data has not been attached yet. Claim it before any process, intent, decline or vault-error call. See Session Creation. |
CurrencyRequired | -- | Thrown client-side, before any network call, when no session-level or per-line currency can be resolved. |
InvalidCheckoutSessionResponse | -- | The billing API returned no session shell for a two-phase create, so it does not support the claim endpoint. |
A claim that conflicts with an earlier one also returns 409, but without the attachment-required code: the session is already attached, with a different payload. A 422 from the claim is catalog validation; inspect issues[] as described in Product Catalog.
Factory Functions
@flopay/shared exports factory functions for creating errors:
import {
validationError,
apiError,
authenticationError,
rateLimitError,
networkError,
} from '@flopay/shared';
// Missing required field
throw validationError('sessionId is required', 'sessionId');
// Provider returned an error
throw apiError('Card was declined', 'card_declined');
// Bad credentials
throw authenticationError('Invalid publishable key');
// Too many requests
throw rateLimitError('Rate limit exceeded, retry after 30s');
// Fetch failed
throw networkError('Failed to reach billing API');Handling Errors in Components
Both FloPayCheckout and FloPayAutomaticPaymentButton accept an onError callback:
<FloPayCheckout
sessionId="sess_abc123"
billingApiUrl="https://billing.example.com"
onError={(error) => {
switch (error.type) {
case 'validation_error':
// show field-level validation message
console.error(`Validation: ${error.param} - ${error.message}`);
break;
case 'api_error':
if (error.declineCode) {
// card was declined
console.error(`Declined: ${error.declineCode}`);
} else {
console.error(`API error: ${error.message}`);
}
break;
case 'network_error':
// suggest retry
console.error('Network issue, please try again');
break;
default:
console.error(error.message);
}
}}
onComplete={handleComplete}
/>Catching Errors from loadFloPay
loadFloPay throws a FloPayError if the publishable key is missing:
import { loadFloPay } from '@flopay/js';
import { FloPayError } from '@flopay/shared';
try {
const flopay = await loadFloPay(publishableKey);
} catch (err) {
if (err instanceof FloPayError) {
console.error(err.type, err.param, err.message);
}
}FloPayError restores the prototype chain with Object.setPrototypeOf, so instanceof checks work correctly even in environments that transpile class inheritance.