Smart Payment Routing and Cascading: Improving Success Rates
A few months ago, one of our best customers tried to check out three times and gave up. I only found out because she messaged support directly, annoyed, asking why her "money kept failing" when her own bank app showed no attempted charge at all. The logs told a simple story: the first attempt hit a card processor having a rough few minutes, returned a generic processing error, and our checkout did exactly what it was built to do — show a failure message and ask her to try again. She tried the same card through the same channel, got the same result, and left.
Nothing about that failure was her fault. Her card was fine. Her bank was fine. One processor, for a few minutes, was not. Because our payment flow had no way to tell "temporary processor problem" apart from "declined transaction," we treated a recoverable failure exactly like a stolen-card attempt: show an error, hand it back to the customer, hope she tries again on her own.
That incident is what pushed me to build routing and cascading logic properly instead of leaving it on the backlog. I lead technical integration for a China-based merchant running an e-commerce operation here in the Philippines, and this is what I wish I'd had before starting: what routing and cascading actually mean, which failures are worth retrying automatically, and where the UX decisions get genuinely tricky.
What Routing and Cascading Actually Mean
Routing means you don't hardcode a single channel as "the" way a transaction gets processed. You define an ordered list of channels allowed to handle a given payment, and the system picks between them based on rules — the method the customer chose, the order amount, or which channel has been healthiest in the last few minutes.
Cascading is what happens when the first choice fails: instead of stopping and reporting the failure to the customer, the system automatically tries the next channel in the order, using the same payment intent, without the customer re-entering anything. A card decline on one processor can trigger a retry on a second processor. A GCash request that times out waiting for approval can fall back to a Maya prompt. The customer experiences one checkout attempt; behind the scenes, the system may have tried two or three.
The part that's easy to get wrong is assuming cascading means "retry everything until something works." Retrying blindly turns a bad experience into a worse one, and in some cases turns a declined transaction into something that resembles a card-testing attack.
Building a Fallback Order
A fallback order is a ranked list: if channel A fails in a retryable way, try B; if B fails too, try C; if nothing works, stop and tell the customer honestly. We weigh three things when ranking a given payment method:
- Recent success rate — a rolling window, not an all-time average, because processor health genuinely drifts day to day.
- Cost and settlement terms — if two channels are equally healthy, the cheaper or faster-settling one goes first.
- Customer intent — a failed GCash attempt falling back to a Maya prompt is a reasonable same-category fallback; silently falling back to a bank transfer form the customer never asked for is not.
We revisit this ranking periodically rather than treating it as fixed. A channel that was reliable last quarter can degrade, and an order that's never reviewed quietly ossifies into whatever was true when someone first wrote it.
Which Failures Are Safe to Retry — and Which Are Not
This is the decision that matters most, and the one teams get wrong first. The instinct is to treat "failed" as one category. It isn't. A failure either signals something went wrong temporarily on the processing side, or it's a decision — by a bank, a customer, or a fraud system — that the transaction should not go through. Only the first category is safe to retry automatically.
| Failure type | Safe to cascade? | Why |
|---|---|---|
| Processor timeout / gateway unreachable | Yes | No decision was made — the request simply didn't get a response in time. |
| Processor-side 5xx / internal error | Yes | The fault is on the processor's infrastructure, not the card or the customer. |
| Network blip / connection reset mid-request | Yes | The request may never have reached the processor, so nothing was evaluated and declined. |
| E-wallet session timeout (app didn't respond to approval prompt) | Yes, with limits | Often client-side, not a true decline — but cap retries, since a customer who isn't there won't approve a second prompt either. |
| Card declined — "do not honor" / generic decline | No | The issuing bank made an explicit decision; a different processor won't change it. |
| Insufficient funds | No | The balance won't change in the next few seconds; immediate retries just waste attempts. |
| Invalid card number / expired card | No | Permanent, structural error — every processor returns the same result. |
| Fraud or risk block from issuer or processor | No | Repeated attempts after a risk block can trigger harder blocks and flag the merchant. |
The rule we apply: cascade on failures that describe the infrastructure, not failures that describe the transaction. A timeout tells you about the pipe. A decline tells you about the money. Only the first is worth a second attempt without the customer's involvement.
This is also why cascading and ordinary error handling solve different problems. We've written separately about handling errors in your own payment code — validating responses and surfacing the right message after a single failed call. Cascading decides what your system does across multiple channels before a failure ever reaches the customer. You need both; they sit at different layers.
How the Retry Logic Works
At a mechanical level, this is a loop with an exit condition, not an open-ended "keep trying" loop. Every attempt is classified into retryable or terminal before the system decides what's next:
async function attemptPayment(paymentIntent, channelOrder) {
const attempts = [];
for (const channel of channelOrder) {
const result = await sendToChannel(channel, paymentIntent);
attempts.push({ channel: channel.id, status: result.status, code: result.code });
if (result.status === "success") {
return { success: true, channel: channel.id, attempts };
}
const classification = classifyFailure(result.code);
if (classification === "terminal") {
// Customer-side decision: do NOT cascade. Stop and report honestly.
return { success: false, terminal: true, reason: result.code, attempts };
}
// else: retryable — continue to next channel in the order
}
return { success: false, terminal: false, reason: "exhausted_fallback_order", attempts };
}
function classifyFailure(code) {
const retryableCodes = ["TIMEOUT", "GATEWAY_UNAVAILABLE", "PROCESSOR_ERROR", "SESSION_TIMEOUT"];
const terminalCodes = ["DO_NOT_HONOR", "INSUFFICIENT_FUNDS", "INVALID_CARD", "FRAUD_BLOCK"];
if (retryableCodes.includes(code)) return "retryable";
if (terminalCodes.includes(code)) return "terminal";
return "terminal"; // unknown codes default to terminal — never guess in favor of retrying
}
Two details matter more than they look. Every attempt is logged with its own channel and failure code, even the ones the customer never sees — that log lets you reconstruct, a week later, whether a decline was handled correctly. And unknown failure codes default to terminal, not retryable: if you're not certain a failure is safe to retry, stop and let the customer try again themselves rather than guess in favor of automation. This default-to-terminal instinct also shows up in our webhook retry design for failed payment notifications — a related but different problem, since that's about retrying the notification that a payment happened, not the payment attempt itself.
Configuring Routing Rules
Once the mechanics are sound, the fallback order should live in configuration, not scattered through conditionals in checkout code, so you can adjust priority or disable a struggling channel without a deployment:
{
"routing_rules": [
{
"payment_method": "card",
"fallback_order": ["processor_a", "processor_b"],
"max_attempts": 2,
"retryable_codes": ["TIMEOUT", "GATEWAY_UNAVAILABLE", "PROCESSOR_ERROR"],
"terminal_codes": ["DO_NOT_HONOR", "INSUFFICIENT_FUNDS", "INVALID_CARD", "FRAUD_BLOCK"]
},
{
"payment_method": "ewallet",
"fallback_order": ["gcash", "maya"],
"max_attempts": 2,
"retryable_codes": ["SESSION_TIMEOUT", "APP_NOT_RESPONDING"],
"terminal_codes": ["USER_CANCELLED", "ACCOUNT_RESTRICTED"],
"notify_customer_on_fallback": true
}
],
"global": {
"max_total_attempts": 3,
"attempt_timeout_seconds": 20,
"disable_channel_if_failure_rate_above": ""
}
}
Two global fields do most of the real work. max_total_attempts caps how many channels one checkout can cascade through — three is usually our ceiling, since beyond that the delay costs more customer patience than it recovers in success rate. disable_channel_if_failure_rate_above is a circuit breaker: once a channel's live failure rate crosses a threshold, routing stops sending it traffic rather than cascading through a channel that's clearly degraded and just adding latency before the real fallback kicks in. And notify_customer_on_fallback is there because whether the customer is told a fallback happened is a UX decision, not only a backend one.
UX: Keeping the Customer Informed Without Confusing Them
The point of cascading is recovering a sale the customer would otherwise have lost through no fault of their own — easy to undermine with a checkout that feels opaque. A few things we learned the hard way:
- Don't show every intermediate failure. If the first processor times out and the second succeeds two seconds later, show one loading state through the cascade, then one result.
- Do say something if the fallback changes what the customer is looking at. A GCash-to-Maya fallback means a different app is about to ask for approval — silence there reads as broken, not smooth.
- Never silently change what the customer agreed to pay. Cascading changes which channel processes the payment; it should never change the amount or how it's presented.
- Give a real answer when cascading is exhausted. Don't show a vague "something went wrong" — say the payment couldn't be completed and suggest a concrete next step.
- Keep customer-facing language honest about the category of failure. A customer declined for insufficient funds shouldn't see language implying a glitch — that just sets up a second attempt that fails the same way.
There's a fraud dimension too. A checkout that silently tries three channels in sequence can, to a risk system, look identical to a script probing for a channel that accepts a stolen number. We've written in more detail about how fraud detection rules get built around repeated attempt patterns — the short version is that cascading logic and fraud rules need to agree on what a legitimate retry looks like, or your own fallback system eventually gets flagged by your own risk controls.
What We Watch After Turning Cascading On
Shipping the logic isn't the finish line. A few things stay on our dashboard permanently:
- Cascade rate by channel — how often each channel actually completes the payment versus only ever being the first attempt that fails over. A channel constantly being failed away from needs a conversation, not an indefinite lower spot in the order.
- Terminal failure rate — declines, insufficient funds, and fraud blocks that correctly weren't retried. A sudden spike usually means something changed upstream, not that the routing logic broke.
- Time added by cascading — every fallback adds latency before a result. We track this at the high end of the distribution, not just the average, since a handful of slow cascades do more damage to patience than the average suggests.
- Reconciliation accuracy — a successful payment might have gone through the second or third channel, so settlement records need the actual channel used, not just that the order was eventually paid.
Operating any of this responsibly also means staying within the regulatory structure governing who can move money here in the first place.
Any routing or cascading setup spanning multiple payment channels in the Philippines should sit on top of providers holding proper BSP authorization — an Operator of Payment System (OPS) and, where relevant, Electronic Money Issuer (EMI) license — rather than unlicensed intermediaries stacked in front of real processors.
That licensing detail isn't a footnote; it determines which entities you can legally route traffic through and who is accountable if a cascade attempt affects customer funds. What a BSP EMI license actually covers is worth understanding before treating any channel as interchangeable in a fallback order. We also watch how routing interacts with our broader risk posture — a merchant account flagged for unusual retry patterns can lose channel access even when every individual attempt was legitimate, covered in more depth in how merchants get flagged by payment risk controls.
Does cascading mean the customer could get charged twice?
Not if it's built correctly. Cascading should only move to the next channel after confirming the previous attempt neither succeeded nor left a pending charge. The payment intent carries an idempotency reference so a late response that actually completed on the processor's end is recognized rather than duplicated.
How many fallback attempts is reasonable before giving up?
We cap at two to three channels per checkout. Beyond that, added delay usually costs more in patience than it recovers in successful payments, and a long attempt chain starts looking suspicious to risk systems regardless of intent.
Can cascading hide a systemic processor outage from me?
Yes, if you only watch customer-facing success rate. A processor failing on every attempt but always rescued by the next channel in line will look fine from the outside while quietly costing you redundancy — which is why cascade rate by channel needs to be a tracked metric on its own.
Should cascading apply to insufficient funds errors?
No. The balance behind that card or wallet isn't going to change between one processor and another in the same session. Retrying it adds no value and can resemble probing to a risk system.
Do I need a separate merchant agreement with every channel in my fallback order?
In most setups, yes — each channel is typically a distinct merchant relationship or sub-account even when integrated through a single aggregator. The aggregator simplifies the technical work, but the settlement relationships behind it are usually still separate.
Does automatic retry across channels complicate reconciliation?
It adds a requirement rather than complication: settlement records need to capture which channel actually processed a given order, not just that it was eventually paid, or matching a payout back to the right statement becomes guesswork.
How does cascading interact with 3D Secure or in-app approval prompts?
Carefully. If a customer already approved a challenge and the failure happened after that step, falling back to a channel requiring a fresh approval means asking them to approve something a second time — a case where being explicit in the UI matters more than usual, since an unexplained second prompt reads as a glitch rather than the system working as intended.
None of this makes payment failures disappear — a declined card is still a declined card, and no routing logic should pretend otherwise. What it does is stop punishing customers for failures that were never theirs to begin with, which is a meaningfully different problem than most checkout pages are built to solve.
If you're weighing whether to build this into your own checkout or want a second opinion on a fallback order you've already sketched out, reach out — @LouisSMARTPAY on Telegram or [email protected].