How Nopan built its Integration Hub to make payment integrations more reliable at scale
When we started working on our third payment provider, something broke. Not in production, but in the codebase.
A teammate opened the pull request and we both stared at it. We were about to write the same provider-specific dispatch logic for the third time: a parallel if/else chain across charge, capture, refund and cancel flows, each one slightly different because each provider is slightly different.
We merged it, shipped the integration, and then immediately started a conversation about how we were not going to do that again.
The result is what we call the Integration Hub: a single state machine that every payment provider plugs into.
This post walks through how it works, why we chose a state machine instead of the alternatives, and which parts were harder than they looked.
What we were working around
No two payment providers behave in exactly the same way.
Some use a two-step authorize-then-capture flow. Others charge in a single call. Refunds may come back synchronously with one provider and asynchronously with another. Response formats vary too: some providers use a status field, some rely on HTTP codes, and some use both inconsistently.
Before the Integration Hub, each provider had its own handler. Those handlers did not share a common interface.
When a bug appeared in how we mapped a capture response, it appeared in one handler and not the others, until it eventually did. When we needed to add a new operation, such as partial refunds, we had to update a growing number of files across all providers and hope we had covered every case.
The logic was spread across the codebase in a way that made it hard to reason about transaction state at a glance.
If someone asked, “Can this transaction be refunded?”, the honest answer was often: “It depends which provider processed it, and we need to check a few places.”
Why a state machine
We looked at a few approaches before settling on a state machine.
The simplest path was a strategy pattern: define a common interface, implement it per provider, and call through the interface. That would have solved the dispatch problem, but not the underlying state problem. Each implementation would still have needed to track its own state logic, and “is this transaction capturable?” could still have been answered differently by each strategy.
What we kept coming back to was that a payment transaction is, at its core, a finite set of states with a finite set of legal transitions between them.
You can initiate, authorize, capture, refund or cancel, but only from the right prior state.
That is a state machine problem.
It is a decades-old idea from computer science, the kind that tends to get overlooked when everyone is reaching for newer abstractions. But that is exactly what made it right here. State machines are boring in the best possible way for financial infrastructure.
How the Integration Hub works
Every payment operation arrives at the Integration Hub from our API gateway.
The Hub supports five operations:
initiatechargecapturerefundcancel
Each operation runs through the same sequence, shown in the flow below.

Integration Hub operation flow
1. Acquire a distributed lock on the transaction
We use DynamoDB for distributed locking. The lock key is the transaction ID and the lock has a TTL.
This prevents concurrent operations on the same transaction from racing. If a capture and a refund somehow arrive simultaneously, one of them waits.
The process is wrapped in a lock-handling mechanism so that the lock is released regardless of the outcome. The TTL also protects us if the process dies while holding the lock.
2. Validate the transition and persist the request intent
The state machine checks whether the requested operation is legal given the current state.
Trying to capture an already captured transaction? The state machine rejects it before anything reaches the provider.
If the transition is legal, we write the request intent to the event store before touching any external system.
This ordering matters. We want a record that we tried this operation, even if the provider call never completes.
3. Call the Providers service
The Providers service is the boundary between our normalized model and the outside world.
The Hub sends a normalized request, with the same structure regardless of provider. The Providers service then does two things:
routes the request to the right provider
runs the request through that provider’s mapper before sending it
The response comes back normalized. The Hub never sees provider-specific fields.
4. Record the response event
Success or failure, the outcome goes into the event store.
The state machine then advances, or does not advance, depending on the result.
5. Release the lock
Once the operation is complete, the lock is released and the transaction can process the next valid operation.
What the state transitions actually look like
The legal transitions in our state machine are represented in the diagram below.

Integration Hub state transitions
Attempting any other transition throws an error before the provider is called.
This is the core guarantee the Hub provides: no matter which provider processed the transaction, the state machine enforces the same rules.
The part that was harder than it looked: failures mid-flow
The happy path was straightforward to implement.
The hard part was the fact that step 2 and step 3 are not atomic.
Consider this sequence:
We persist the request intent.
We call the provider.
The provider times out.
At this point, we have committed internally that we tried to capture the transaction. But did the provider actually capture it?
We do not know.
This is one of the harder problems in payment infrastructure: the moment between committing intent internally and receiving confirmation from an external provider.
We handle these cases through explicit exception flows, queued retries and rollback mechanisms where appropriate. Each approach comes with trade-offs, but the important principle is that uncertainty is treated as a first-class state, not as an afterthought.
That means the system does not simply “fail and forget”. It records what was attempted, keeps the transaction history intact, and gives us a controlled way to resolve uncertainty.
The data model: append-only events
Every event that passes through the state machine lands in an append-only store.
No record is ever updated or deleted.
To know the current state of a transaction, we fetch all its events in order and replay them:
The state machine does the same at runtime. It does not read a status field. It reconstructs state from history.
This means there is no place in the database where a transaction can sit in an inconsistent state because an update partially completed.
The events are facts. The state is derived.
Debugging is also much easier as a result. If a transaction ends up in an unexpected state, we replay its events and the problem usually surfaces immediately.
For example, if a refund was not processed, the rejection code will appear within the event itself. Because the state machine does not progress for that event, the transaction remains in a state where the issue can be investigated or retried safely.
Adding a new provider
The Integration Hub knows nothing about the providers it works with.
Adding a new provider means writing the provider-specific mappers in the Providers service: one direction to translate the normalized request into the provider’s format, and the other to map the provider’s response back into our normalized model.
The mapper interface looks something like this:
The state machine runs the same sequence. The event store records the same events. Nothing else changes.
In practice, adding a new provider is now significantly faster. Most of the time is spent reading the provider’s API documentation and handling provider-specific edge cases in the mapper.
The Hub itself requires no changes.
Closing thoughts
State machines do not get a lot of press.
They are not interesting in the way new databases or AI workloads are interesting. But for a system where correctness matters more than novelty, where a transaction getting into the wrong state is not just a bug to fix next sprint, but a financial error affecting a real merchant, the right abstraction is the one that makes illegal states unrepresentable.
The Integration Hub has not eliminated complexity. It has moved it somewhere predictable.
Provider-specific complexity lives in the mappers. State logic lives in the machine. The rest of the system does not have to think about either.
For merchants, these details are invisible when everything works. But they matter when payment volume grows, when providers behave differently, or when an edge case becomes a real operational issue.
The Integration Hub helps us keep provider complexity contained, transaction state consistent, and payment operations reliable as we scale.
If you have built something similar, or solved the mid-flow failure problem differently, we would like to hear about it.

