The problem underneath the problem
Most payment integrations answer one question: how do I talk to this provider? That question is boring and solved. The interesting question in a multi-provider market is which provider should carry this specific transaction, right now — and it has a different answer for a ₨500 carrier-billing top-up at 2am than for a card payment from a returning user on a Tuesday afternoon.
Merchants were being asked to pick a provider at integration time and live with it. That’s a decision made once, with no information, that then silently taxes every transaction for years. The whole product thesis was to move that decision from integration time to request time.
Constraints that shaped the design
Providers fail in incompatible ways. One returns HTTP 200 with a failure code in the body. One times out but completes. One is reliable except during its own settlement window. A router that treats “error” as a single category will make confidently wrong decisions.
Retrying a payment is not like retrying a GET. The failure mode of an over-eager fallback ladder is a double charge, which costs more than the failed transaction ever would. Every retry path had to be idempotent end to end, keyed from the SDK call, not from the server-side attempt.
Three objectives, no single winner. Cheapest provider, highest success rate, and fastest settlement are rarely the same provider. Pretending otherwise produces a router that optimises whichever metric the author cared about that week.
Decisions and trade-offs
A scoring matrix, not a rules chain
The obvious implementation is a priority list with fallthrough — try A, then B, then C. It’s easy to reason about and it’s wrong, because provider quality is contextual. Instead each attempt scores every eligible provider across weighted dimensions (expected cost, recent success rate for that instrument and amount band, settlement latency, current health) and the highest score wins.
The trade-off is real: a scoring engine is harder to debug than an if-chain. I paid that back by making every routing decision explainable — the ledger stores the score vector that produced the choice, so “why did this go to provider B?” is a query, not an archaeology project.
Health signals from the request path, not from a health check
Synthetic health checks tell you a provider answers pings. They don’t tell you that card payments over a certain amount have been failing for nine minutes. Success rates were computed from live traffic in short rolling windows, segmented by instrument and amount band, so degradation showed up in routing behaviour before it showed up in anyone’s monitoring.
The cost: cold-start bias. A provider with no recent traffic has no signal, so it needs a deliberate exploration budget or it starves permanently. That’s the classic explore/exploit tension, and it needs to be a conscious knob rather than an emergent accident.
An append-only ledger as the only truth
Provider dashboards, our own database and the settlement file will disagree. Not occasionally — routinely. The ledger was append-only, every state transition was an event, and reconciliation was an asynchronous worker whose entire job was to notice disagreement and raise it rather than paper over it. Finance got a view that showed unreconciled as a first-class state instead of a rounding error.
One adapter contract, many provider realities
Each provider adapter normalised its provider’s chaos into a single internal contract — including a taxonomy of failure that distinguished declined (don’t retry), transient (retry elsewhere), and unknown (do not retry, reconcile). That last category is the one people skip, and it’s the one that causes double charges.
Three dashboards, because there were three jobs
Admin needed to configure and override. Finance needed reconciliation and settlement. Developers needed request-level traces and sandbox keys. Compressing those into one “portal” would have produced an interface that served none of them. Separate surfaces on a shared API cost more to build and considerably less to operate.
What I’d do differently in 2026
The scoring weights were operator-tuned. Today I’d keep the deterministic matrix as the floor — it’s auditable, and in payments auditability is not optional — but layer a contextual bandit over the exploration budget, so provider discovery tunes itself while the guarantees stay human-legible. I’d also push the idempotency key generation further out, into the SDK’s local storage, so that a client that dies mid-request and relaunches still resolves to the same attempt.
And I’d make the explainability a product feature rather than an internal one. “Here’s why your transaction cost what it cost” is a genuinely differentiating thing to show a merchant.