When a payment fails, your system has two choices: surface an error to the user or route the transaction to a different processor and try again. Payments failover routing is the second option, and getting it right is harder than it looks.
The difficulty is not the retry itself. It is knowing which failures warrant a retry, which processor to try next, how long to wait, and how to confirm you are not charging the card twice. This article walks through the mechanics: decline code classification, cascade patterns, retry limits imposed by card networks, and the idempotency failure mode that catches most teams off guard.
Failover vs. cascade retry: two different triggers
These terms are often used interchangeably. They describe different situations, and conflating them produces incorrect retry logic.
Failover is what happens when you get no response. Connection error, DNS failure, TLS error, gateway timeout, sustained 5xx responses, rate limiting. The processor did not process your request. You have no transaction state on the other side.
Cascade retry (sometimes called waterfall retry) is what happens after you receive a response and it contains a soft decline code. The processor processed your request and told you the transaction was declined for a reason that might succeed elsewhere.
The distinction matters because the risks are different. In a failover scenario, you do not know whether the first processor attempted to charge the card before losing connectivity. In a cascade retry scenario, you know the transaction was cleanly declined. The idempotency handling required for failover is more involved, and the timeout thresholds you set for switching processors operate differently from the decline-code logic that drives cascade routing.
In your code, this means two separate decision branches:
def route_payment(payment_request):
for processor in routing_order:
try:
result = processor.charge(
amount=payment_request.amount,
idempotency_key=payment_request.idempotency_key
)
except (ConnectionError, TimeoutError, GatewayError) as e:
# Failover path: no response received
# Log the technical failure, try next processor
log_failover_event(processor, e)
continue
if result.status == "success":
return result
elif result.decline_code in SOFT_DECLINE_CODES:
# Cascade path: response received, soft decline
log_soft_decline(processor, result.decline_code)
continue
else:
# Hard decline: stop, do not retry
return result
return PaymentResult(status="failed", reason="all_processors_exhausted")
Decline codes that trigger failover
Not every decline warrants a retry. Retrying a hard decline wastes a network call, can generate duplicate charge attempts, and in some cases makes the fraud profile of the transaction worse.
Soft declines: retry candidates
Soft declines indicate a temporary or processor-specific condition that might resolve on a different attempt or with a different processor.
| Code (Stripe / generic) | Condition | Notes |
|---|---|---|
insufficient_funds | Card balance low | Retrying immediately unlikely to help; retrying via a different PSP might still fail, depending on card issuer logic |
processing_error | PSP-side processing fault | Good failover candidate |
issuer_not_available | Card issuer temporarily unreachable | Good failover candidate |
card_velocity_exceeded | Issuer-set rate limit on the card | Retry via different PSP may succeed if issuer uses different velocity tracking per acquiring bank |
do_not_honor (generic) | Issuer declined without reason | Occasionally recoverable; often not, treat with caution |
| Visa Category 2 (codes 51, 91) | Retriable conditions per Visa classification | Visa explicitly identifies these as retriable via their operating rules |
Hard declines: do not retry
Hard declines reflect a permanent state that will not change because you chose a different processor. Retrying is wasteful at best and flags your account at worst.
| Code | Condition |
|---|---|
expired_card |
Card past expiry date |
lost_card |
Card reported lost |
stolen_card |
Card reported stolen |
invalid_account |
Account closed or does not exist |
card_not_permitted |
Card type blocked by issuer |
| Visa Category 1 (codes 41, 43, 46) | Fraudulent card indicators |
Source: decline code classification aggregated from ChargeBlast, Akurateco, and BeastInsights, 2026. Visa Category 1/2 classifications per GR4VY’s network-rules summary, 2026.
For codes outside this classification, the safest default is to treat an unknown code as non-retriable and log it for review. Card network operating rules evolve, and a code that is retriable today may not be retriable after a network rule update.
Cascade routing patterns
Once you decide a transaction should be retried, you need a sequence: which processors to try, in what order, and under what conditions to stop.
Linear cascade
The simplest pattern: try processor A, then B, then C in a fixed order. Deterministic and easy to reason about, but offers no intelligence about which processor is most likely to succeed for a given transaction type.
Transaction
→ PSP 1 (primary)
↓ failure
→ PSP 2 (secondary)
↓ failure
→ PSP 3 (tertiary)
↓ failure
→ Decline to user
Performance-weighted cascade
Route first to the processor with the highest recent authorization rate for the transaction’s BIN range, currency, or card type. When the highest-weighted processor fails, fall through to the next in rank.
This is where Orchestra’s automatic failover works: routing decisions factor in processor performance data, and on failure the cascade order reflects that same weighting rather than a static list you maintain by hand. Per the product’s routing optimization page, routing decisions carry less than 50ms latency, which means the cascade adds minimal overhead to the transaction flow.
Geographic cascade
If you serve multiple markets, your cascade order may differ by region. A processor that authorizes well for US cards may have poor reach for European issuers. Build your cascade sequence per-market rather than a single global list.
CASCADE_ORDERS = {
"US": ["stripe", "braintree", "adyen"],
"EU": ["adyen", "mollie", "stripe"],
"APAC": ["adyen", "stripe", "2c2p"],
}
def get_cascade_order(currency, country):
region = get_region(country)
return CASCADE_ORDERS.get(region, CASCADE_ORDERS["US"])
Health-check-gated cascade
Before including a processor in the cascade, check whether it is currently healthy. Routing to a processor you already know is down wastes time. This requires a health-check layer, either from a monitoring service you maintain or from a payment orchestration platform that aggregates real-time health status across connected PSPs.
Retry timing and attempt limits
Card networks set hard caps on retry attempts. Exceeding these caps can result in penalties from Visa and Mastercard, so they are not soft guidance.
Per GR4VY’s network-rules summary (2026):
- Visa: one retry per day on a soft decline; hard cap of 15 attempts over a rolling 30-day window.
- Mastercard: comparable retry structure, enforced through Merchant Advice Codes (MACs) that explicitly specify whether a retry is permitted for a given decline. A MAC of “01” means do not retry; a MAC of “02” or “03” means retry is permitted under specific conditions.
These caps apply to the card itself across all acquirers, not per-PSP. If your cascade sends the same card through three processors in sequence, each attempt counts toward the network cap.
For real-time failover cascades (a single checkout session), this cap is rarely hit in practice. Where teams run into trouble is when real-time cascade logic and scheduled subscription retry logic both operate on the same card without a shared attempt counter.
Timeout thresholds
Your failover timeout needs to be long enough to catch normal latency spikes (a slow response is not a failed response) but short enough to not leave the user’s browser hanging while you wait for a gateway that has stopped responding.
A practical starting point: 5 seconds for a primary processor, 3 seconds for fallback processors. The primary gets more patience because it is your first choice; fallbacks are already a recovery path, so you want them to respond quickly or yield to the next in chain.
TIMEOUT_SECONDS = {
"primary": 5,
"secondary": 3,
"tertiary": 3,
}
These are starting values, not universal recommendations. Watch your p95 and p99 latency distributions for each processor and calibrate accordingly. A processor with a p99 of 4 seconds should not have a 3-second timeout applied, or you will trigger unnecessary failovers on slow-but-successful transactions.
Avoiding duplicate charges
The most dangerous failure mode in payments failover routing is not the retry itself. It is charging the customer twice. This happens when a PSP processes the charge successfully but the response is lost to a network error or timeout on the way back to your server.
From your side: no response, which looks identical to a genuine failure. Your naive failover logic routes to the next processor, which also successfully charges the card. The customer sees one charge; their bank statement shows two.
Idempotency keys
The standard mitigation is idempotency keys. Generate one key per logical transaction (not per attempt), and send it in every attempt against every processor.
import uuid
def process_payment_with_failover(order_id, amount, currency, card_token):
# One key per logical transaction, not per attempt
idempotency_key = f"order-{order_id}-{uuid.uuid4()}"
for processor in get_cascade_order(currency):
try:
result = processor.charge(
amount=amount,
currency=currency,
card_token=card_token,
idempotency_key=idempotency_key,
timeout=TIMEOUT_SECONDS.get(processor.tier, 3)
)
if result.success:
return result
if result.decline_code not in SOFT_DECLINE_CODES:
return result # Hard decline, stop
except (ConnectionError, TimeoutError):
continue
return PaymentResult(status="failed")
On PSPs that support idempotency keys natively (Stripe, Adyen, and most modern gateways), sending the same key twice returns the result of the first attempt rather than processing a new charge. This covers the case where your retry is hitting the same processor and the first attempt actually succeeded.
When failing over to a different processor, the idempotency key has no effect. The second processor has no knowledge of the first processor’s attempt. You need an additional guard:
- Before attempting any failover processor, query your own transaction ledger for the order ID. If there is an existing successful charge, return it rather than proceeding.
- Set a short hold flag on the order during processing (a database lock or a distributed lock via Redis) so concurrent requests do not trigger parallel attempts.
Source: idempotency-key mechanics from Apidog’s “What Is Payment API Idempotency and Why Does It Prevent Double Charges?” (2026) and AcquirerOS gateway-failover implementation guide (2026).
Check transaction state before retrying
Your own database is an underused guard here. Before routing to a fallback processor:
def safe_failover(order_id, payment_request):
# Check ledger before attempting failover
existing = db.get_transaction(order_id)
if existing and existing.status == "captured":
return existing # Already charged, do not retry
return route_payment(payment_request)
This is the fastest check you can do, and it covers the scenario where the first processor charged the card, the response was lost, your server timed out, and the user retried from the front end, triggering a second call before your failover logic even fires.
Measuring failover effectiveness
Implementing failover routing without measurement produces a black box. Three metrics matter:
Failover rate by trigger type. Track what is causing your failovers: connection errors, timeouts, or specific decline codes. A high failover rate on timeouts from a single PSP signals a performance issue with that provider, not a general payment problem.
Cascade recovery rate. Of all transactions that entered the cascade, how many completed successfully via a fallback processor? Industry data from Stripe (via Paddle’s soft-decline analysis, 2026) puts smart retry recovery at 10-15% of initially declined revenue. Your number will vary by transaction mix, but this is a reasonable baseline.
Cross-processor success rate by decline code. Which decline codes actually recover on a different processor? This tells you whether your cascade is earning its latency cost. If issuer_not_available recovers 60% of the time via your secondary processor, it is worth retrying. If do_not_honor recovers 3%, it is probably not.
-- Example query for measuring cascade recovery by decline code
SELECT
initial_decline_code,
COUNT(*) AS total_attempts,
SUM(CASE WHEN final_status = 'success' THEN 1 ELSE 0 END) AS recovered,
ROUND(
100.0 * SUM(CASE WHEN final_status = 'success' THEN 1 ELSE 0 END) / COUNT(*),
2
) AS recovery_rate_pct
FROM payment_cascade_events
WHERE entered_cascade = true
GROUP BY initial_decline_code
ORDER BY total_attempts DESC;
Log the transaction ID, the processor attempted, the decline code received, and the timestamp for every step. Without this, you cannot tell whether your cascade sequence is ordered correctly or whether you have a systematic mismatch between your soft-decline classification and the codes that actually recover.
Implementing failover routing with Orchestra
Building the cascade logic above from scratch works, but it requires maintaining the PSP-specific decline code mappings, the health-check layer, the idempotency handling across providers, and the performance data that should drive your cascade order. That is the ongoing maintenance burden that compounds as you add processors.
Orchestra provides automatic failover as part of its routing layer. When your primary processor fails or declines a transaction, Orchestra routes to the next processor in your configured sequence without you writing the cascade logic. The routing decision uses real-time processor performance data, so the fallback sequence reflects which processor is most likely to succeed for that transaction type, not a static list you maintain by hand.
Failover and cascade behavior applies to every transaction, whether it was captured through the Orchestra Library or triggered later through the Orchestra API. Existing single-PSP integrations can coexist with Orchestra during a phased migration, so you are not choosing between keeping your working Stripe integration and getting automatic failover.
The payment routing optimization page covers the full routing feature set: cost-based, geographic, performance, and hybrid routing strategies, all configurable without code changes. The payment gateway failover page documents how the failover layer works at the product level.
To understand how failover fits into the broader routing picture, the payment routing 101 for developers article covers the fundamentals before getting into failover mechanics, and a guide to payment routing covers static vs. dynamic routing strategies for context.
Frequently Asked Questions
What is payment failover routing?
Failover routing automatically retries declined or failed transactions through alternate payment processors. Unlike standard routing that selects the best processor before the transaction attempt, failover kicks in after an initial failure, whether that failure is a technical error (timeout, connection drop) or a soft decline code.
Which decline codes should trigger failover?
Soft declines are good failover candidates: processing_error, issuer_not_available, card_velocity_exceeded, and Visa Category 2 codes (51, 91) among them. Hard declines (stolen_card, lost_card, expired_card, invalid_account, Visa Category 1 codes 41, 43, 46) should not be retried. The card state that caused the decline will not change because you chose a different processor.
How many failover attempts should I allow?
Two to three attempts is a reasonable limit for real-time checkout flows. More attempts increase per-transaction latency without proportional improvement in recovery rate. Card networks cap retries separately: Visa allows one retry per day on a soft decline, with a hard cap of 15 attempts in a rolling 30-day window; Mastercard enforces a comparable structure via Merchant Advice Codes. Each cascade attempt counts toward that cap regardless of which processor you route to.
How do I prevent duplicate charges during failover?
Use idempotency keys: one key per logical transaction, sent on every attempt including cross-processor failovers. Before routing to a fallback processor, query your own transaction ledger for an existing successful charge on the same order ID. On processors that support idempotency keys natively, a duplicate key returns the prior result rather than creating a new charge. Cross-processor failovers do not benefit from the key on the second PSP’s side, which is why the ledger check is the more reliable guard.
What is the difference between failover routing and intelligent routing?
Intelligent routing selects the best processor before the transaction attempt, based on factors like cost, geography, and historical performance. Failover routing handles what happens after a transaction fails. They work together: intelligent routing minimizes the number of failures; failover routing recovers from the failures that occur despite good upfront routing.
What is the difference between failover and cascade retry?
Failover reroutes on a technical failure: no response received, connection error, timeout. Cascade retry reroutes after a soft decline response is received. Same underlying mechanism (route to the next processor), different triggers, and different idempotency risks. In a failover, you do not know whether the first processor processed the charge before losing connectivity. In a cascade retry, you know it cleanly declined.


