One Interface, Many Providers: Abstracting Crypto Custody Behind Your Own Domain
March 21, 2026 · 6 min read
You integrate a crypto liquidity or custody provider the obvious way: their SDK, their endpoints, their webhook format, sprinkled across your services. It ships. Then the business wants a second provider, for better rates, a currency the first does not support, or just redundancy so one provider's outage does not take you down. And you discover your codebase does not really model "a wallet" or "a withdrawal." It models that provider's idea of those things, in their vocabulary, with their quirks, everywhere.
The quirks are the killer. One provider authenticates with an API key, the next with signed requests and a rotating nonce. One calls it a "withdrawal," another a "payout," another a "transfer." Their error codes do not agree, their webhook payloads do not agree, and their notion of when money is final does not agree. Wire all of that directly into your business logic and a second provider is not an integration, it is a rewrite. Worse, the differences are subtle enough that the rewrite quietly introduces money bugs nobody notices until reconciliation does.
Own the interface
The fix is to define, in your own domain terms, exactly what your app needs: create a wallet, fetch a balance, quote a price, buy, sell, withdraw, handle an incoming event. You write that interface in your types and your vocabulary, and the rest of the app only ever talks to it. Each provider becomes an adapter: a translation layer that turns your domain calls into their API and their responses back into your domain. The business logic never imports a provider SDK. It never learns a provider exists.
The interface should be designed around what your product means, not around the union of what every provider offers. If you let it become a thin pass-through of one provider's API, you have not abstracted anything, you have just renamed it. Model the operations your business actually performs, in the units and states your business actually uses, and make each adapter do the work of bridging the gap. The real test of the interface is that a new provider with a wildly different API still fits behind it without changing a single caller.
Routing is a policy in one place
A small registry decides which adapter handles a given operation: provider A for one asset, provider B for another, a designated fallback when the primary is failing or rate-limited. Because everything flows through one interface, that routing is a single policy decision rather than an if-statement scattered through the codebase. Adding a provider is writing one adapter against a known contract. Swapping the default is a config change. Routing by capability, asking "who supports this token, in this region, right now?", becomes data you can change without a deploy. That is the payoff: the thing the business keeps wanting to change becomes the thing that is cheapest to change.
Operations have to be idempotent
Because there is money on the other side of these calls and networks fail mid-request, every operation through the interface carries an idempotency key, and each adapter honours it against its provider. A withdrawal that times out must be safe to retry without risking a double send, and the only way to guarantee that across providers with different semantics is to make idempotency a first-class part of your interface rather than something you hope each provider handles. This is exactly the kind of cross-cutting rule that is easy to enforce once, at the boundary, and nearly impossible to retrofit across a dozen scattered direct integrations.
Stubs and tests fall out for free
Once the interface exists, two nice things follow. You can write a stub adapter for a provider you have not onboarded yet: it satisfies the interface and throws where it must, so the rest of the system can be built and reasoned about before the real integration lands, and failover always has a defined target. And you can write a fake adapter for tests, so your suite runs against an in-memory provider instead of hitting a live exchange with real money and real rate limits. Testing money code against a fake you control, rather than a sandbox that behaves differently from production, is worth the small cost of writing the fake.
Webhooks are where it gets real
Inbound events are the messiest part of the whole thing, because every provider signs and shapes its webhooks differently and you control none of it. The adapter's second job is to verify each provider's signature in that provider's particular scheme, then normalize the event into the single domain event your app already understands, so "deposit confirmed" means the same thing to your system no matter who sent it.
Versioning and the long tail of provider changes
Providers change their APIs on their own schedule and rarely care about yours, so the adapter layer is also where you absorb the long tail of churn that would otherwise ripple through your whole system. A provider deprecates an endpoint, changes a field's meaning, tightens a rate limit, or alters how it signs webhooks, and because all of that is contained behind one adapter, the blast radius is one file and a set of tests rather than a hunt through the codebase. You pin and track each provider's API version explicitly, you keep a contract test per adapter that runs against the provider's sandbox to catch breaking changes before your users do, and you treat an adapter as a living component that needs maintenance rather than a write-once integration. The interface in front stays stable for the rest of the app precisely because the adapter behind it is allowed to change as often as the provider forces it to.
The honest tension, and when not to bother
An abstraction like this is a lowest-common-denominator bet, and providers do not agree on much, so the interface is under constant pressure to either bloat, as every provider's special case becomes a method, or lie, pretending differences do not exist. The way through is capability flags: the interface models the common case cleanly, and genuinely unique behaviour is exposed as an explicit, queryable capability rather than smeared across the core. You also accept some translation overhead and the ongoing cost of keeping adapters current as providers version their APIs, which they do without asking you.
And the honest caveat: if you are certain you will only ever use one provider, do not build this. A thin client is fine, and the abstraction is just ceremony that slows you down. The pattern pays for itself the moment "what if this provider goes down, prices us out, or drops a currency?" becomes a real question, which in this space it always eventually does, usually at the worst possible time.
Enjoyed this? Let me know