Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 074 / 100

Designing multi-gateway payments: lessons from production

Lessons from integrating several payment gateways.

Expona Admin12 min read

Every engineer working on e-commerce or SaaS has faced the question: what happens when your primary payment gateway goes down? Designing multi-gateway payments isn't just about redundancy—it's about building a payment abstraction layer that treats all gateways as interchangeable implementations of a unified contract. I'll walk you through the lessons I learned integrating Stripe, PayPal, and local processors across different markets.

One internal payment model — Gateways differ; your order and payment states shouldn't

The biggest mistake I made early was letting gateway responses dictate my database schema. Stripe's payment_intent has different fields than PayPal's sale object, which differ again from a local provider's response. This led to conditional logic scattered across my codebase—checking gateway type before accessing payment state.

The solution is a canonical payment model that lives entirely in your domain. This model doesn't care which gateway processed the transaction. Here's what I mean:

typescript
enum PaymentStatus {
  PENDING = 'PENDING',
  AUTHORIZED = 'AUTHORIZED',
  CAPTURED = 'CAPTURED',
  FAILED = 'FAILED',
  REFUNDED = 'REFUNDED',
  DISPUTED = 'DISPUTED'
}

interface Payment {
  id: string;
  orderId: string;
  amount: number;
  currency: string;
  status: PaymentStatus;
  gateway: 'stripe' | 'paypal' | 'local_processor';
  gatewayTransactionId: string;
  gatewayReference?: string;
  authorizedAt?: Date;
  capturedAt?: Date;
  failureReason?: string;
  metadata: Record<string, unknown>;
  createdAt: Date;
  updatedAt: Date;
}

interface PaymentCapture {
  paymentId: string;
  capturedAmount: number;
  capturedAt: Date;
  gatewayResponse: string;
}

interface PaymentRefund {
  paymentId: string;
  refundId: string;
  refundedAmount: number;
  refundedAt: Date;
  reason: string;
  gatewayRefundId: string;
}

Your order state machine should only reference this canonical model, never the raw gateway response:

typescript
enum OrderStatus {
  PENDING_PAYMENT = 'PENDING_PAYMENT',
  PAYMENT_AUTHORIZED = 'PAYMENT_AUTHORIZED',
  PAYMENT_CAPTURED = 'PAYMENT_CAPTURED',
  FULFILLING = 'FULFILLING',
  FULFILLED = 'FULFILLED',
  PAYMENT_FAILED = 'PAYMENT_FAILED',
  CANCELLED = 'CANCELLED'
}

async function processPaymentAuthorization(
  orderId: string,
  payment: Payment
): Promise<void> {
  if (payment.status === PaymentStatus.AUTHORIZED) {
    await updateOrderStatus(orderId, OrderStatus.PAYMENT_AUTHORIZED);
    await publishEvent('PaymentAuthorized', { orderId, paymentId: payment.id });
  } else if (payment.status === PaymentStatus.FAILED) {
    await updateOrderStatus(orderId, OrderStatus.PAYMENT_FAILED);
    await publishEvent('PaymentFailed', { orderId, reason: payment.failureReason });
  }
}

This decoupling is crucial. When you add a sixth gateway later, your order logic doesn't change—only the adapter does.

Adapters per gateway — Each integration translates to your model

Each payment gateway has its own SDK, API shape, and quirks. An adapter encapsulates these differences and exposes a consistent interface. Think of it as a translator between the gateway's language and yours.

Here's the interface your adapters implement:

typescript
interface PaymentGatewayAdapter {
  authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>;
  capture(request: CaptureRequest): Promise<CaptureResponse>;
  refund(request: RefundRequest): Promise<RefundResponse>;
  fetchTransaction(transactionId: string): Promise<Transaction>;
  validateWebhook(payload: unknown, signature: string): boolean;
}

interface AuthorizeRequest {
  orderId: string;
  amount: number;
  currency: string;
  cardToken?: string;
  paymentMethod?: string;
  customer?: { email: string; name: string };
  returnUrl?: string;
  idempotencyKey: string;
}

interface AuthorizeResponse {
  status: PaymentStatus;
  gatewayTransactionId: string;
  gatewayReference?: string;
  redirectUrl?: string;
  authorizationCode?: string;
  errorMessage?: string;
}

Now implement the Stripe adapter:

typescript
class StripeAdapter implements PaymentGatewayAdapter {
  private stripe: Stripe;

  constructor(apiKey: string) {
    this.stripe = new Stripe(apiKey);
  }

  async authorize(request: AuthorizeRequest): Promise<AuthorizeResponse> {
    try {
      const paymentIntent = await this.stripe.paymentIntents.create({
        amount: Math.round(request.amount * 100),
        currency: request.currency.toLowerCase(),
        payment_method: request.cardToken,
        confirm: true,
        return_url: request.returnUrl,
        statement_descriptor: `Order ${request.orderId}`.substring(0, 22),
        metadata: { orderId: request.orderId },
        idempotency_key: request.idempotencyKey
      });

      const status = this.mapStripeStatus(paymentIntent.status);
      return {
        status,
        gatewayTransactionId: paymentIntent.id,
        authorizationCode: paymentIntent.charges.data[0]?.id,
        redirectUrl: paymentIntent.client_secret ? undefined : undefined,
        errorMessage: paymentIntent.last_payment_error?.message
      };
    } catch (error) {
      return {
        status: PaymentStatus.FAILED,
        gatewayTransactionId: '',
        errorMessage: error.message
      };
    }
  }

  async capture(request: CaptureRequest): Promise<CaptureResponse> {
    const paymentIntent = await this.stripe.paymentIntents.retrieve(
      request.gatewayTransactionId
    );
    await this.stripe.paymentIntents.confirm(request.gatewayTransactionId);
    return {
      success: paymentIntent.status === 'succeeded',
      gatewayResponse: paymentIntent.id
    };
  }

  async refund(request: RefundRequest): Promise<RefundResponse> {
    const refund = await this.stripe.refunds.create({
      payment_intent: request.gatewayTransactionId,
      amount: Math.round(request.amount * 100),
      reason: 'requested_by_customer',
      metadata: { orderId: request.orderId }
    });
    return {
      success: refund.status === 'succeeded',
      refundId: refund.id
    };
  }

  private mapStripeStatus(stripeStatus: string): PaymentStatus {
    const mapping: Record<string, PaymentStatus> = {
      'requires_action': PaymentStatus.PENDING,
      'succeeded': PaymentStatus.CAPTURED,
      'processing': PaymentStatus.AUTHORIZED,
      'requires_payment_method': PaymentStatus.FAILED
    };
    return mapping[stripeStatus] ?? PaymentStatus.PENDING;
  }

  validateWebhook(payload: unknown, signature: string): boolean {
    try {
      this.stripe.webhooks.constructEvent(
        JSON.stringify(payload),
        signature,
        process.env.STRIPE_WEBHOOK_SECRET!
      );
      return true;
    } catch {
      return false;
    }
  }
}

And a PayPal adapter following the same contract:

typescript
class PayPalAdapter implements PaymentGatewayAdapter {
  private client: paypal.Client;

  async authorize(request: AuthorizeRequest): Promise<AuthorizeResponse> {
    const payment = {
      intent: 'authorize',
      payer: { payment_method: 'credit_card' },
      transactions: [{
        amount: {
          total: request.amount.toString(),
          currency: request.currency
        },
        description: `Order ${request.orderId}`
      }]
    };

    return new Promise((resolve, reject) => {
      this.client.execute(payment, (err, result) => {
        if (err) {
          resolve({
            status: PaymentStatus.FAILED,
            gatewayTransactionId: '',
            errorMessage: err.message
          });
        } else {
          resolve({
            status: PaymentStatus.AUTHORIZED,
            gatewayTransactionId: result.id,
            authorizationCode: result.transactions[0].related_resources[0].authorization.id
          });
        }
      });
    });
  }

  // Implement other methods similarly...
}

The key insight: your service layer never imports Stripe SDK directly. It only knows about PaymentGatewayAdapter, which each gateway implements.

Route intelligently — Choose gateways by currency, country or availability

Not every gateway works everywhere. Stripe dominates the US and EU but doesn't operate in Bangladesh. PayPal is global but more expensive. Local processors are cheaper but regional. Your routing logic decides which adapter to use based on business rules.

Build a gateway router that makes this decision:

typescript
interface GatewayConfig {
  name: 'stripe' | 'paypal' | 'local_processor';
  priority: number;
  supportedCountries: string[];
  supportedCurrencies: string[];
  minAmount: number;
  maxAmount: number;
  enabled: boolean;
}

class PaymentGatewayRouter {
  private adapters: Map<string, PaymentGatewayAdapter>;
  private configs: GatewayConfig[];

  constructor(
    adapters: Map<string, PaymentGatewayAdapter>,
    configs: GatewayConfig[]
  ) {
    this.adapters = adapters;
    this.configs = configs.sort((a, b) => b.priority - a.priority);
  }

  selectGateway(
    country: string,
    currency: string,
    amount: number
  ): {adapter: PaymentGatewayAdapter; gatewayName: string} {
    const candidates = this.configs.filter(cfg =>
      cfg.enabled &&
      cfg.supportedCountries.includes(country) &&
      cfg.supportedCurrencies.includes(currency) &&
      amount >= cfg.minAmount &&
      amount <= cfg.maxAmount
    );

    if (candidates.length === 0) {
      throw new Error(
        `No gateway available for ${country}/${currency}/${amount}`
      );
    }

    const selected = candidates[0];
    return {
      adapter: this.adapters.get(selected.name)!,
      gatewayName: selected.name
    };
  }
}

// Configuration example
const gatewayConfigs: GatewayConfig[] = [
  {
    name: 'local_processor',
    priority: 100,
    supportedCountries: ['LK'],
    supportedCurrencies: ['LKR'],
    minAmount: 0,
    maxAmount: 1000000,
    enabled: true
  },
  {
    name: 'stripe',
    priority: 50,
    supportedCountries: ['US', 'GB', 'AU'],
    supportedCurrencies: ['USD', 'GBP', 'AUD'],
    minAmount: 0.50,
    maxAmount: 999999,
    enabled: true
  },
  {
    name: 'paypal',
    priority: 25,
    supportedCountries: ['US', 'GB', 'DE', 'LK'],
    supportedCurrencies: ['USD', 'GBP', 'EUR', 'LKR'],
    minAmount: 1,
    maxAmount: 999999,
    enabled: true
  }
];

Use this router in your payment service:

typescript
async function initiatePayment(order: Order, cardToken: string): Promise<Payment> {
  const {adapter, gatewayName} = paymentRouter.selectGateway(
    order.country,
    order.currency,
    order.total
  );

  const response = await adapter.authorize({
    orderId: order.id,
    amount: order.total,
    currency: order.currency,
    cardToken,
    idempotencyKey: `${order.id}-${Date.now()}`
  });

  const payment = await Payment.create({
    orderId: order.id,
    amount: order.total,
    currency: order.currency,
    gateway: gatewayName,
    gatewayTransactionId: response.gatewayTransactionId,
    status: response.status,
    failureReason: response.errorMessage
  });

  return payment;
}

Reconcile everything — Daily comparison of gateway settlements and your records

Here's what keeps me up at night: a customer's payment succeeds in Stripe but fails to capture in your system. Now they're charged but their order isn't fulfilled. Reconciliation is your safety net.

I run a nightly batch job that fetches settlement data from each gateway and compares it against my payment records:

typescript
async function reconcilePayments(): Promise<void> {
  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);

  // For each gateway...
  for (const gatewayName of ['stripe', 'paypal', 'local_processor']) {
    const adapter = paymentAdapters.get(gatewayName);
    const localPayments = await Payment.findByGateway(gatewayName, yesterday);

    // Fetch gateway's view of what settled
    const gatewayTransactions = await adapter.fetchSettlement(yesterday);

    for (const gtwayTxn of gatewayTransactions) {
      const localPayment = localPayments.find(
        p => p.gatewayTransactionId === gtwayTxn.id
      );

      if (!localPayment) {
        // Gateway says this succeeded but we have no record
        logger.error('Orphan transaction', {
          gateway: gatewayName,
          gatewayId: gtwayTxn.id,
          amount: gtwayTxn.amount
        });
        await flagForManualReview(gtwayTxn);
      } else if (
        localPayment.amount !== gtwayTxn.amount ||
        localPayment.status !== gtwayTxn.status
      ) {
        // Mismatch
        logger.error('Payment mismatch', {
          paymentId: localPayment.id,
          expected: { amount: localPayment.amount, status: localPayment.status },
          actual: { amount: gtwayTxn.amount, status: gtwayTxn.status }
        });
        await flagForManualReview(localPayment);
      }
    }

    // Also check the reverse: do we have payments marked as captured that didn't settle?
    for (const localPayment of localPayments) {
      if (localPayment.status === PaymentStatus.CAPTURED) {
        const settled = gatewayTransactions.find(
          t => t.id === localPayment.gatewayTransactionId
        );
        if (!settled) {
          logger.warn('Captured payment not in gateway settlement', {
            paymentId: localPayment.id
          });
        }
      }
    }
  }

  logger.info('Reconciliation complete');
}

For disputes and chargebacks, listen to gateway webhooks and update your payment records immediately:

typescript
async function handleStripeWebhook(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case 'charge.dispute.created': {
      const dispute = event.data.object as Stripe.Dispute;
      const payment = await Payment.findByGatewayId(dispute.charge as string);
      if (payment) {
        payment.status = PaymentStatus.DISPUTED;
        payment.metadata = { disputeId: dispute.id, reason: dispute.reason };
        await payment.save();
        await publishEvent('PaymentDisputed', { paymentId: payment.id });
      }
      break;
    }
    case 'charge.refunded': {
      const charge = event.data.object as Stripe.Charge;
      const payment = await Payment.findByGatewayId(charge.id);
      if (payment && charge.refunded) {
        payment.status = PaymentStatus.REFUNDED;
        await payment.save();
        await publishEvent('PaymentRefunded', { paymentId: payment.id });
      }
      break;
    }
  }
}

Common mistakes

  • Mixing gateway responses into domain logic: If your order service knows about Stripe's payment_intent.status, you're doing it wrong. Always normalize to your canonical model first.
  • Synchronous capture after authorization: Stripe lets you authorize now and capture later. If you immediately capture every authorization, you lose flexibility and increase failed captures. Capture should be a separate step, often triggered by fulfillment.
  • No idempotency keys: Network failures happen. Without idempotency, retrying an authorization creates duplicate charges. Always include a unique idempotencyKey derived from the order ID and timestamp.
  • Ignoring webhook delays: A customer's payment succeeds at 3:00 PM, but the webhook arrives at 3:05 PM. If your UI doesn't handle "payment submitted, waiting for confirmation," users will place duplicate orders. Build with eventual consistency in mind.
  • Skipping reconciliation on low volumes: Even if you process 10 payments a day, run nightly reconciliation. One orphaned transaction costs you far more in troubleshooting and refunds than the cost of automation.

Key takeaways

  • Define a single canonical payment model that abstracts all gateway differences. Your orders and business logic should only reference this model, never raw gateway responses.
  • Implement the adapter pattern: each gateway gets its own adapter class implementing a shared interface. This makes adding a sixth gateway as simple as writing one new class.
  • Route payment requests based on currency, country, and amount using a priority-based router. This lets you optimize for cost, availability, and compliance per region.
  • Reconcile daily. Fetch settlement data from each gateway and compare against your records. Flag mismatches immediately—they're canaries for bugs or data loss.
  • Handle webhooks asynchronously and idempotently. Don't update payment status synchronously; let webhooks confirm what really happened at the gateway.
  • Use idempotency keys for all authorization and capture requests. This prevents duplicate charges if your client retries due to network timeouts.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles