When you integrate with one payment partner, you write code that talks to their API. When you integrate with four — each with different auth, different endpoints, different state models, and different webhook formats — you need architecture.
This post walks through the connector pattern we built for a fiat-to-crypto platform. The system routes payments through OwlPay, Yellow Card, Brale, and MoneyGram depending on the user's region, currency, and target blockchain. The routing layer doesn't know which partner it's talking to. That's the point.
The Problem: Four Partners, Four Worlds
Each partner we integrated has a fundamentally different approach:
- OwlPay — API key in a header, idempotency keys on writes, a three-step quote → requirements → transfer flow
- Yellow Card — HMAC-SHA256 signature on every request (timestamp + path + body), channel/network discovery before payment submission
- Brale — OAuth2 client credentials flow to get a bearer token, then standard REST with that token
- MoneyGram — Stellar SEP-10 authentication (challenge-response with Stellar keypairs), then SEP-24 interactive flow with a webview URL
If you wire each partner directly into your business logic, you get four parallel codepaths that diverge more with every feature. Status polling? Four implementations. Webhook handling? Four parsers. Error handling? Four retry strategies.
The Pattern: One Interface, Many Implementations
The core idea is simple: define what a payment partner does, not how it does it.
interface PaymentPartnerAdapter {
readonly partnerId: string;
readonly capabilities: PartnerCapabilities;
// On-ramp (fiat → crypto)
getOnRampQuote(params: QuoteRequest): Promise<NormalizedQuote>;
initiateOnRamp(params: OnRampRequest, idempotencyKey: string): Promise<NormalizedTransaction>;
// Off-ramp (crypto → fiat)
getOffRampQuote(params: QuoteRequest): Promise<NormalizedQuote>;
initiateOffRamp(params: OffRampRequest, idempotencyKey: string): Promise<NormalizedTransaction>;
// Lifecycle
getTransactionStatus(partnerTxId: string): Promise<NormalizedStatus>;
cancelTransaction(partnerTxId: string): Promise<void>;
// Webhooks
validateWebhook(payload: unknown, headers: Record<string, string>): boolean;
parseWebhook(payload: unknown): NormalizedWebhookEvent;
// Health
healthCheck(): Promise<PartnerHealth>;
}Every partner implements this interface. The OwlPay adapter knows about API keys and Harbor v2 endpoints. The Yellow Card adapter knows about HMAC signatures and channel discovery. But nothing outside the adapter knows any of that.
Structured Capabilities for Smart Routing
Each adapter declares what it can do:
interface PartnerCapabilities {
supportsOnRamp: boolean;
supportsOffRamp: boolean;
supportedRegions: string[]; // ['US'] or ['NG', 'GH', 'KE', ...]
supportedFiatCurrencies: string[]; // ['USD'] or ['NGN', 'GHS', 'KES', ...]
supportedChains: string[]; // ['ethereum', 'polygon', 'stellar', ...]
supportedAssets: string[]; // ['USDC', 'USDT']
minAmount: bigint;
maxAmount: bigint;
priority: number; // Lower = preferred
estimatedFeePercent: number;
}The routing engine uses these capabilities to match a user's request to the best partner. A Ugandan user paying in UGX is routed to Yellow Card. A US user buying USDC on Ethereum is routed to OwlPay or Brale (whichever has a lower fee and higher priority). A user wanting USDC on Stellar gets MoneyGram.
This is a data-driven decision, not a code-driven one. No if (region === 'UGX') anywhere in the routing logic.
Nest.js Wiring: The DI Container Does the Work
In Nest.js, we use a custom injection token to collect all adapters:
// All adapters register under the same token
export const PARTNER_ADAPTER = 'PARTNER_ADAPTER';
// ConnectorModule collects them
@Module({})
export class ConnectorModule {
static register(
adapterClasses: Array<new (...args: any[]) => PaymentPartnerAdapter>,
): DynamicModule {
return {
global: true,
module: ConnectorModule,
providers: [
...adapterClasses.map(cls => ({ provide: cls, useClass: cls })),
{
provide: PARTNER_ADAPTER,
useFactory: (...adapters: PaymentPartnerAdapter[]) => adapters,
inject: adapterClasses,
},
ConnectorService,
RoutePlannerService,
],
exports: [ConnectorService, RoutePlannerService],
};
}
}
// In AppModule
ConnectorModule.register([
OwlPayAdapter, YellowCardAdapter, BraleAdapter, MoneygramAdapter,
])The ConnectorService receives all adapters as an array. It doesn't import them individually and doesn't know how many there are.
The Route Planner: Computing Multi-Leg Plans
Some transactions require multiple steps. Buying USDC on Hedera when no partner delivers directly to Hedera means:
- Buy USDC on an EVM chain (via a partner)
- Bridge USDC from that chain to Hedera (via Hashport)
The Route Planner computes this at intent creation:
computePlan(request: RoutePlanRequest, adapters: PaymentPartnerAdapter[]): RoutePlan | null {
switch (request.type) {
case 'onramp':
return this.planOnRamp(request, adapters); // 1 leg
case 'offramp':
return this.planOffRamp(request, adapters); // 1 leg
case 'onramp_and_bridge':
return this.planOnRampAndBridge(request, adapters); // 2 legs
case 'bridge':
return this.planBridge(request); // 1 leg
}
}The plan is stored as JSON on the PaymentIntent. The execution engine reads it and processes legs in order.
Why Not Just Use a Payment Aggregator?
Fair question. Services like Ramp Network or MoonPay aggregate multiple providers behind their own API. The difference:
- Control — We control the routing logic, the fallback strategy, and the fee structure. An aggregator makes those decisions for you.
- Direct relationships — The client can negotiate rates directly with each partner. Aggregators add a margin.
- Specialized partners — Yellow Card for African mobile money and MoneyGram for cash-based markets aren't available through typical aggregators.
- Cross-chain bridging — No aggregator handles the "buy on Ethereum, bridge to Hedera" flow. That's custom orchestration.
Mock Mode: Demo Without Credentials
Every adapter has a mock mode that simulates realistic responses:
constructor(private readonly config: ConfigService) {
this.isMockMode = this.config.get('PARTNER_MODE') === 'mock';
}
async getOnRampQuote(params: QuoteRequest): Promise<NormalizedQuote> {
if (this.isMockMode) {
return transformQuote(mockCreateQuote(params.sourceCurrency, params.amount));
}
// Real API call...
}Mocks simulate state progressions too — a transfer starts as pending, advances to processing, then completed on each status poll. This lets the entire platform run end-to-end without partner sandbox credentials.
Switching to live: change one environment variable (PARTNER_MODE=production) and add partner API credentials.
Money as Integers: Branded Types
In fintech, the most dangerous bugs involve money representation. Is 100 dollars or cents? Is 100.50 stored as a float (rounding errors) or a string?
We store all amounts as bigint in the smallest unit (cents for USD, 6 decimals for USDC, 8 for HBAR). TypeScript's branded types make it impossible to mix them up:
declare const __brand: unique symbol;
type AmountInSmallestUnit = bigint & { readonly [__brand]: 'SmallestUnit' };
// You MUST convert explicitly:
const amount = toSmallestUnit('10.50', 2); // → 1050n as AmountInSmallestUnit
// You CAN'T accidentally pass a raw number:
// const bad: AmountInSmallestUnit = 1050n; // ❌ Type errorThis catches bugs at compile time that would otherwise show up as off-by-100x errors in production.
What We'd Do Differently
- BullMQ for execution — We drive leg execution synchronously (fire-and-forget async). For production volume, each leg should be a BullMQ job with automatic retries and dead-letter queues.
- Per-partner circuit breakers — If OwlPay goes down, the router should automatically shift traffic to Brale for US users instead of failing.
- Webhook signature verification — Our mock mode accepts all webhooks. Production needs per-partner HMAC/signature verification before processing.
The Takeaway
The Strategy + Adapter pattern isn't novel. But applying it well — with structured capabilities for routing, normalized types for interop, and mock mode for development — turns "integrate four different APIs" from a maintenance nightmare into a pluggable system.
The real test: when the client asks to add a fifth partner, the answer should be "implement one adapter, register it, done." That's what we built.
This architecture powers the demo Fiat-Crypto Bridge platform. Built by Agile Creative Minds.