Start a project

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.

architecture · payments · integrations

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.