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.