TL;DR: Prevent checkout downtime by building a multi-gateway payment router in Node.js that seamlessly falls back between Stripe and Razorpay during outages. By implementing the Adapter Pattern and an idempotent
PaymentProviderTypeScript interface, you can decouple your application from specific SDKs and normalize transaction payloads across multiple providers.
⚡ Key Takeaways
- Implement the Adapter Pattern to decouple your core checkout logic from tightly coupled Stripe or Razorpay SDKs.
- Create a generic
PaymentProviderTypeScript interface to normalize transaction inputs and standardize gateway responses. - Enforce all monetary values in their lowest denominations (e.g., cents for USD, paise for INR) within the abstraction layer to prevent conversion bugs during a fallback event.
- Require an
idempotencyKeyin yourPaymentIntentRequestcontract to safely handle transaction retries and prevent duplicate charges across gateways. - Map provider-specific API timeouts (like Stripe's
503 Service Unavailable) into a standardNormalizedPaymentResponseto trigger clean routing to a secondary gateway.
Your marketing team just spent $50,000 driving traffic to a massive Black Friday campaign. Users are adding items to their carts, reaching the checkout page, and then—your payment gateway throws a 503 Service Unavailable.
Every minute of downtime bleeds revenue, spikes customer support tickets, and irreparably damages user trust. Relying entirely on a single payment provider like Stripe or Razorpay is a textbook single point of failure (SPOF). While these giants boast 99.9% uptime, API degradation, regional DNS issues, and unannounced webhook delays are inevitable realities of distributed systems.
The solution isn't to cross your fingers and hope for the best; it's to architect a resilient Payment Router. By building an idempotent abstraction layer in Node.js, you can seamlessly route transactions based on geography or availability, and instantly fall back to a secondary gateway when your primary provider falters.
Let's break down how to design a multi-gateway architecture using Node.js, Stripe, and Razorpay that keeps your checkout operational 100% of the time.
The Hidden Cost of Tightly Coupled Payment Logic
Most early-stage applications tightly couple their business logic directly to a specific provider's SDK. You install the Stripe package, import it directly into your controllers, and map your database columns to Stripe's exact payload definitions.
The problem arises when an outage occurs. A standard tightly coupled implementation will crash your entire checkout pipeline when it encounters a gateway timeout like this:
{
"error": {
"message": "The Stripe API is currently unavailable.",
"type": "api_error",
"charge": null,
"code": "server_error"
},
"status": 503
}
When this happens, ripping out the SDK and hardcoding a replacement takes days of engineering time. When technical founders evaluate the cost of building custom software architectures, building a multi-gateway fallback system often feels like premature optimization. However, comparing the upfront development cost against the irrecoverable loss of a peak-hour outage quickly justifies the investment.
To survive gateway degradation, we must decouple our application from specific providers by introducing a standardized interface.
Architecting an Idempotent Abstraction Layer
The foundation of a multi-gateway system is the Adapter Pattern. Instead of your checkout service talking directly to Stripe or Razorpay, it communicates with a generic PaymentProvider interface.
This abstraction standardizes the input (amount, currency, customer details) and normalizes the output (gateway transaction IDs, status, error codes).
Production Note: Both Stripe and Razorpay accept monetary values in their smallest currency units (cents for USD, paise for INR). Our abstraction layer enforces this standard across the board to prevent conversion bugs during a fallback event.
Here is how we define the core contract in TypeScript:
// types/payment.ts
export type Currency = 'USD' | 'INR' | 'EUR' | 'GBP';
export interface PaymentIntentRequest {
amount: number; // Lowest denomination (cents/paise)
currency: Currency;
customerId?: string;
orderId: string;
metadata?: Record<string, string>;
idempotencyKey: string;
}
export interface NormalizedPaymentResponse {
success: boolean;
transactionId: string | null;
provider: 'STRIPE' | 'RAZORPAY';
clientSecret?: string; // Used by frontend to complete payment
errorMessage?: string;
requiresAction: boolean;
}
export interface PaymentProvider {
getProviderName(): string;
createPaymentIntent(request: PaymentIntentRequest): Promise<NormalizedPaymentResponse>;
verifyWebhookSignature(payload: any, signature: string): boolean;
}
By coding against the PaymentProvider interface, your core e-commerce logic never knows or cares who is processing the payment.
Implementing the Stripe and Razorpay Adapters
Next, we implement concrete adapters for our gateways. In our custom API and payment gateway integrations, we structure these adapters to catch provider-specific errors and cleanly map them to our normalized response.
First, let's implement the StripeAdapter:
// adapters/StripeAdapter.ts
import Stripe from 'stripe';
import { PaymentProvider, PaymentIntentRequest, NormalizedPaymentResponse } from '../types/payment';
export class StripeAdapter implements PaymentProvider {
private stripe: Stripe;
constructor(apiKey: string) {
this.stripe = new Stripe(apiKey, { apiVersion: '2023-10-16' });
}
getProviderName(): string {
return 'STRIPE';
}
async createPaymentIntent(req: PaymentIntentRequest): Promise<NormalizedPaymentResponse> {
try {
const intent = await this.stripe.paymentIntents.create({
amount: req.amount,
currency: req.currency.toLowerCase(),
metadata: { ...req.metadata, orderId: req.orderId },
}, {
idempotencyKey: req.idempotencyKey
});
return {
success: true,
transactionId: intent.id,
provider: 'STRIPE',
clientSecret: intent.client_secret || undefined,
requiresAction: intent.status === 'requires_action'
};
} catch (error: any) {
// Differentiate between user errors (e.g., declined card) and system timeouts
return {
success: false,
transactionId: null,
provider: 'STRIPE',
requiresAction: false,
errorMessage: error.message,
};
}
}
verifyWebhookSignature(payload: any, signature: string): boolean {
// Stripe webhook verification logic goes here
return true;
}
}
Now, we build the equivalent RazorpayAdapter. Notice how we normalize Razorpay's order_id into our universal transactionId format so the frontend and database handle it identically.
// adapters/RazorpayAdapter.ts
import Razorpay from 'razorpay';
import { PaymentProvider, PaymentIntentRequest, NormalizedPaymentResponse } from '../types/payment';
export class RazorpayAdapter implements PaymentProvider {
private razorpay: any;
constructor(keyId: string, keySecret: string) {
this.razorpay = new Razorpay({ key_id: keyId, key_secret: keySecret });
}
getProviderName(): string {
return 'RAZORPAY';
}
async createPaymentIntent(req: PaymentIntentRequest): Promise<NormalizedPaymentResponse> {
try {
const order = await this.razorpay.orders.create({
amount: req.amount, // Also expects smallest unit (paise)
currency: req.currency,
receipt: req.orderId,
notes: req.metadata
});
return {
success: true,
transactionId: order.id, // Razorpay order ID acts as our internal intent ID
provider: 'RAZORPAY',
clientSecret: order.id, // Razorpay uses order ID on the client side
requiresAction: true
};
} catch (error: any) {
return {
success: false,
transactionId: null,
provider: 'RAZORPAY',
requiresAction: false,
errorMessage: error.description || 'Razorpay API Error',
};
}
}
verifyWebhookSignature(payload: any, signature: string): boolean {
// Razorpay webhook signature logic goes here
return true;
}
}
Designing the Payment Router and Fallback Logic
With our adapters ready, we need the brain of the operation: the PaymentRouter.
The router dictates the flow of transactions. A common approach is Geographical Routing paired with Active-Passive Fallback. For example, you might route all INR transactions to Razorpay natively, and all international transactions to Stripe. If Stripe fails with a server error, the router instantly falls back to Razorpay (since Razorpay also supports international cards).
Warning: You must only trigger a fallback on
5xx(server errors) or network timeouts. If a gateway returns a402 Payment Required(insufficient funds or declined card), falling back will just result in the secondary gateway also declining the card. Reserve fallbacks purely for infrastructure failures.
// services/PaymentRouter.ts
import { PaymentProvider, PaymentIntentRequest, NormalizedPaymentResponse } from '../types/payment';
export class PaymentRouter {
private primaryProvider: PaymentProvider;
private fallbackProvider: PaymentProvider;
constructor(primary: PaymentProvider, fallback: PaymentProvider) {
this.primaryProvider = primary;
this.fallbackProvider = fallback;
}
async processCheckout(req: PaymentIntentRequest): Promise<NormalizedPaymentResponse> {
console.log(`[Router] Attempting primary gateway: ${this.primaryProvider.getProviderName()}`);
let response = await this.primaryProvider.createPaymentIntent(req);
// If successful, or if it's a client/user error (like a declined card), return immediately.
// Fallbacks are only for API failures, indicated by missing intent IDs and system errors.
if (response.success || this.isUserError(response.errorMessage)) {
return response;
}
console.warn(`[Router] Primary gateway failed: ${response.errorMessage}. Initiating fallback to ${this.fallbackProvider.getProviderName()}`);
// Regenerate the idempotency key for the fallback provider to avoid cross-provider collisions
const fallbackReq = { ...req, idempotencyKey: `${req.idempotencyKey}-fallback` };
response = await this.fallbackProvider.createPaymentIntent(fallbackReq);
if (!response.success) {
console.error(`[Router] Fatal: Both primary and fallback gateways failed.`);
// Alert engineering team via PagerDuty/Slack here
}
return response;
}
private isUserError(errorMessage?: string): boolean {
// Logic to detect if the error is a declined card vs a 5xx timeout.
// In production, map specific error codes rather than checking strings.
const userErrors = ['insufficient_funds', 'card_declined', 'expired_card'];
return userErrors.some(err => errorMessage?.includes(err));
}
}
Ensuring Data Consistency with Idempotency Keys
When a user clicks "Pay" and the primary gateway hangs for 10 seconds before timing out, the user is likely to frantically click the button multiple times. In a multi-gateway setup, this can result in the primary gateway eventually processing request #1, while the fallback gateway processes retry request #2. The user ends up getting charged twice.
To prevent this, you must implement Idempotency Keys. An idempotency key ensures that no matter how many times a request is sent, the server only processes the payment once. We enforce this using a Redis distributed lock.
When architecting complex transactional systems, adhering to strict engineering and sprint methodologies ensures distributed race conditions like these are caught during system design, not in production.
Here is how we implement a Redis-backed idempotency lock in Node.js:
// services/IdempotencyService.ts
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
export async function withIdempotency<T>(
key: string,
ttlSeconds: number,
operation: () => Promise<T>
): Promise<T> {
// 'NX' ensures the key is only set if it does not already exist.
// 'EX' sets an expiration time to prevent permanent deadlocks.
const acquired = await redis.set(`idempotency:${key}`, 'LOCKED', 'NX', 'EX', ttlSeconds);
if (!acquired) {
throw new Error('A payment request with this idempotency key is already in progress.');
}
try {
const result = await operation();
// Cache the successful result so subsequent identical requests return the cached response
await redis.set(`idempotency:${key}`, JSON.stringify(result), 'EX', 86400); // Keep for 24h
return result;
} catch (error) {
// If the operation fails due to a network error, release the lock so it can be retried safely
await redis.del(`idempotency:${key}`);
throw error;
}
}
In your Express controller, you would wrap the PaymentRouter.processCheckout call within this withIdempotency function.
Normalizing Webhooks in a Multi-Gateway Setup
Initiating the payment is only half the battle. Your backend must listen for webhooks to reliably fulfill the order. Because Stripe and Razorpay send entirely different webhook payloads to different endpoints, we need a unified Webhook Controller that normalizes incoming data before updating our database.
By funneling all gateway webhooks into a generic OrderFulfillmentService, your database logic remains clean and entirely isolated from provider-specific quirks.
// controllers/WebhookController.ts
import { Request, Response } from 'express';
interface NormalizedWebhookEvent {
orderId: string;
transactionId: string;
status: 'SUCCESS' | 'FAILED';
provider: string;
}
export const handleStripeWebhook = async (req: Request, res: Response) => {
const signature = req.headers['stripe-signature'] as string;
// In production, verify the signature using your Stripe adapter here.
// If verification succeeds, extract the event:
const event = req.body;
if (event.type === 'payment_intent.succeeded') {
const intent = event.data.object;
const normalizedEvent: NormalizedWebhookEvent = {
orderId: intent.metadata.orderId,
transactionId: intent.id,
status: 'SUCCESS',
provider: 'STRIPE'
};
await processOrderFulfillment(normalizedEvent);
}
res.status(200).send('Webhook Received');
};
export const handleRazorpayWebhook = async (req: Request, res: Response) => {
const signature = req.headers['x-razorpay-signature'] as string;
// Verify signature in production before proceeding
const event = req.body;
if (event.event === 'order.paid') {
const order = event.payload.order.entity;
const normalizedEvent: NormalizedWebhookEvent = {
orderId: order.receipt, // We mapped orderId to receipt during creation
transactionId: order.id,
status: 'SUCCESS',
provider: 'RAZORPAY'
};
await processOrderFulfillment(normalizedEvent);
}
res.status(200).send('Webhook Received');
};
async function processOrderFulfillment(event: NormalizedWebhookEvent) {
console.log(`[Fulfillment] Fulfilling order ${event.orderId} processed by ${event.provider}`);
// Update DB: UPDATE orders SET status = 'PAID', tx_id = event.transactionId WHERE id = event.orderId
}
By decoupling your webhook handlers from your fulfillment logic, you can safely swap, remove, or add new gateways (like PayPal or Adyen) in the future simply by writing a new adapter and standardizing its webhook events.
Protecting Your Checkout Flow
Relying on a single payment provider is a calculated risk that rarely pays off at scale. By leveraging the Adapter pattern, intelligent routing, and Redis-backed idempotency, you can shield your revenue from third-party outages and deliver a frictionless checkout experience—even when the internet's infrastructure is having a bad day.
Work With Us
Need help building this in production? SoftwareCrafting is a full-stack dev agency — we ship React, Next.js, Node.js, React Native & Flutter apps for global clients.
Frequently Asked Questions
Why shouldn't I just use the Stripe or Razorpay SDK directly in my controllers?
Tightly coupling your business logic to a single provider's SDK creates a single point of failure (SPOF) for your application. If that specific gateway experiences an outage or API degradation, your entire checkout pipeline will crash and throw errors like 503 Service Unavailable. Using an abstraction layer allows you to swap providers instantly without rewriting your core e-commerce logic.
What design pattern is best for building a multi-gateway payment router?
The Adapter Pattern is the most effective approach for architecting multi-gateway payment systems in Node.js. It allows your checkout service to communicate with a generic PaymentProvider interface rather than provider-specific SDKs. This standardizes inputs (like amount and currency) and normalizes outputs (like transaction IDs and status codes) across different gateways.
How do I handle currency formatting when falling back between Stripe and Razorpay?
Both Stripe and Razorpay require monetary values to be passed in their smallest currency units, such as cents for USD or paise for INR. Your abstraction layer must enforce this lowest-denomination standard globally to prevent conversion bugs during an active fallback event. If your team needs help standardizing complex financial data, SoftwareCrafting provides specialized custom API and payment gateway integrations to ensure seamless, bug-free routing.
Why is an idempotency key required in the payment abstraction interface?
An idempotency key prevents duplicate charges if a network request times out and your system automatically retries the payment creation. When falling back between gateways or retrying degraded APIs, this key guarantees that the customer's card is only authorized or billed once. It is a critical safeguard for maintaining transactional integrity in distributed, high-availability systems.
Is building a custom payment routing system worth the upfront development cost?
While building a multi-gateway fallback system might feel like premature optimization, the upfront cost is easily justified when compared to the irrecoverable revenue loss during a peak-hour gateway outage. When you evaluate the cost of building custom software architectures against the damage of a Black Friday checkout failure, it becomes a clear business necessity. SoftwareCrafting can help you design and implement these resilient systems efficiently so you never lose a sale to gateway downtime.
How does the payment abstraction layer handle different provider error codes?
The concrete adapters (such as StripeAdapter) catch provider-specific errors and map them to a standardized NormalizedPaymentResponse. This ensures your core e-commerce logic only has to evaluate a standard success boolean and a generic errorMessage. As a result, your application doesn't need to parse unique, deeply nested gateway error structures like Stripe's api_error object.
📎 Full Code on GitHub Gist: The complete
stripe-error.jsonfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
