February 19, 2026
One API, many processors
Designing the adapter boundary so adding the eighteenth processor changes no caller
The question that decides everything downstream
A payments orchestration platform we run sits in front of 18 processors and gateways — Stripe, CyberSource, NMI, Finix, Mastercard Payment Gateway, FlexCharge, PayNetWorx, TriplePlay among them — and presents callers with one API for charges, 3-D Secure, auth/capture/refund/void, disputes and payouts. The question that decides whether this stays maintainable or turns into a tangle is: what does the boundary between “our API” and “a specific processor’s API” look like, and who has to change when processor nineteen shows up?
The answer that works is the same answer that works for most integration problems: define the contract from the caller’s side first, and make every processor’s adapter responsible for translating into and out of that contract. The caller never sees a processor-specific shape.
The contract, not the processors, comes first
Before writing an adapter for any specific processor, the platform defines what a charge attempt looks like from the caller’s point of view, independent of who ends up processing it:
interface ChargeRequest {
amount: Money;
paymentMethod: TokenizedPaymentMethod;
threeDSecure?: ThreeDSecureContext;
idempotencyKey: string;
}
interface ChargeResult {
status: "succeeded" | "requires_action" | "declined" | "failed";
processorReference: string;
declineReason?: DeclineReason;
actionRequired?: ThreeDSecureChallenge;
}
interface ProcessorAdapter {
charge(req: ChargeRequest): Promise<ChargeResult>;
capture(ref: string, amount: Money): Promise<ChargeResult>;
refund(ref: string, amount: Money): Promise<ChargeResult>;
void(ref: string): Promise<ChargeResult>;
}
Every processor — whether its actual API speaks REST with a nested JSON
body, form-encoded fields, or SOAP — implements ProcessorAdapter. The
translation from ChargeRequest into whatever CyberSource’s or NMI’s API
actually expects lives entirely inside that processor’s adapter, and nothing
outside the adapter is allowed to know the difference.
Where the hard part actually is
The interface above looks easy. The hard part is that DeclineReason has to
mean the same thing regardless of which processor produced it, and
processors do not agree on decline taxonomies. One gateway returns a numeric
code, another returns a string enum with different granularity, a third
bundles “insufficient funds” and “do not honor” under one generic decline.
Normalizing this is most of the actual engineering effort in an adapter:
class CyberSourceAdapter implements ProcessorAdapter {
async charge(req: ChargeRequest): Promise<ChargeResult> {
const raw = await this.client.authorize(toCyberSourcePayload(req));
return {
status: mapCyberSourceStatus(raw.decision),
processorReference: raw.id,
declineReason: raw.reasonCode
? normalizeDeclineReason("cybersource", raw.reasonCode)
: undefined,
};
}
}
normalizeDeclineReason is a lookup table per processor into a shared,
finite set of reasons the rest of the platform — decline recovery, retry
logic, dispute handling — actually reasons about. Getting this wrong doesn’t
break at compile time; it breaks as decline recovery retrying a “card
expired” decline as if it were a transient “issuer unavailable,” which
either wastes attempts or, worse, retries something that will never
succeed.
Routing and fallback stay outside the adapters
Choosing which processor handles a given charge — based on cost, geography,
card brand, or a merchant’s specific routing rules, including reaching Visa
and Mastercard through acquirer and gateway integrations — is a concern that
sits above the adapters, not inside any one of them. A routing layer selects
an adapter, and if that adapter’s charge call fails in a way classified as
retryable, the same routing layer can fall back to a different processor
using the exact same ChargeRequest, unchanged:
async function chargeWithFallback(req: ChargeRequest, chain: ProcessorAdapter[]) {
for (const adapter of chain) {
const result = await adapter.charge(req);
if (result.status !== "failed" || !isRetryable(result)) return result;
}
throw new AllProcessorsFailedError();
}
None of this logic needs to know which processors are in the chain. That’s the payoff of the boundary: routing, decline recovery, and dispute handling are all written once, against the contract, and every processor the platform adds gets those behaviors for free.
What changes when processor eighteen arrives
Adding a new processor means writing one adapter: request translation,
response mapping, decline normalization, and whatever webhook signature
verification that processor requires. It means adding one entry to
configuration and routing rules. It should mean touching zero lines in the
charge API, the event handlers that react to charge.succeeded, the
transactional outbox, or any caller. If adding a processor requires a change
outside its own adapter file, that’s a sign the contract has a leak
somewhere — usually a processor-specific field that snuck into the shared
ChargeRequest type because one integration needed it and nobody pushed
back.
What this means for you
The number of processors behind an API is a business decision that changes over time; the cost of adding the next one shouldn’t be an architecture decision you have to keep revisiting. Define the contract from the caller’s perspective, put every processor-specific translation inside its own adapter, and normalize the parts — like decline reasons — that processors disagree about but the rest of your system needs to reason about uniformly. Processor eighteen should be a new file, not a new set of conditionals scattered through the code that was already there.
30 minutes with a senior engineer.
Tell us what you're building. You'll leave with an honest opinion, even if it's "you don't need us."
Reference calls with past clients are available under NDA during evaluation.