Documentation
Guides

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.

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 in most-recently-used order, 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 most-recently-used attempt. unattended mode remains reserved (see Modes).

Overview

The cascade is a single run over a returning customer's vaulted payment methods:

  1. Select the customer's most-recently-used active, vaulted payment methods.
  2. 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.
  3. 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.
  4. 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 methods are tried most-recently-used first, so the card the customer reached for last time is tried first. Only a handful of the most recent methods 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.

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

When an attempt comes back as a decline, the cascade decides what to do next based on the decline reason. The reason is gateway-agnostic: both the Stripe and PayPal flows translate their gateway's response into the same set of reasons: so the behavior below is the same whichever gateway charged the card.

Decline reasonActionWhy
USER_ERRORNext cardWrong CVV / PIN: the bank already gave this card a chance; try the next method.
AVS_FAILNext cardAddress mismatch: re-charging the same card won't pass AVS. See AVS.
INSUFFICIENT_FUNDSNext cardThis card is short; another card may clear.
DO_NOT_HONOURNext cardGeneric issuer decline: try the next method.
EXCEEDS_CARD_LIMITNext cardOver the card's limit; another card may clear.
RETRYABLE_DECLINENext cardThe issuer signalled the charge is reasonable to retry elsewhere.
PAYMENT_GATEWAY_ERRORNext cardA gateway-side error on this charge; the next method may route cleanly.
TECHNICAL_ERRORNext cardA technical failure on this attempt; try the next method.
UNKNOWN_DECLINE_REASONNext cardAn unclassified decline: move on rather than stop the run.
3DS_FAILStopThe cardholder failed or declined the 3DS challenge: burning through more cards won't help and risks tripping fraud alarms.
TOO_MANY_ATTEMPTSStopThe bank has rate-limited this customer: stop.
CLIENT_ERRORStopAn unclassified client-side error from the gateway: stop and surface it.
UNSUPPORTED_CARD_PURCHASEStopThe 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:

ResultActionBehavior
SuccessNot applicableThe charge cleared. The cascade returns success with the method that worked.
3DS requiredPauseThe 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 cardVault refreshThe saved token is stale. On a card this triggers a single vault refresh and retry; PayPal moves on instead.
Transient errorRetryA blip. The cascade waits briefly and retries the same method.

Cascade outcomes

A whole run resolves to exactly one outcome:

OutcomeCarriesMeaning
successthe payment method that workedA method charged successfully.
three_ds_requiredthe payment method, a redirect URLPaused for a 3DS challenge; hand the redirect URL to the SDK. Interactive mode only.
haltedthe payment method, the decline reasonA "Stop" decline ended the run. The classified reason is surfaced.
exhaustedthe reason it ran outNo 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 with three_ds_required and 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.

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.

  • Automatic Payment Buttons: the saved-payment / auto checkout surface the cascade backs.
  • Stripe Payment Methods: how the Stripe gateway resolves and charges methods.
  • Error Handling: the FloPayError and decline shapes returned when a charge fails.
  • AVS: why an AVS_FAIL decline 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

TypeDescription
validation_errorA required field is missing or invalid (e.g., no publishable key)
api_errorThe upstream provider or billing API returned an error
authentication_errorInvalid API key or credentials
rate_limit_errorToo many requests
network_errorNetwork 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.

CodeStatusMeaning
checkout_session_data_attachment_required409The 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.

On this page