Back to Blog

From One Provider to Five: What the Payments Layer Should Handle

Abstract visualization of one connection expanding to multiple payment providers

Almost every payment platform starts with one provider. It is the right call early on: you pick the provider that fits your transaction type and geography, build the integration, and ship. The complexity you were avoiding by not picking three providers in parallel is real, and deferring it was the right engineering decision.

The problem comes when the second provider arrives. Then the third. The pattern that made sense for one provider starts to break down, often in ways that are not obvious until the debt is already accumulated. This article is about what that breakdown looks like and the architecture decisions that prevent it from getting worse with each addition.

Why adding a second provider is harder than the first

The first provider integration establishes your payment data model. You build your database schema around the fields that provider returns. You build your reconciliation around how that provider formats settlement files. You build your error handling around that provider's error taxonomy.

The second provider has a different data model, different field names for the same concepts, different status codes, different settlement formats, and different webhook payloads. None of this is unusual: payment providers are not standardized. But it means the second integration does not just add one new provider. It forces a renegotiation of the assumptions baked into the first integration.

A common pattern is that the first provider's transaction identifier becomes a de facto standard in the codebase. You have code that passes that identifier around, logs it, queries by it. When the second provider arrives, its identifier is in a different format, possibly a different length, possibly with a different namespace collision risk. You either add a second identifier field to every relevant table, or you create a mapping layer, or you have a messy period where some code assumes the first format and other code handles both. All three are worse than what you had with one provider.

The three layers that compound by provider count

When you look at what actually scales badly as providers are added, it falls into three areas.

The first is connection management. Each provider has its own API authentication scheme, rate limits, and connectivity requirements. With one provider, this is a thin wrapper. With five providers, you are managing five sets of credentials, five rate limit budgets, five retry policies, and five sets of SDK or HTTP client configurations. If you are doing this inside your application code, every service that touches payments inherits this complexity.

The second is event normalization. Providers send webhooks, settlement notifications, and status update events. Each provider formats these differently. A "payment succeeded" webhook from one provider looks nothing like the equivalent from another. Before any of your application logic can process these events, they need to be translated into your canonical event format. Without a dedicated normalization layer, this translation ends up scattered across whatever service first receives each provider's events, making it inconsistent and hard to audit.

The third is reconciliation. With one provider, reconciliation is a single pipeline: pull the settlement file, match against your transaction records, surface discrepancies. With five providers, you have five pipelines with different file formats, different settlement timing, and different dispute workflows. The reconciliation logic for each provider tends to accumulate in isolation, written by whoever integrated that provider. You end up with five implementations of reconciliation logic with different assumptions and different quality levels.

The architecture decision that does not age well

The architecture that does not age well is one where provider-specific logic is embedded in your application services. Payment service contains logic for Provider A. Gets updated when Provider B is added. Gets messy when Provider C arrives. By Provider D, you have conditional blocks that read like a provider-specific switch statement spread across hundreds of lines of code.

This happens naturally because each integration is done when there is urgency to ship. The engineer doing the Provider B integration is under pressure. They write the cleanest code they can in the time available, but the shape of the codebase they are adding to already constrains what "clean" looks like.

The alternative is to make the architecture decision early: payment routing, connection management, event normalization, and reconciliation belong in a layer that is separate from your application services. Application services express what they want (route a payment of this amount, to this account, using this strategy) without knowing which provider handles it. The payments layer handles the provider-specific mechanics and returns a normalized result.

This is not a microservices argument per se. The payments layer can be a set of well-separated modules in a monolith. What matters is that the boundary exists and that provider-specific code lives exclusively on one side of it.

What the payments layer owns

When you have the boundary in place, the payments layer owns a specific set of responsibilities that should not leak into application code.

Provider selection is the first. Given a transaction's type, destination, amount, and any configured preferences, the payments layer decides which provider to use. This includes failover decisions: if the preferred provider is degraded, the payments layer routes to the next-best option without the calling service needing to know or care. This logic runs inside the payments layer and is tested against the actual behavior of each provider it manages.

Identifier management is the second. The payments layer maintains the mapping between your internal transaction identifiers and each provider's identifiers. Your internal ID is what your application code uses throughout its lifecycle. The provider's ID is an implementation detail the payments layer tracks, translates, and uses when communicating with that provider or processing their events.

Event normalization is the third. Incoming events from all providers arrive at the payments layer, which translates them into canonical event types (payment.settled, payment.failed, payment.disputed) before emitting them internally. The rest of your system subscribes to these canonical events without needing to know which provider generated the underlying event.

State management for in-flight transactions is the fourth. A payment that was submitted to a provider and has not yet received a definitive success or failure response is in a state that needs careful management, especially during provider degradation. The payments layer tracks this state and coordinates retries and fallback decisions with idempotency guarantees. Application code does not track this; it waits for a terminal event from the payments layer.

Adding provider five is a configuration change, not an engineering project

When this architecture is working, adding the fifth provider should be qualitatively different from adding the second. Provider-specific code for Provider 5 lives entirely within the payments layer. You write the connector, wire it into the routing configuration, write tests against its sandbox, and deploy. Nothing else in your application changes because nothing else in your application knew about providers in the first place.

We are not claiming this is easy or fast. Writing a production-quality connector for a new payment provider involves authentication, retries, idempotency handling, event parsing, rate limiting, and sandbox testing. That takes time regardless of where the code lives. What changes is the surface area. You are not hunting down provider assumptions scattered across ten services. You are writing one new connector that plugs into an established interface.

The test of whether you have the architecture right is whether adding Provider 5 requires touching any code outside the payments layer. If the answer is no, the boundary is doing what it is supposed to do. If the answer is "mostly no, but we have to update a few config files and maybe one service that has a hardcoded provider reference," the boundary has some leaks to close.

What this means for reconciliation

Reconciliation gets considerably simpler when it lives inside the payments layer rather than being per-provider. There is one reconciliation data model, one mechanism for matching external events to internal records, and one exception surfacing path regardless of which provider generated the discrepancy. The finance team gets a single interface. The engineering team maintains one reconciliation pipeline.

The reconciliation pipeline does still need to handle provider-specific differences, particularly around settlement formats and timing. But those differences are encapsulated in provider-specific parsing code inside the pipeline, not exposed to the rest of the system. The consumer of reconciliation results does not know or care whether a given record came from a DBS settlement file, a Wise API response, or a SWIFT confirmation.

That is the goal: your application code describes what it needs done, and the payments layer handles the complexity of a world where multiple providers each do things their own way.

Build on Checker

Payment routing, reconciliation, and compliance for Southeast Asian fintech platforms. Start free, scale as you grow.

Get API Key Free