Back to blogs
Fintech / Payments
7 min read
Mar 10, 2025

Supporting Multiple Payment Gateways in One Application: Gateway-Agnostic Architecture

How Trulipay abstracts over Stripe, Razorpay, and other payment gateways behind a unified adapter interface—enabling gateway switching, fallback routing, and consistent transaction records regardless of which provider processed the payment.

Payment GatewaysStripeRazorpayAdapter PatternArchitectureFintechTypeScript
Table of contents

When we first built Trulipay, it only supported Stripe. Then Indian merchants told us they needed Razorpay. Then Southeast Asian merchants needed PayU. Each gateway has a completely different API, different webhook formats, different error codes. Without an abstraction layer, adding each new gateway means scattered changes across the codebase.

We built a gateway-agnostic architecture instead. Here's the design.

The Adapter Interface

Every gateway is represented by an adapter that implements a common interface. We defined the minimum surface area needed for payment operations:

interface PaymentGatewayAdapter {
  readonly name: string;
  readonly currencies: string[];
  readonly countries: string[];
 
  createPaymentIntent(
    params: CreatePaymentParams
  ): Promise<PaymentIntentResult>;
 
  confirmPayment(
    intentId: string,
    confirmParams: ConfirmPaymentParams
  ): Promise<PaymentConfirmResult>;
 
  refundPayment(
    chargeId: string,
    amount?: number,
    reason?: string
  ): Promise<RefundResult>;
 
  getPaymentStatus(externalId: string): Promise<PaymentStatusResult>;
 
  parseWebhook(
    rawBody: string,
    signature: string,
    secret: string
  ): Promise<WebhookEvent>;
}

The parseWebhook method on the interface is important. Every gateway sends webhooks in a different format—Stripe uses X-Stripe-Signature, Razorpay uses X-Razorpay-Signature, PayU uses a different mechanism entirely. Keeping parsing inside the adapter means the webhook handler doesn't need to know which gateway sent a given event.

Unified Result Types

The adapter methods return gateway-neutral types. This is where normalization happens:

type CreatePaymentParams = {
  amount: number;          // always in smallest currency unit (paise, cents)
  currency: string;        // ISO 4217
  merchantAccountId: string;
  customerId?: string;
  description?: string;
  metadata?: Record<string, string>;
};
 
type PaymentIntentResult = {
  externalId: string;       // gateway-specific ID
  clientSecret?: string;    // for client-side confirmation (Stripe style)
  redirectUrl?: string;     // for redirect-based flows (Razorpay style)
  status: "requires_payment_method" | "requires_confirmation" | "processing";
  raw: unknown;             // original gateway response, stored for debugging
};
 
type WebhookEvent = {
  type: "payment.completed" | "payment.failed" | "payment.refunded" | "chargeback.opened";
  externalId: string;
  amount?: number;
  currency?: string;
  metadata?: Record<string, string>;
  raw: unknown;
};

Stripe Adapter

class StripeAdapter implements PaymentGatewayAdapter {
  readonly name = "stripe";
  readonly currencies = ["usd", "eur", "gbp", "sgd", "aed"];
  readonly countries = ["US", "GB", "EU", "SG", "AE"];
 
  constructor(private readonly stripe: Stripe) {}
 
  async createPaymentIntent(params: CreatePaymentParams): Promise<PaymentIntentResult> {
    const intent = await this.stripe.paymentIntents.create({
      amount: params.amount,
      currency: params.currency,
      transfer_data: { destination: params.merchantAccountId },
      metadata: params.metadata,
    });
 
    return {
      externalId: intent.id,
      clientSecret: intent.client_secret!,
      status: "requires_payment_method",
      raw: intent,
    };
  }
 
  async parseWebhook(rawBody: string, signature: string, secret: string): Promise<WebhookEvent> {
    const event = this.stripe.webhooks.constructEvent(rawBody, signature, secret);
 
    switch (event.type) {
      case "payment_intent.succeeded":
        return {
          type: "payment.completed",
          externalId: (event.data.object as Stripe.PaymentIntent).id,
          amount: (event.data.object as Stripe.PaymentIntent).amount,
          currency: (event.data.object as Stripe.PaymentIntent).currency,
          raw: event,
        };
      case "charge.dispute.created":
        return {
          type: "chargeback.opened",
          externalId: (event.data.object as Stripe.Dispute).payment_intent as string,
          raw: event,
        };
      default:
        throw new Error(`Unhandled Stripe event: ${event.type}`);
    }
  }
}

Razorpay Adapter

class RazorpayAdapter implements PaymentGatewayAdapter {
  readonly name = "razorpay";
  readonly currencies = ["INR"];
  readonly countries = ["IN"];
 
  constructor(private readonly razorpay: Razorpay) {}
 
  async createPaymentIntent(params: CreatePaymentParams): Promise<PaymentIntentResult> {
    const order = await this.razorpay.orders.create({
      amount: params.amount,   // Razorpay uses paise
      currency: params.currency.toUpperCase(),
      receipt: params.metadata?.receipt_id,
      notes: params.metadata,
    });
 
    // Razorpay uses a redirect/checkout widget, not client secret
    return {
      externalId: order.id,
      redirectUrl: undefined, // client uses the order ID with Razorpay.js
      status: "requires_confirmation",
      raw: order,
    };
  }
 
  async parseWebhook(rawBody: string, signature: string, secret: string): Promise<WebhookEvent> {
    const expectedSignature = crypto
      .createHmac("sha256", secret)
      .update(rawBody)
      .digest("hex");
 
    if (expectedSignature !== signature) {
      throw new Error("Invalid Razorpay webhook signature");
    }
 
    const payload = JSON.parse(rawBody);
 
    switch (payload.event) {
      case "payment.captured":
        return {
          type: "payment.completed",
          externalId: payload.payload.payment.entity.order_id,
          amount: payload.payload.payment.entity.amount,
          currency: payload.payload.payment.entity.currency.toLowerCase(),
          raw: payload,
        };
      default:
        throw new Error(`Unhandled Razorpay event: ${payload.event}`);
    }
  }
}

Gateway Registry and Selection

The GatewayRegistry holds all available adapters and selects the right one for a given payment:

class GatewayRegistry {
  private readonly adapters: Map<string, PaymentGatewayAdapter> = new Map();
 
  register(adapter: PaymentGatewayAdapter) {
    this.adapters.set(adapter.name, adapter);
  }
 
  select(currency: string, country: string, merchantPreference?: string): PaymentGatewayAdapter {
    // Honor merchant preference if capable
    if (merchantPreference) {
      const preferred = this.adapters.get(merchantPreference);
      if (preferred?.currencies.includes(currency) && preferred.countries.includes(country)) {
        return preferred;
      }
    }
 
    // Fall back to capability match
    for (const adapter of this.adapters.values()) {
      if (adapter.currencies.includes(currency) && adapter.countries.includes(country)) {
        return adapter;
      }
    }
 
    throw new Error(`No gateway supports ${currency} in ${country}`);
  }
 
  getByName(name: string): PaymentGatewayAdapter {
    const adapter = this.adapters.get(name);
    if (!adapter) throw new Error(`Unknown gateway: ${name}`);
    return adapter;
  }
}

Webhook Routing

All gateways send webhooks to a single endpoint. We route them based on a gateway identifier in the URL:

app.post("/webhooks/payment/:gateway", async (req, res) => {
  const { gateway } = req.params;
  const adapter = registry.getByName(gateway);
  const secret = await getWebhookSecret(gateway);
 
  let event: WebhookEvent;
  try {
    event = await adapter.parseWebhook(
      req.rawBody,
      req.headers["x-signature"] as string ?? req.headers["x-stripe-signature"] as string,
      secret
    );
  } catch {
    return res.sendStatus(400);
  }
 
  res.sendStatus(200); // ACK immediately
 
  await handleWebhookEvent(event, gateway); // process asynchronously
});

Storing Gateway-Neutral Transaction Records

Every payment, regardless of gateway, writes to the same transactions table:

type StoredTransaction = {
  id: string;
  merchant_id: string;
  gateway: string;          // "stripe" | "razorpay" | ...
  external_id: string;      // gateway's own ID
  amount: number;
  currency: string;
  status: TransactionStatus;
  raw_response: unknown;    // full gateway response for debugging
  created_at: Date;
  updated_at: Date;
};

Queries for transaction history don't need to know which gateway processed a payment—the normalized record is all they need.

Key Takeaways

  • Define the adapter interface before writing any adapter—the interface is your contract; write the Stripe adapter last, not first.
  • Normalized result types are where the real work is—getting the edge cases of each gateway into a consistent shape takes more time than you'd expect.
  • A single webhook endpoint per gateway (not one endpoint for all) keeps routing explicit and makes secret management simpler.
  • Store the raw response alongside normalized fields—when a gateway changes their API or you find a parsing bug, the raw data lets you re-derive without re-fetching.
  • Gateway selection by capability, not hardcoding—currency and country constraints change; a capability-based registry handles new markets without code changes.