
Thought Leadership
Building a Normalization Layer Across Twenty-Four Payment Providers
Written by: Orest Vovchak

Notes on how we normalize statuses, errors, compliance checks and bank accounts across twenty-four payment providers, and the lessons we learned along the way.
The challenge: moving beyond a single provider
The first provider integration is always easy, not because it's great, but because you have nothing to compare it with. Its statuses become your status enum, its error format becomes your error handling, and its idea of a bank account becomes your database table. You think you have built an abstraction. In practice, it is closer to a copy of your first provider.
The second provider shows that. In an earlier post, We'll Just Build It Ourselves, we walked through why writing the integration is not the expensive part. The expensive part is the layer above it, the one that takes twenty-four providers with twenty-four different data models and exposes one. Every aggregator calls this "one API". For us it’s both an API problem and a data-model normalization problem. We’ve rewritten our approach three times in production to solve it. Very little of it came from a design session. It came from two years of customers telling us where the API hurts, and from providers breaking it. Recently one more kind of customer appeared: at least one integration against our API is not maintained by a team of engineers at all, it is driven by the customer's own AI agent. That changed how we think about error messages and documentation. More on that below.
What the normalization layer means in practice: The customer sends the same request to any provider (sender identity, beneficiary identity, payment instruction, provider name), gets back the same transaction object with the same status enum, and if something fails, gets the same error format with field names from their own request, not the provider's. Everything below is what it takes to keep that true for twenty-four providers, and after each section there is a question you can forward to your engineers.
Lesson 1: capabilities beyond the API docs
After about thirty integrations we know what to check before we even get sandbox access. Is the idempotency key honored, and what format does it need? How are webhooks signed, and what is the retry policy if our endpoint returns 500? Is a refund automatic or do we have to request it, and what is the time window? What happens to an on-chain deposit the provider does not recognize: wrong amount, wrong asset, missing memo? Where does the sandbox differ from production? Is the rate limit in the docs the one you actually hit? Each of those questions exists because we fell into it once. Asking them on the first call saves weeks, and it changes the conversation with the provider. When we ask for a change, we can point at the exact transfer that breaks without it, and that gets a yes much more often than a feature request.
The second check is automated, and this is where we use Claude Code seriously. We have an agent with three skills. The first reads the provider's documentation and builds a structured model of it: endpoints, auth, enums, webhook events, idempotency, rate limits. The second turns that model into a test plan, one flow per capability we need: quotes, compliance checks, deposits, withdrawals, virtual accounts, webhooks, refunds, error formats. The third runs the plan against the provider's sandbox, records every request and response, and writes down every place where the API behaved differently from the docs. The output is a drift table and a compatibility score.
The agent is only as good as the checklist it works from, and that checklist is ours: the thirteen capabilities it probes and the questions it asks are the list of things that went wrong for us across two dozen providers, written down so the agent knows where to look. Typical findings includea request body shown in the Postman collection without two fields that the live API returns 422 without or an error format documented as {errorCode, message} that actually comes back as {error, error_description}. So far every provider has had at least one of these.
We did not compare models for this, it runs on Opus and we left it there. The problems we had were not model specific problems. The first version read the docs and wrote a confident report without making a single request. So we added rules:
Every statement in the report has to point to a request and a response in the execution log.
The agent cannot call anything that changes sandbox state until it has probed auth and the read-only endpoints and corrected its plan with what came back.
Each corrected plan is saved as a new file, v1, v2, v3, nothing is overwritten, so we can diff them and see what the docs got wrong.
Those three rules are what made the output usable.
Lesson 2: not every integration makes it to production
Some integrations end with an engineer who spent six weeks on a provider, has a working client, a green test suite, and nothing to ship. Close to ten providers got real engineering time from us and never made it. The reasons include:
a sandbox that did not work through a month of polite messages in a shared Slack channel
an API that changed response formats every few weeks, so we were updating the mapper faster than we could release it
a product that existed in the sales deck but not in the API
credentials that were always coming next week
a provider that exited the corridor while we were still testing
Each of those cost weeks, and what was left was a Slack channel that went quiet. I don't count them as failures. Every one of them turned into one of the questions above, and that is why the twenty-four that did ship behave the way they do.
It's also how the list of providers gets curated. We did not pick twenty-four providers, we tried more than thirty and kept the ones that worked. If you’re building this yourself, plan for some attempts not reaching production. That’s normal in this business.
Creating a uniform state machine
Every provider has its own transaction state machine. One provider has sixteen transaction states, another has five, and a third has a status that basically means “we’ll get back to you.” Your customer’s dashboard still needs one enum.
Ours has ten values: Submitted, Verifying, Orchestrating, Pending, Processing, AwaitingDocuments, Completed, Failed, Cancelled, Refunded. Every provider has a Record<ProviderStatus, TransactionStatus> map, about 241 entries in total, and the compiler rejects a map with a missing key. We also store the provider's raw status next to ours on the transaction, so support can see both without opening the provider's dashboard.
Two rules in this layer came from incidents.
The first is Refunded. For most of our first year, REFUNDED, RETURNED and PendingRefund from different providers all mapped to Failed. Technically the payout had failed, but the money was moving back to the sender, and support had to explain that Failed sometimes meant "coming back". In February 2026 we added Refunded to the enum and changed every provider's map in one PR. A refund is a different outcome for the money, and it needs its own state.
The second is unknown states. Providers add them without a changelog. If the mapper throws on an unknown value, the monitoring workflow for that transaction fails, usually at night. Our mapper logs an error, keeps the transaction in its current state, and somebody looks at it in the morning. A transaction that waits is better than a workflow that crashes.
flowchart LR A["Provider A: 16 states"] --> M["Status mapper: Record<ProviderStatus, TransactionStatus>, ~241 entries"] B["Provider B: 7 states"] --> M C["Provider C: 5 states"] --> M M --> S["TransactionStatus enum, 10 values"] M -. unknown value .-> U["log error, keep current status"]
Question for your team: what does a refund map to in your state machine, and who decided that?
Normalizing errors
When a provider rejects a payout, the error uses the provider's field names. For example identifying_information[1].image_back is required. On your side that field is called "back of document". Somebody has to map one to the other. If your code doesn't do it, your support team will, by reading the provider's docs.
The transaction stores a failureReason JSON: a message and a list of details, each with a path into the customer's own request (identity.document.imageBack) and the provider's original text in pfiErrorMessage. The path is ours, but the text is left exactly as the provider wrote it. We translate which field is wrong, not what the provider said, because paraphrasing a compliance rejection is an easy way to be wrong about it. When the provider gives no field at all, the path falls back to the context: identity, payment instruction, transfer or quote, so the customer at least knows which object to look at.
There is more mapping logic here than you would expect. Across nine providers they are around 726 lines, and one provider alone needs 93 field mappings for customer data. There is a comment in one of them that the provider sometimes returns an array index with no field name after it. None of this shows up in an integration estimate.
The same applies to rate limits. When a provider returns 429, we pass it to the customer as a 429 with a retry hint, not as a 400 that looks like a bug in their request.
flowchart LR E["Provider error: identifying_information[1].image_back is required"] --> X["Error converter"] X --> F["failureReason<br/>path: identity.document.imageBack<br/>pfiErrorMessage: provider's original text"]
Question for your team: when a provider rejects a payout, can support tell which field is wrong without opening the provider's docs?
Normalizing compliance checks across providers
This is the hardest part, and the one we get the most feedback about. "Your KYC is complicated."
Every provider is a separately licensed company. Each one is required to know your customer itself; it cannot rely on the fact that somebody else checked (reliance agreements have their own paragraph below). So each provider asks for its own data about the same person. Some want occupation and source of funds, some want ultimate beneficial owners, and others want the purpose of the account or the customer’s name in local script. Each runs its own review on its own timeline with its own document requirements. In our schema there are more than twenty compliance check types, because "verified" at a provider can mean a base check, an enhanced check, a check for EU rails or a check for virtual accounts, and some of them include others.
No normalization layer removes that. What it can change is how many times you have to collect the data, and whether you can see where every check stands.
So we keep one identity record. It holds the superset of fields any provider has asked us for, and each provider receives it mapped to its own format. POST /identities/:id/compliance-checks/:slug starts a check with one provider. GET /identities/:id/compliance-checks lists every check for that identity with its status: not started, under review, approved, rejected, and the fields still missing. For each check, …/requirements returns the provider's JSON schema for that person, missingFields, and the required documents as allOf or oneOf sets. A coverage endpoint tells you which corridors a given check unlocks and which other checks it already includes. If the customer edits the identity, one call resyncs it to a provider that already approved it. And when a provider rejects a field, the rejection comes back with the same field paths as the error format above, so the customer's form can highlight the right input.
The part of this that customers mention most is the gap analysis. A beneficiary is approved at provider A, and the customer wants to pay them through provider B. Instead of starting a new onboarding and seeing what gets rejected, they call the requirements endpoint for B's check and get the exact delta: which fields are already there, which are missing, which documents B wants that A never asked for. Collect those, start the check, done. The second compliance check doesn’t go away, but it becomes one call, and you know exactly what it will cost in data before you make it.
A note on reliance, because many teams plan around it. Reliance means a provider agrees to rely on your verification instead of doing its own review. Where it exists, it helps, but it does less than people expect. Even with reliance the data still has to be transmitted.Travel rule requirements mean the sender and beneficiary details have to move with the money, so the provider still needs the record. At one provider, reliance removed one onboarding step, added a required document, and the full identity was still sent. And every API accepts that data differently. One takes a nested object but only above an amount threshold, another wants flat fields on the payout request for a single corridor, and another runs it as a separate exchange with its own webhooks. Reliance shifts who signs off, not what gets sent, so it comes back to how each API accepts the data.
flowchart TD I["Identity: one record, superset of every provider's fields"] --> A["Provider A, base check: Approved"] I --> B["Provider B, enhanced check: Under review, missingFields: occupation"] I --> C["Provider C, check: Not started"] A --> R["GET .../compliance-checks/:slug/requirements: JSON schema, missingFields, documents allOf / oneOf"] B --> R C --> R
Question for your team: if you add a second provider tomorrow, do you know which customers need a new compliance check, and what each of them is missing?
Normalizing bank accounts
A bank account is the same bank account everywhere, until you try to register it with two providers. Now each provider wants something different. A routing number for one, the same bank as a code from its own internal list for another, a branch code for a third. Mobile-money providers skip the bank entirely and ask for a network name.
A customer saves a payment instruction once. The first time it is used with a provider, we create it on the provider side in that provider's format and store the provider's ID next to ours. The provider's list of banks and mobile networks is mapped to our slugs, so the customer picks a bank once and it works with every provider that supports it. When it does not work, we say so. If the provider does not have that bank in its list, the withdrawal fails and the account has to be created again. There is no clean solution for that one.
There was also a gap here we lived with for too long. We knew what every provider required for a bank account on every corridor, because we had been rejected for all of it. The customer did not.
Checking requirements before the transaction
For a long time our customers learned about a required field the same way we did: from a rejection. The withdrawal went out, the provider rejected it, the transaction went to Failed with a path pointing at the field, the customer added the field and retried. We passed the loop on to them. The compliance gap analysis above showed us what the fix looks like, so we are applying the same idea to payment instructions.
GET /payment-instructions/requirements answers before anything is sent: for this provider, country, currency and payment method, what does the payment instruction need, what does the beneficiary identity need, and for a specific withdrawal, what is still missing.
Underneath it is a catalogue of 229 rules across twenty-four providers: 177 for the payment instruction, 52 for the identity. Some apply to a whole provider, some to just one country and one rail. Some are "one of" groups: bank code or bank name, either works. The rules use the same closed set of about sixty field paths that failureReason uses, so the field name you see before sending is the same one you would get back in the failure. Some examples, with provider names removed: USD payouts to the US at one provider need the relationship between sender and recipient. Payouts to individuals in Nigeria need both a national ID and a BVN. Payouts to China need the beneficiary name in Chinese. None of that is in anyone's getting-started guide.
For one provider we do not maintain the rules by hand. Its API describes its own requirements, but in its own variable names. So we fill a test request with numbered placeholder values and let the provider's validation tell us which of our fields maps to which of its variables. The result is cached for an hour and merged with the catalogue.
If a customer sends a withdrawal with something missing anyway, the validation error names the field and points to the endpoint. Withdrawals first.
sequenceDiagram participant C as Customer participant B as Borderless participant P as Provider Note over C,P: Before C->>B: POST /withdrawals B->>P: submit payout P-->>B: rejected, field missing B-->>C: status Failed, failureReason.path names the field Note over C,P: After C->>B: GET /payment-instructions/requirements B-->>C: missingFields: [paymentInstruction.recipientRelationship] C->>B: POST /withdrawals (complete) B->>P: submit payout P-->>B: accepted
Question for your team: can your API tell a customer what is missing before the money moves, with the same field names it will use in the failure?
Normalizing the sandbox
There is one more thing providers give us in twenty-four different shapes, and it is the one engineers notice first: the test environment. Every engineer who has integrated a payment provider knows the bad sandbox. The one where a transfer sits in processing for a week. The one where the webhook never arrives, so you cannot tell whether your handler is broken or theirs never fired. The one where you cannot test a refund at all, because nothing in it fails on purpose. With stablecoins there is a specific reason for this. Few providers want to deal with testnets, so their sandbox mocks what is cheap to mock on their side, but the on-chain part, matching an incoming transfer to a transaction and moving it to a final status, usually never happens in a test environment. The transaction gets created and then just stays there. So you test the happy path and deploy the rest without ever seeing it run.
A normalized status enum is not much use if the sandbox never reaches the interesting values in it. So we normalized the sandbox too. In our sandbox a transaction always reaches a final state, and you choose which one, because we simulate the part the providers skip. The request still goes to the real provider's sandbox, so the first part is real: the quote, the submission, the provider's IDs. Then the monitoring workflow switches to a simulated flow: it fetches the provider transaction once, then steps through that provider's real status sequence every twenty seconds, so your webhook handler gets the same intermediate statuses as in production. The final state is selected by the amount: ending in 9 fails, ending in 8 is refunded with a refund event and a tx hash, anything else completes. No provider credentials yet? There is a demo provider with the same API and the same ten statuses, so a team can build and test the full flow before a single real provider is enabled.
This is the feature engineers thank us for most often. One caveat: completed in our sandbox is our ending, not the provider's. It proves that your code handles every state. It does not prove that the provider settled anything.
Question for your team: can a new engineer trigger a failed payout and a refund in your sandbox on day one, without asking a provider for anything?
Giving the agent on the other side its context
More and more of the code calling our API is written, and sometimes run, by an LLM. An agent does not read a getting-started guide and remember it. It works with whatever context it has in the window right now, and it fixes problems by reading the response it just got. So the API has to carry its own context.
Most of what makes that work is the normalization described above. Errors name the exact field path in the customer's own request, so an agent can correct the payload without a human looking up what identifying_information[1] means. The requirements endpoints, for compliance checks and now for payment instructions, answer "what is missing" in the same field paths before anything is sent, so the agent can fill the gaps in one pass instead of discovering them from rejections. Statuses are one enum with ten documented values, so a workflow written against one provider does not break on the next one.
On top of that we publish the pieces an agent needs to load. The full OpenAPI spec with descriptions on operations and fields, generated from the code so it cannot drift, with deprecated endpoints removed from it. An llms-full.txt with the complete documentation in one file. A skill file that describes how to integrate with Borderless, in the format agents consume directly. An engineer with Claude Code or Cursor points the agent at those files and gets a working integration against the sandbox in an afternoon, and the sandbox then lets the agent test the failed and refunded paths too.
None of this is a separate “AI feature". It is the same normalization work, with one more consumer in mind.
Question for your team: if an agent integrates against your API, can it fix a rejected request from the error response alone?
What the normalization layer doesn't solve
Normalization does not make a provider's review faster. It makes the status visible. It does not move your payers' bank details when you change providers: a virtual account lives at one provider, and a new one means new details for the people paying you. It does not rewrite the provider's error text, on purpose. It does not pick the provider for you: every transaction names its provider, or falls back to the default you configured per corridor, and we do not reroute anything. And it does not change what you are regulated to do for your own customers. It changes how many times you have to build the plumbing.
The twenty-fifth provider
Before writing this I went through the repo to see what the layer looks like today. Twenty-four providers sit behind it. Getting here took 341 database migrations, most of them touching something a provider forced us to change, plus about three thousand commits and 675 test files, a large part of which exist because of a specific incident with a specific provider. The layer itself was rebuilt three times in production: structured failure reasons in January 2026, Refunded in February, the shared mapper base in September. A lot of this code was written with Claude as a co-author. What it needed to do came from our incidents and our customers’ feedback.
The twenty-fifth provider will still cost us weeks. For our customers it will cost nothing, because nothing in their integration changes.
Seven questions to take to your team:
What does a refund map to in your state machine, and who decided that?
When a provider rejects a payout, can support tell which field is wrong without opening the provider's docs?
If you add a second provider tomorrow, do you know which customers need a new compliance check, and what each of them is missing?
How many of your saved bank accounts would a second provider accept without changes?
Can your API tell a customer what is missing before the money moves, with the same field names it will use in the failure?
Can a new engineer trigger a failed payout and a refund in your sandbox on day one, without asking a provider for anything?
If an AI agent integrates against your API, can it fix a rejected request from the error response alone?
If your team has good answers, build it; we mean that. If each answer is a quarter of work, now you know what the quarter buys.
Notes on how we normalize statuses, errors, compliance checks and bank accounts across twenty-four payment providers, and the lessons we learned along the way.
The challenge: moving beyond a single provider
The first provider integration is always easy, not because it's great, but because you have nothing to compare it with. Its statuses become your status enum, its error format becomes your error handling, and its idea of a bank account becomes your database table. You think you have built an abstraction. In practice, it is closer to a copy of your first provider.
The second provider shows that. In an earlier post, We'll Just Build It Ourselves, we walked through why writing the integration is not the expensive part. The expensive part is the layer above it, the one that takes twenty-four providers with twenty-four different data models and exposes one. Every aggregator calls this "one API". For us it’s both an API problem and a data-model normalization problem. We’ve rewritten our approach three times in production to solve it. Very little of it came from a design session. It came from two years of customers telling us where the API hurts, and from providers breaking it. Recently one more kind of customer appeared: at least one integration against our API is not maintained by a team of engineers at all, it is driven by the customer's own AI agent. That changed how we think about error messages and documentation. More on that below.
What the normalization layer means in practice: The customer sends the same request to any provider (sender identity, beneficiary identity, payment instruction, provider name), gets back the same transaction object with the same status enum, and if something fails, gets the same error format with field names from their own request, not the provider's. Everything below is what it takes to keep that true for twenty-four providers, and after each section there is a question you can forward to your engineers.
Lesson 1: capabilities beyond the API docs
After about thirty integrations we know what to check before we even get sandbox access. Is the idempotency key honored, and what format does it need? How are webhooks signed, and what is the retry policy if our endpoint returns 500? Is a refund automatic or do we have to request it, and what is the time window? What happens to an on-chain deposit the provider does not recognize: wrong amount, wrong asset, missing memo? Where does the sandbox differ from production? Is the rate limit in the docs the one you actually hit? Each of those questions exists because we fell into it once. Asking them on the first call saves weeks, and it changes the conversation with the provider. When we ask for a change, we can point at the exact transfer that breaks without it, and that gets a yes much more often than a feature request.
The second check is automated, and this is where we use Claude Code seriously. We have an agent with three skills. The first reads the provider's documentation and builds a structured model of it: endpoints, auth, enums, webhook events, idempotency, rate limits. The second turns that model into a test plan, one flow per capability we need: quotes, compliance checks, deposits, withdrawals, virtual accounts, webhooks, refunds, error formats. The third runs the plan against the provider's sandbox, records every request and response, and writes down every place where the API behaved differently from the docs. The output is a drift table and a compatibility score.
The agent is only as good as the checklist it works from, and that checklist is ours: the thirteen capabilities it probes and the questions it asks are the list of things that went wrong for us across two dozen providers, written down so the agent knows where to look. Typical findings includea request body shown in the Postman collection without two fields that the live API returns 422 without or an error format documented as {errorCode, message} that actually comes back as {error, error_description}. So far every provider has had at least one of these.
We did not compare models for this, it runs on Opus and we left it there. The problems we had were not model specific problems. The first version read the docs and wrote a confident report without making a single request. So we added rules:
Every statement in the report has to point to a request and a response in the execution log.
The agent cannot call anything that changes sandbox state until it has probed auth and the read-only endpoints and corrected its plan with what came back.
Each corrected plan is saved as a new file, v1, v2, v3, nothing is overwritten, so we can diff them and see what the docs got wrong.
Those three rules are what made the output usable.
Lesson 2: not every integration makes it to production
Some integrations end with an engineer who spent six weeks on a provider, has a working client, a green test suite, and nothing to ship. Close to ten providers got real engineering time from us and never made it. The reasons include:
a sandbox that did not work through a month of polite messages in a shared Slack channel
an API that changed response formats every few weeks, so we were updating the mapper faster than we could release it
a product that existed in the sales deck but not in the API
credentials that were always coming next week
a provider that exited the corridor while we were still testing
Each of those cost weeks, and what was left was a Slack channel that went quiet. I don't count them as failures. Every one of them turned into one of the questions above, and that is why the twenty-four that did ship behave the way they do.
It's also how the list of providers gets curated. We did not pick twenty-four providers, we tried more than thirty and kept the ones that worked. If you’re building this yourself, plan for some attempts not reaching production. That’s normal in this business.
Creating a uniform state machine
Every provider has its own transaction state machine. One provider has sixteen transaction states, another has five, and a third has a status that basically means “we’ll get back to you.” Your customer’s dashboard still needs one enum.
Ours has ten values: Submitted, Verifying, Orchestrating, Pending, Processing, AwaitingDocuments, Completed, Failed, Cancelled, Refunded. Every provider has a Record<ProviderStatus, TransactionStatus> map, about 241 entries in total, and the compiler rejects a map with a missing key. We also store the provider's raw status next to ours on the transaction, so support can see both without opening the provider's dashboard.
Two rules in this layer came from incidents.
The first is Refunded. For most of our first year, REFUNDED, RETURNED and PendingRefund from different providers all mapped to Failed. Technically the payout had failed, but the money was moving back to the sender, and support had to explain that Failed sometimes meant "coming back". In February 2026 we added Refunded to the enum and changed every provider's map in one PR. A refund is a different outcome for the money, and it needs its own state.
The second is unknown states. Providers add them without a changelog. If the mapper throws on an unknown value, the monitoring workflow for that transaction fails, usually at night. Our mapper logs an error, keeps the transaction in its current state, and somebody looks at it in the morning. A transaction that waits is better than a workflow that crashes.
flowchart LR A["Provider A: 16 states"] --> M["Status mapper: Record<ProviderStatus, TransactionStatus>, ~241 entries"] B["Provider B: 7 states"] --> M C["Provider C: 5 states"] --> M M --> S["TransactionStatus enum, 10 values"] M -. unknown value .-> U["log error, keep current status"]
Question for your team: what does a refund map to in your state machine, and who decided that?
Normalizing errors
When a provider rejects a payout, the error uses the provider's field names. For example identifying_information[1].image_back is required. On your side that field is called "back of document". Somebody has to map one to the other. If your code doesn't do it, your support team will, by reading the provider's docs.
The transaction stores a failureReason JSON: a message and a list of details, each with a path into the customer's own request (identity.document.imageBack) and the provider's original text in pfiErrorMessage. The path is ours, but the text is left exactly as the provider wrote it. We translate which field is wrong, not what the provider said, because paraphrasing a compliance rejection is an easy way to be wrong about it. When the provider gives no field at all, the path falls back to the context: identity, payment instruction, transfer or quote, so the customer at least knows which object to look at.
There is more mapping logic here than you would expect. Across nine providers they are around 726 lines, and one provider alone needs 93 field mappings for customer data. There is a comment in one of them that the provider sometimes returns an array index with no field name after it. None of this shows up in an integration estimate.
The same applies to rate limits. When a provider returns 429, we pass it to the customer as a 429 with a retry hint, not as a 400 that looks like a bug in their request.
flowchart LR E["Provider error: identifying_information[1].image_back is required"] --> X["Error converter"] X --> F["failureReason<br/>path: identity.document.imageBack<br/>pfiErrorMessage: provider's original text"]
Question for your team: when a provider rejects a payout, can support tell which field is wrong without opening the provider's docs?
Normalizing compliance checks across providers
This is the hardest part, and the one we get the most feedback about. "Your KYC is complicated."
Every provider is a separately licensed company. Each one is required to know your customer itself; it cannot rely on the fact that somebody else checked (reliance agreements have their own paragraph below). So each provider asks for its own data about the same person. Some want occupation and source of funds, some want ultimate beneficial owners, and others want the purpose of the account or the customer’s name in local script. Each runs its own review on its own timeline with its own document requirements. In our schema there are more than twenty compliance check types, because "verified" at a provider can mean a base check, an enhanced check, a check for EU rails or a check for virtual accounts, and some of them include others.
No normalization layer removes that. What it can change is how many times you have to collect the data, and whether you can see where every check stands.
So we keep one identity record. It holds the superset of fields any provider has asked us for, and each provider receives it mapped to its own format. POST /identities/:id/compliance-checks/:slug starts a check with one provider. GET /identities/:id/compliance-checks lists every check for that identity with its status: not started, under review, approved, rejected, and the fields still missing. For each check, …/requirements returns the provider's JSON schema for that person, missingFields, and the required documents as allOf or oneOf sets. A coverage endpoint tells you which corridors a given check unlocks and which other checks it already includes. If the customer edits the identity, one call resyncs it to a provider that already approved it. And when a provider rejects a field, the rejection comes back with the same field paths as the error format above, so the customer's form can highlight the right input.
The part of this that customers mention most is the gap analysis. A beneficiary is approved at provider A, and the customer wants to pay them through provider B. Instead of starting a new onboarding and seeing what gets rejected, they call the requirements endpoint for B's check and get the exact delta: which fields are already there, which are missing, which documents B wants that A never asked for. Collect those, start the check, done. The second compliance check doesn’t go away, but it becomes one call, and you know exactly what it will cost in data before you make it.
A note on reliance, because many teams plan around it. Reliance means a provider agrees to rely on your verification instead of doing its own review. Where it exists, it helps, but it does less than people expect. Even with reliance the data still has to be transmitted.Travel rule requirements mean the sender and beneficiary details have to move with the money, so the provider still needs the record. At one provider, reliance removed one onboarding step, added a required document, and the full identity was still sent. And every API accepts that data differently. One takes a nested object but only above an amount threshold, another wants flat fields on the payout request for a single corridor, and another runs it as a separate exchange with its own webhooks. Reliance shifts who signs off, not what gets sent, so it comes back to how each API accepts the data.
flowchart TD I["Identity: one record, superset of every provider's fields"] --> A["Provider A, base check: Approved"] I --> B["Provider B, enhanced check: Under review, missingFields: occupation"] I --> C["Provider C, check: Not started"] A --> R["GET .../compliance-checks/:slug/requirements: JSON schema, missingFields, documents allOf / oneOf"] B --> R C --> R
Question for your team: if you add a second provider tomorrow, do you know which customers need a new compliance check, and what each of them is missing?
Normalizing bank accounts
A bank account is the same bank account everywhere, until you try to register it with two providers. Now each provider wants something different. A routing number for one, the same bank as a code from its own internal list for another, a branch code for a third. Mobile-money providers skip the bank entirely and ask for a network name.
A customer saves a payment instruction once. The first time it is used with a provider, we create it on the provider side in that provider's format and store the provider's ID next to ours. The provider's list of banks and mobile networks is mapped to our slugs, so the customer picks a bank once and it works with every provider that supports it. When it does not work, we say so. If the provider does not have that bank in its list, the withdrawal fails and the account has to be created again. There is no clean solution for that one.
There was also a gap here we lived with for too long. We knew what every provider required for a bank account on every corridor, because we had been rejected for all of it. The customer did not.
Checking requirements before the transaction
For a long time our customers learned about a required field the same way we did: from a rejection. The withdrawal went out, the provider rejected it, the transaction went to Failed with a path pointing at the field, the customer added the field and retried. We passed the loop on to them. The compliance gap analysis above showed us what the fix looks like, so we are applying the same idea to payment instructions.
GET /payment-instructions/requirements answers before anything is sent: for this provider, country, currency and payment method, what does the payment instruction need, what does the beneficiary identity need, and for a specific withdrawal, what is still missing.
Underneath it is a catalogue of 229 rules across twenty-four providers: 177 for the payment instruction, 52 for the identity. Some apply to a whole provider, some to just one country and one rail. Some are "one of" groups: bank code or bank name, either works. The rules use the same closed set of about sixty field paths that failureReason uses, so the field name you see before sending is the same one you would get back in the failure. Some examples, with provider names removed: USD payouts to the US at one provider need the relationship between sender and recipient. Payouts to individuals in Nigeria need both a national ID and a BVN. Payouts to China need the beneficiary name in Chinese. None of that is in anyone's getting-started guide.
For one provider we do not maintain the rules by hand. Its API describes its own requirements, but in its own variable names. So we fill a test request with numbered placeholder values and let the provider's validation tell us which of our fields maps to which of its variables. The result is cached for an hour and merged with the catalogue.
If a customer sends a withdrawal with something missing anyway, the validation error names the field and points to the endpoint. Withdrawals first.
sequenceDiagram participant C as Customer participant B as Borderless participant P as Provider Note over C,P: Before C->>B: POST /withdrawals B->>P: submit payout P-->>B: rejected, field missing B-->>C: status Failed, failureReason.path names the field Note over C,P: After C->>B: GET /payment-instructions/requirements B-->>C: missingFields: [paymentInstruction.recipientRelationship] C->>B: POST /withdrawals (complete) B->>P: submit payout P-->>B: accepted
Question for your team: can your API tell a customer what is missing before the money moves, with the same field names it will use in the failure?
Normalizing the sandbox
There is one more thing providers give us in twenty-four different shapes, and it is the one engineers notice first: the test environment. Every engineer who has integrated a payment provider knows the bad sandbox. The one where a transfer sits in processing for a week. The one where the webhook never arrives, so you cannot tell whether your handler is broken or theirs never fired. The one where you cannot test a refund at all, because nothing in it fails on purpose. With stablecoins there is a specific reason for this. Few providers want to deal with testnets, so their sandbox mocks what is cheap to mock on their side, but the on-chain part, matching an incoming transfer to a transaction and moving it to a final status, usually never happens in a test environment. The transaction gets created and then just stays there. So you test the happy path and deploy the rest without ever seeing it run.
A normalized status enum is not much use if the sandbox never reaches the interesting values in it. So we normalized the sandbox too. In our sandbox a transaction always reaches a final state, and you choose which one, because we simulate the part the providers skip. The request still goes to the real provider's sandbox, so the first part is real: the quote, the submission, the provider's IDs. Then the monitoring workflow switches to a simulated flow: it fetches the provider transaction once, then steps through that provider's real status sequence every twenty seconds, so your webhook handler gets the same intermediate statuses as in production. The final state is selected by the amount: ending in 9 fails, ending in 8 is refunded with a refund event and a tx hash, anything else completes. No provider credentials yet? There is a demo provider with the same API and the same ten statuses, so a team can build and test the full flow before a single real provider is enabled.
This is the feature engineers thank us for most often. One caveat: completed in our sandbox is our ending, not the provider's. It proves that your code handles every state. It does not prove that the provider settled anything.
Question for your team: can a new engineer trigger a failed payout and a refund in your sandbox on day one, without asking a provider for anything?
Giving the agent on the other side its context
More and more of the code calling our API is written, and sometimes run, by an LLM. An agent does not read a getting-started guide and remember it. It works with whatever context it has in the window right now, and it fixes problems by reading the response it just got. So the API has to carry its own context.
Most of what makes that work is the normalization described above. Errors name the exact field path in the customer's own request, so an agent can correct the payload without a human looking up what identifying_information[1] means. The requirements endpoints, for compliance checks and now for payment instructions, answer "what is missing" in the same field paths before anything is sent, so the agent can fill the gaps in one pass instead of discovering them from rejections. Statuses are one enum with ten documented values, so a workflow written against one provider does not break on the next one.
On top of that we publish the pieces an agent needs to load. The full OpenAPI spec with descriptions on operations and fields, generated from the code so it cannot drift, with deprecated endpoints removed from it. An llms-full.txt with the complete documentation in one file. A skill file that describes how to integrate with Borderless, in the format agents consume directly. An engineer with Claude Code or Cursor points the agent at those files and gets a working integration against the sandbox in an afternoon, and the sandbox then lets the agent test the failed and refunded paths too.
None of this is a separate “AI feature". It is the same normalization work, with one more consumer in mind.
Question for your team: if an agent integrates against your API, can it fix a rejected request from the error response alone?
What the normalization layer doesn't solve
Normalization does not make a provider's review faster. It makes the status visible. It does not move your payers' bank details when you change providers: a virtual account lives at one provider, and a new one means new details for the people paying you. It does not rewrite the provider's error text, on purpose. It does not pick the provider for you: every transaction names its provider, or falls back to the default you configured per corridor, and we do not reroute anything. And it does not change what you are regulated to do for your own customers. It changes how many times you have to build the plumbing.
The twenty-fifth provider
Before writing this I went through the repo to see what the layer looks like today. Twenty-four providers sit behind it. Getting here took 341 database migrations, most of them touching something a provider forced us to change, plus about three thousand commits and 675 test files, a large part of which exist because of a specific incident with a specific provider. The layer itself was rebuilt three times in production: structured failure reasons in January 2026, Refunded in February, the shared mapper base in September. A lot of this code was written with Claude as a co-author. What it needed to do came from our incidents and our customers’ feedback.
The twenty-fifth provider will still cost us weeks. For our customers it will cost nothing, because nothing in their integration changes.
Seven questions to take to your team:
What does a refund map to in your state machine, and who decided that?
When a provider rejects a payout, can support tell which field is wrong without opening the provider's docs?
If you add a second provider tomorrow, do you know which customers need a new compliance check, and what each of them is missing?
How many of your saved bank accounts would a second provider accept without changes?
Can your API tell a customer what is missing before the money moves, with the same field names it will use in the failure?
Can a new engineer trigger a failed payout and a refund in your sandbox on day one, without asking a provider for anything?
If an AI agent integrates against your API, can it fix a rejected request from the error response alone?
If your team has good answers, build it; we mean that. If each answer is a quarter of work, now you know what the quarter buys.
Notes on how we normalize statuses, errors, compliance checks and bank accounts across twenty-four payment providers, and the lessons we learned along the way.
The challenge: moving beyond a single provider
The first provider integration is always easy, not because it's great, but because you have nothing to compare it with. Its statuses become your status enum, its error format becomes your error handling, and its idea of a bank account becomes your database table. You think you have built an abstraction. In practice, it is closer to a copy of your first provider.
The second provider shows that. In an earlier post, We'll Just Build It Ourselves, we walked through why writing the integration is not the expensive part. The expensive part is the layer above it, the one that takes twenty-four providers with twenty-four different data models and exposes one. Every aggregator calls this "one API". For us it’s both an API problem and a data-model normalization problem. We’ve rewritten our approach three times in production to solve it. Very little of it came from a design session. It came from two years of customers telling us where the API hurts, and from providers breaking it. Recently one more kind of customer appeared: at least one integration against our API is not maintained by a team of engineers at all, it is driven by the customer's own AI agent. That changed how we think about error messages and documentation. More on that below.
What the normalization layer means in practice: The customer sends the same request to any provider (sender identity, beneficiary identity, payment instruction, provider name), gets back the same transaction object with the same status enum, and if something fails, gets the same error format with field names from their own request, not the provider's. Everything below is what it takes to keep that true for twenty-four providers, and after each section there is a question you can forward to your engineers.
Lesson 1: capabilities beyond the API docs
After about thirty integrations we know what to check before we even get sandbox access. Is the idempotency key honored, and what format does it need? How are webhooks signed, and what is the retry policy if our endpoint returns 500? Is a refund automatic or do we have to request it, and what is the time window? What happens to an on-chain deposit the provider does not recognize: wrong amount, wrong asset, missing memo? Where does the sandbox differ from production? Is the rate limit in the docs the one you actually hit? Each of those questions exists because we fell into it once. Asking them on the first call saves weeks, and it changes the conversation with the provider. When we ask for a change, we can point at the exact transfer that breaks without it, and that gets a yes much more often than a feature request.
The second check is automated, and this is where we use Claude Code seriously. We have an agent with three skills. The first reads the provider's documentation and builds a structured model of it: endpoints, auth, enums, webhook events, idempotency, rate limits. The second turns that model into a test plan, one flow per capability we need: quotes, compliance checks, deposits, withdrawals, virtual accounts, webhooks, refunds, error formats. The third runs the plan against the provider's sandbox, records every request and response, and writes down every place where the API behaved differently from the docs. The output is a drift table and a compatibility score.
The agent is only as good as the checklist it works from, and that checklist is ours: the thirteen capabilities it probes and the questions it asks are the list of things that went wrong for us across two dozen providers, written down so the agent knows where to look. Typical findings includea request body shown in the Postman collection without two fields that the live API returns 422 without or an error format documented as {errorCode, message} that actually comes back as {error, error_description}. So far every provider has had at least one of these.
We did not compare models for this, it runs on Opus and we left it there. The problems we had were not model specific problems. The first version read the docs and wrote a confident report without making a single request. So we added rules:
Every statement in the report has to point to a request and a response in the execution log.
The agent cannot call anything that changes sandbox state until it has probed auth and the read-only endpoints and corrected its plan with what came back.
Each corrected plan is saved as a new file, v1, v2, v3, nothing is overwritten, so we can diff them and see what the docs got wrong.
Those three rules are what made the output usable.
Lesson 2: not every integration makes it to production
Some integrations end with an engineer who spent six weeks on a provider, has a working client, a green test suite, and nothing to ship. Close to ten providers got real engineering time from us and never made it. The reasons include:
a sandbox that did not work through a month of polite messages in a shared Slack channel
an API that changed response formats every few weeks, so we were updating the mapper faster than we could release it
a product that existed in the sales deck but not in the API
credentials that were always coming next week
a provider that exited the corridor while we were still testing
Each of those cost weeks, and what was left was a Slack channel that went quiet. I don't count them as failures. Every one of them turned into one of the questions above, and that is why the twenty-four that did ship behave the way they do.
It's also how the list of providers gets curated. We did not pick twenty-four providers, we tried more than thirty and kept the ones that worked. If you’re building this yourself, plan for some attempts not reaching production. That’s normal in this business.
Creating a uniform state machine
Every provider has its own transaction state machine. One provider has sixteen transaction states, another has five, and a third has a status that basically means “we’ll get back to you.” Your customer’s dashboard still needs one enum.
Ours has ten values: Submitted, Verifying, Orchestrating, Pending, Processing, AwaitingDocuments, Completed, Failed, Cancelled, Refunded. Every provider has a Record<ProviderStatus, TransactionStatus> map, about 241 entries in total, and the compiler rejects a map with a missing key. We also store the provider's raw status next to ours on the transaction, so support can see both without opening the provider's dashboard.
Two rules in this layer came from incidents.
The first is Refunded. For most of our first year, REFUNDED, RETURNED and PendingRefund from different providers all mapped to Failed. Technically the payout had failed, but the money was moving back to the sender, and support had to explain that Failed sometimes meant "coming back". In February 2026 we added Refunded to the enum and changed every provider's map in one PR. A refund is a different outcome for the money, and it needs its own state.
The second is unknown states. Providers add them without a changelog. If the mapper throws on an unknown value, the monitoring workflow for that transaction fails, usually at night. Our mapper logs an error, keeps the transaction in its current state, and somebody looks at it in the morning. A transaction that waits is better than a workflow that crashes.
flowchart LR A["Provider A: 16 states"] --> M["Status mapper: Record<ProviderStatus, TransactionStatus>, ~241 entries"] B["Provider B: 7 states"] --> M C["Provider C: 5 states"] --> M M --> S["TransactionStatus enum, 10 values"] M -. unknown value .-> U["log error, keep current status"]
Question for your team: what does a refund map to in your state machine, and who decided that?
Normalizing errors
When a provider rejects a payout, the error uses the provider's field names. For example identifying_information[1].image_back is required. On your side that field is called "back of document". Somebody has to map one to the other. If your code doesn't do it, your support team will, by reading the provider's docs.
The transaction stores a failureReason JSON: a message and a list of details, each with a path into the customer's own request (identity.document.imageBack) and the provider's original text in pfiErrorMessage. The path is ours, but the text is left exactly as the provider wrote it. We translate which field is wrong, not what the provider said, because paraphrasing a compliance rejection is an easy way to be wrong about it. When the provider gives no field at all, the path falls back to the context: identity, payment instruction, transfer or quote, so the customer at least knows which object to look at.
There is more mapping logic here than you would expect. Across nine providers they are around 726 lines, and one provider alone needs 93 field mappings for customer data. There is a comment in one of them that the provider sometimes returns an array index with no field name after it. None of this shows up in an integration estimate.
The same applies to rate limits. When a provider returns 429, we pass it to the customer as a 429 with a retry hint, not as a 400 that looks like a bug in their request.
flowchart LR E["Provider error: identifying_information[1].image_back is required"] --> X["Error converter"] X --> F["failureReason<br/>path: identity.document.imageBack<br/>pfiErrorMessage: provider's original text"]
Question for your team: when a provider rejects a payout, can support tell which field is wrong without opening the provider's docs?
Normalizing compliance checks across providers
This is the hardest part, and the one we get the most feedback about. "Your KYC is complicated."
Every provider is a separately licensed company. Each one is required to know your customer itself; it cannot rely on the fact that somebody else checked (reliance agreements have their own paragraph below). So each provider asks for its own data about the same person. Some want occupation and source of funds, some want ultimate beneficial owners, and others want the purpose of the account or the customer’s name in local script. Each runs its own review on its own timeline with its own document requirements. In our schema there are more than twenty compliance check types, because "verified" at a provider can mean a base check, an enhanced check, a check for EU rails or a check for virtual accounts, and some of them include others.
No normalization layer removes that. What it can change is how many times you have to collect the data, and whether you can see where every check stands.
So we keep one identity record. It holds the superset of fields any provider has asked us for, and each provider receives it mapped to its own format. POST /identities/:id/compliance-checks/:slug starts a check with one provider. GET /identities/:id/compliance-checks lists every check for that identity with its status: not started, under review, approved, rejected, and the fields still missing. For each check, …/requirements returns the provider's JSON schema for that person, missingFields, and the required documents as allOf or oneOf sets. A coverage endpoint tells you which corridors a given check unlocks and which other checks it already includes. If the customer edits the identity, one call resyncs it to a provider that already approved it. And when a provider rejects a field, the rejection comes back with the same field paths as the error format above, so the customer's form can highlight the right input.
The part of this that customers mention most is the gap analysis. A beneficiary is approved at provider A, and the customer wants to pay them through provider B. Instead of starting a new onboarding and seeing what gets rejected, they call the requirements endpoint for B's check and get the exact delta: which fields are already there, which are missing, which documents B wants that A never asked for. Collect those, start the check, done. The second compliance check doesn’t go away, but it becomes one call, and you know exactly what it will cost in data before you make it.
A note on reliance, because many teams plan around it. Reliance means a provider agrees to rely on your verification instead of doing its own review. Where it exists, it helps, but it does less than people expect. Even with reliance the data still has to be transmitted.Travel rule requirements mean the sender and beneficiary details have to move with the money, so the provider still needs the record. At one provider, reliance removed one onboarding step, added a required document, and the full identity was still sent. And every API accepts that data differently. One takes a nested object but only above an amount threshold, another wants flat fields on the payout request for a single corridor, and another runs it as a separate exchange with its own webhooks. Reliance shifts who signs off, not what gets sent, so it comes back to how each API accepts the data.
flowchart TD I["Identity: one record, superset of every provider's fields"] --> A["Provider A, base check: Approved"] I --> B["Provider B, enhanced check: Under review, missingFields: occupation"] I --> C["Provider C, check: Not started"] A --> R["GET .../compliance-checks/:slug/requirements: JSON schema, missingFields, documents allOf / oneOf"] B --> R C --> R
Question for your team: if you add a second provider tomorrow, do you know which customers need a new compliance check, and what each of them is missing?
Normalizing bank accounts
A bank account is the same bank account everywhere, until you try to register it with two providers. Now each provider wants something different. A routing number for one, the same bank as a code from its own internal list for another, a branch code for a third. Mobile-money providers skip the bank entirely and ask for a network name.
A customer saves a payment instruction once. The first time it is used with a provider, we create it on the provider side in that provider's format and store the provider's ID next to ours. The provider's list of banks and mobile networks is mapped to our slugs, so the customer picks a bank once and it works with every provider that supports it. When it does not work, we say so. If the provider does not have that bank in its list, the withdrawal fails and the account has to be created again. There is no clean solution for that one.
There was also a gap here we lived with for too long. We knew what every provider required for a bank account on every corridor, because we had been rejected for all of it. The customer did not.
Checking requirements before the transaction
For a long time our customers learned about a required field the same way we did: from a rejection. The withdrawal went out, the provider rejected it, the transaction went to Failed with a path pointing at the field, the customer added the field and retried. We passed the loop on to them. The compliance gap analysis above showed us what the fix looks like, so we are applying the same idea to payment instructions.
GET /payment-instructions/requirements answers before anything is sent: for this provider, country, currency and payment method, what does the payment instruction need, what does the beneficiary identity need, and for a specific withdrawal, what is still missing.
Underneath it is a catalogue of 229 rules across twenty-four providers: 177 for the payment instruction, 52 for the identity. Some apply to a whole provider, some to just one country and one rail. Some are "one of" groups: bank code or bank name, either works. The rules use the same closed set of about sixty field paths that failureReason uses, so the field name you see before sending is the same one you would get back in the failure. Some examples, with provider names removed: USD payouts to the US at one provider need the relationship between sender and recipient. Payouts to individuals in Nigeria need both a national ID and a BVN. Payouts to China need the beneficiary name in Chinese. None of that is in anyone's getting-started guide.
For one provider we do not maintain the rules by hand. Its API describes its own requirements, but in its own variable names. So we fill a test request with numbered placeholder values and let the provider's validation tell us which of our fields maps to which of its variables. The result is cached for an hour and merged with the catalogue.
If a customer sends a withdrawal with something missing anyway, the validation error names the field and points to the endpoint. Withdrawals first.
sequenceDiagram participant C as Customer participant B as Borderless participant P as Provider Note over C,P: Before C->>B: POST /withdrawals B->>P: submit payout P-->>B: rejected, field missing B-->>C: status Failed, failureReason.path names the field Note over C,P: After C->>B: GET /payment-instructions/requirements B-->>C: missingFields: [paymentInstruction.recipientRelationship] C->>B: POST /withdrawals (complete) B->>P: submit payout P-->>B: accepted
Question for your team: can your API tell a customer what is missing before the money moves, with the same field names it will use in the failure?
Normalizing the sandbox
There is one more thing providers give us in twenty-four different shapes, and it is the one engineers notice first: the test environment. Every engineer who has integrated a payment provider knows the bad sandbox. The one where a transfer sits in processing for a week. The one where the webhook never arrives, so you cannot tell whether your handler is broken or theirs never fired. The one where you cannot test a refund at all, because nothing in it fails on purpose. With stablecoins there is a specific reason for this. Few providers want to deal with testnets, so their sandbox mocks what is cheap to mock on their side, but the on-chain part, matching an incoming transfer to a transaction and moving it to a final status, usually never happens in a test environment. The transaction gets created and then just stays there. So you test the happy path and deploy the rest without ever seeing it run.
A normalized status enum is not much use if the sandbox never reaches the interesting values in it. So we normalized the sandbox too. In our sandbox a transaction always reaches a final state, and you choose which one, because we simulate the part the providers skip. The request still goes to the real provider's sandbox, so the first part is real: the quote, the submission, the provider's IDs. Then the monitoring workflow switches to a simulated flow: it fetches the provider transaction once, then steps through that provider's real status sequence every twenty seconds, so your webhook handler gets the same intermediate statuses as in production. The final state is selected by the amount: ending in 9 fails, ending in 8 is refunded with a refund event and a tx hash, anything else completes. No provider credentials yet? There is a demo provider with the same API and the same ten statuses, so a team can build and test the full flow before a single real provider is enabled.
This is the feature engineers thank us for most often. One caveat: completed in our sandbox is our ending, not the provider's. It proves that your code handles every state. It does not prove that the provider settled anything.
Question for your team: can a new engineer trigger a failed payout and a refund in your sandbox on day one, without asking a provider for anything?
Giving the agent on the other side its context
More and more of the code calling our API is written, and sometimes run, by an LLM. An agent does not read a getting-started guide and remember it. It works with whatever context it has in the window right now, and it fixes problems by reading the response it just got. So the API has to carry its own context.
Most of what makes that work is the normalization described above. Errors name the exact field path in the customer's own request, so an agent can correct the payload without a human looking up what identifying_information[1] means. The requirements endpoints, for compliance checks and now for payment instructions, answer "what is missing" in the same field paths before anything is sent, so the agent can fill the gaps in one pass instead of discovering them from rejections. Statuses are one enum with ten documented values, so a workflow written against one provider does not break on the next one.
On top of that we publish the pieces an agent needs to load. The full OpenAPI spec with descriptions on operations and fields, generated from the code so it cannot drift, with deprecated endpoints removed from it. An llms-full.txt with the complete documentation in one file. A skill file that describes how to integrate with Borderless, in the format agents consume directly. An engineer with Claude Code or Cursor points the agent at those files and gets a working integration against the sandbox in an afternoon, and the sandbox then lets the agent test the failed and refunded paths too.
None of this is a separate “AI feature". It is the same normalization work, with one more consumer in mind.
Question for your team: if an agent integrates against your API, can it fix a rejected request from the error response alone?
What the normalization layer doesn't solve
Normalization does not make a provider's review faster. It makes the status visible. It does not move your payers' bank details when you change providers: a virtual account lives at one provider, and a new one means new details for the people paying you. It does not rewrite the provider's error text, on purpose. It does not pick the provider for you: every transaction names its provider, or falls back to the default you configured per corridor, and we do not reroute anything. And it does not change what you are regulated to do for your own customers. It changes how many times you have to build the plumbing.
The twenty-fifth provider
Before writing this I went through the repo to see what the layer looks like today. Twenty-four providers sit behind it. Getting here took 341 database migrations, most of them touching something a provider forced us to change, plus about three thousand commits and 675 test files, a large part of which exist because of a specific incident with a specific provider. The layer itself was rebuilt three times in production: structured failure reasons in January 2026, Refunded in February, the shared mapper base in September. A lot of this code was written with Claude as a co-author. What it needed to do came from our incidents and our customers’ feedback.
The twenty-fifth provider will still cost us weeks. For our customers it will cost nothing, because nothing in their integration changes.
Seven questions to take to your team:
What does a refund map to in your state machine, and who decided that?
When a provider rejects a payout, can support tell which field is wrong without opening the provider's docs?
If you add a second provider tomorrow, do you know which customers need a new compliance check, and what each of them is missing?
How many of your saved bank accounts would a second provider accept without changes?
Can your API tell a customer what is missing before the money moves, with the same field names it will use in the failure?
Can a new engineer trigger a failed payout and a refund in your sandbox on day one, without asking a provider for anything?
If an AI agent integrates against your API, can it fix a rejected request from the error response alone?
If your team has good answers, build it; we mean that. If each answer is a quarter of work, now you know what the quarter buys.
Other Articles
Global Stablecoin Orchestration Network

Borderless Innovations Labs Inc. (Borderless) is a technology and smart contract development company. Borderless in not a broker-dealer or financial institution and does not engage any conduct or transactions requiring such registration. All financial products are offered by and through financial institutions directly. Borderless does not make any recommendation for the purchase or sale of digital assets. Our products and services are offered in limited jurisdictions so please contact our partnerships team for further information and refer to our Terms of Services.
Global Stablecoin Orchestration Network

Borderless Innovations Labs Inc. (Borderless) is a technology and smart contract development company. Borderless in not a broker-dealer or financial institution and does not engage any conduct or transactions requiring such registration. All financial products are offered by and through financial institutions directly. Borderless does not make any recommendation for the purchase or sale of digital assets. Our products and services are offered in limited jurisdictions so please contact our partnerships team for further information and refer to our Terms of Services.
Global Stablecoin Orchestration Network

Borderless Innovations Labs Inc. (Borderless) is a technology and smart contract development company. Borderless in not a broker-dealer or financial institution and does not engage any conduct or transactions requiring such registration. All financial products are offered by and through financial institutions directly. Borderless does not make any recommendation for the purchase or sale of digital assets. Our products and services are offered in limited jurisdictions so please contact our partnerships team for further information and refer to our Terms of Services.