Why South Asia demands a different approach to payments
Card penetration in South Asia sits far below Western markets. In India, Pakistan, Bangladesh, and Sri Lanka, a significant portion of the online customer base either lacks access to international credit cards or prefers not to use them for online transactions. Building a payment system that works across the region means moving beyond Visa and Mastercard as your only option. Local payment methods in South Asia in Payments & Stripe unlock access to millions of customers who are otherwise locked out of your checkout flow.
Over the next 71 days of this series, we've been building toward this moment: the ability to accept payments in a region where cash-on-delivery, bank transfers, and regional digital wallets dominate. If you're an e-commerce platform, SaaS company, or marketplace serving South Asia, you cannot ignore this infrastructure gap.
Cards still matter — Visa and Mastercard via international gateways
Let's start with what we know. Visa and Mastercard remain valuable in South Asia, especially for higher-value transactions and affluent customer segments. International card networks aren't going away, but they come with friction: higher decline rates, fraud concerns, and currency conversion fees that customers notice.
Stripe's core offering handles international cards reasonably well, but the real win is using Stripe as an orchestration layer alongside regional partners. In India, for example, customers with Visa or Mastercard can still use them, but your conversion rate improves dramatically when you offer local alternatives in the same checkout.
Here's a practical integration pattern we use: accept cards through Stripe's standard Payment Intent flow, but detect the customer's location and card issuer to intelligently route or prompt for alternatives.
async function createPaymentIntent(
amount: number,
currency: string,
customerId: string,
region: string,
cardBin?: string
) {
const paymentMethods = [];
// Always offer cards if the region supports them
if (['IN', 'PK', 'BD', 'LK'].includes(region)) {
paymentMethods.push('card');
}
// Detect card issuer from BIN and suggest alternatives
if (cardBin) {
const issuer = await detectCardIssuer(cardBin);
if (issuer === 'domestic_debit') {
// Domestic debit cards work fine; no additional routing needed
paymentMethods.push('netbanking');
}
}
const intent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100),
currency,
customer: customerId,
automatic_payment_methods: {
enabled: true,
},
metadata: {
region,
supportedMethods: paymentMethods.join(','),
},
});
return intent;
}
The key insight: international cards work, but they're not your growth lever. They're your safety net.
Local gateways — Regional providers support local banks and wallets
This is where the real volume lives. Regional payment gateways—Razorpay in India, SSLCommerz in Bangladesh, Esewa in Nepal—process the bulk of online transactions because they integrate directly with local banks and wallets.
Stripe doesn't operate in all South Asian markets with full local payment method support, which means you need a hybrid architecture: Stripe for international cards and some regions, plus local gateways for depth in each market. This isn't a limitation; it's the standard pattern for platforms operating across multiple South Asian countries.
Your integration needs to handle provider-specific flows. Razorpay supports UPI, NEFT, and wallet payments in India. SSLCommerz in Bangladesh handles bKash, Nagad, and Rocket. These aren't interchangeable APIs; each gateway has its own SDK, webhook signatures, and reconciliation logic.
Here's how we structure a gateway abstraction layer:
interface PaymentGateway {
createOrder(
amount: number,
currency: string,
metadata: Record<string, any>
): Promise<{ orderId: string; redirectUrl: string }>;
verifySignature(payload: any, signature: string): boolean;
fetchOrderStatus(orderId: string): Promise<OrderStatus>;
}
class RazorpayGateway implements PaymentGateway {
private client: Razorpay;
constructor(apiKey: string, apiSecret: string) {
this.client = new Razorpay({
key_id: apiKey,
key_secret: apiSecret,
});
}
async createOrder(
amount: number,
currency: string,
metadata: Record<string, any>
) {
const order = await this.client.orders.create({
amount: Math.round(amount * 100), // Razorpay uses paise
currency,
receipt: metadata.orderId,
notes: {
customerId: metadata.customerId,
email: metadata.email,
},
});
return {
orderId: order.id,
redirectUrl: `${process.env.APP_URL}/checkout/razorpay/${order.id}`,
};
}
verifySignature(payload: any, signature: string): boolean {
const hmac = require('crypto').createHmac(
'sha256',
this.client.key_secret
);
hmac.update(
payload.razorpay_order_id +
'|' +
payload.razorpay_payment_id
);
return hmac.digest('hex') === signature;
}
async fetchOrderStatus(orderId: string): Promise<OrderStatus> {
const order = await this.client.orders.fetch(orderId);
return {
status: order.status,
amount: order.amount / 100,
currency: order.currency,
};
}
}
class SSLCommerzGateway implements PaymentGateway {
private storeId: string;
private storePassword: string;
private baseUrl = 'https://sandbox.sslcommerz.com/gwprocess/v4/api.php';
constructor(storeId: string, storePassword: string) {
this.storeId = storeId;
this.storePassword = storePassword;
}
async createOrder(
amount: number,
currency: string,
metadata: Record<string, any>
) {
const params = new URLSearchParams();
params.append('store_id', this.storeId);
params.append('store_passwd', this.storePassword);
params.append('total_amount', amount.toString());
params.append('currency', currency);
params.append('tran_id', metadata.orderId);
params.append('success_url', `${process.env.APP_URL}/payment/success`);
params.append('fail_url', `${process.env.APP_URL}/payment/fail`);
params.append('cancel_url', `${process.env.APP_URL}/payment/cancel`);
params.append('cus_name', metadata.customerName);
params.append('cus_email', metadata.email);
const response = await fetch(this.baseUrl, {
method: 'POST',
body: params,
});
const result = await response.text();
const lines = result.split('\n');
const sessionKey = lines[1]?.split('SESSION_ID=')[1];
return {
orderId: metadata.orderId,
redirectUrl: `https://sandbox.sslcommerz.com/gwprocess/v4/gw.php?Q=${sessionKey}`,
};
}
verifySignature(payload: any, signature: string): boolean {
// SSLCommerz uses validation endpoint instead of HMAC
return true; // Validation happens server-to-server
}
async fetchOrderStatus(orderId: string): Promise<OrderStatus> {
// SSLCommerz webhook provides status; manual fetch requires additional setup
return { status: 'pending', amount: 0, currency: 'BDT' };
}
}
Notice the abstraction lets you swap gateways based on region without rewriting checkout logic. Your application routes the customer to the appropriate gateway based on their location and payment method preference.
Bank transfers — Manual reconciliation needs references
In markets like Pakistan and parts of India, manual bank transfers still represent meaningful transaction volume, particularly for B2B and high-value payments. This isn't 1990s payment processing; modern bank transfer flows use UPI (Unified Payments Interface in India) or NEFT/RTGS for larger amounts.
The challenge: bank transfers require reference-based reconciliation. Your customer transfers money to a business account, includes a reference code, and you have to match the incoming payment to the original order. This is slow, error-prone, and manual if not automated properly.
A production system needs to:
- Generate unique, trackable references per transaction
- Monitor incoming transfers to your bank account (via bank APIs or aggregators)
- Automatically match transfers to orders using those references
- Handle partial payments, late arrivals, and duplicates
Here's how we structure this:
async function generateBankTransferReference(
orderId: string,
amount: number
): Promise<BankTransferDetails> {
// Encode order ID and amount checksum into reference
const checksum = Math.abs(
orderId
.split('')
.reduce((sum, char) => sum + char.charCodeAt(0), 0) % 9999
);
const reference = `ORD-${orderId.slice(-8).toUpperCase()}-${checksum
.toString()
.padStart(4, '0')}`;
const transfer = await db.bankTransfer.create({
orderId,
reference,
amount,
expectedAmount: amount,
status: 'awaiting_payment',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hour window
});
return {
reference,
bankAccountNumber: process.env.BUSINESS_ACCOUNT_NUMBER,
bankName: 'HDFC Bank',
ifscCode: process.env.IFSC_CODE,
accountHolder: 'Your Business Name',
amount,
};
}
async function reconcileBankTransfer(
transferAmount: number,
reference: string,
timestamp: Date
): Promise<{ matched: boolean; orderId?: string }> {
// Parse reference to extract order ID
const orderIdMatch = reference.match(/ORD-([A-Z0-9]+)-(\d{4})$/);
if (!orderIdMatch) {
console.warn(`Invalid reference format: ${reference}`);
return { matched: false };
}
const [, orderIdPart, checksumPart] = orderIdMatch;
const potentialOrderId = await findOrderByReference(orderIdPart);
if (!potentialOrderId) {
console.warn(
`No order found for reference part: ${orderIdPart}`
);
return { matched: false };
}
// Verify checksum
const expectedChecksum = Math.abs(
potentialOrderId
.split('')
.reduce((sum, char) => sum + char.charCodeAt(0), 0) % 9999
)
.toString()
.padStart(4, '0');
if (expectedChecksum !== checksumPart) {
console.warn(
`Checksum mismatch for order ${potentialOrderId}`
);
return { matched: false };
}
const bankTransfer = await db.bankTransfer.findUnique({
where: { reference },
});
if (!bankTransfer) {
console.warn(`No pending transfer found for reference: ${reference}`);
return { matched: false };
}
// Allow 1% variance for forex fluctuations
const tolerance = bankTransfer.expectedAmount * 0.01;
if (
Math.abs(transferAmount - bankTransfer.expectedAmount) >
tolerance
) {
console.warn(
`Amount mismatch: expected ${bankTransfer.expectedAmount}, got ${transferAmount}`
);
return { matched: false };
}
// Mark as complete
await db.bankTransfer.update({
where: { id: bankTransfer.id },
data: {
status: 'confirmed',
receivedAmount: transferAmount,
receivedAt: timestamp,
},
});
// Update order status
await db.order.update({
where: { id: potentialOrderId },
data: { paymentStatus: 'completed' },
});
return { matched: true, orderId: potentialOrderId };
}
In production, you'll integrate with a bank API or reconciliation service (like Razorpay X in India) to automate the monitoring and matching. Manual bank transfers without automation don't scale beyond a handful of transactions per day.
Currency — Price clearly in local currency
This seems obvious, but we've seen enough implementations get it wrong to warrant explicit coverage. Showing prices in USD to customers in India or Pakistan causes cart abandonment. They need to see the price in INR, PKR, or BDT respectively, and they need to understand the total cost in local terms.
Two patterns we use:
Pattern 1: Fixed pricing in local currency
Your product catalog stores prices in the local currency for each market. When a customer from India loads your checkout, they see ₹2,499 (not $30 USD). This requires you to maintain pricing rules per country.
Pattern 2: Dynamic conversion at checkout
You store a base price (often USD) and convert it to the customer's local currency at checkout time using real-time or daily-fixed exchange rates. This simplifies inventory management but adds complexity around exchange rate timing and rounding.
We recommend Pattern 1 for most platforms: it gives customers certainty, reduces support questions, and lets you control pricing strategy per market.
interface PricingRule {
productId: string;
country: string;
currency: string;
price: number;
validFrom: Date;
validTo: Date;
}
async function getPriceForCustomer(
productId: string,
customerCountry: string
): Promise<{ price: number; currency: string }> {
const now = new Date();
const rule = await db.pricingRule.findFirst({
where: {
productId,
country: customerCountry,
validFrom: { lte: now },
validTo: { gte: now },
},
});
if (!rule) {
// Fallback to default pricing
const product = await db.product.findUnique({
where: { id: productId },
});
return {
price: product.defaultPrice,
currency: 'USD',
};
}
return { price: rule.price, currency: rule.currency };
}
When displaying prices in UI, always show the currency symbol or code alongside the amount. ₹2,499 is infinitely clearer than 2499.
Common mistakes
- Assuming cards work everywhere: Even when you support Visa and Mastercard, your conversion rate stays low until you add local payment methods. Cards are necessary but not sufficient.
- Building your own bank transfer reconciliation: Without proper state management, reference generation, and timeout handling, you'll spend weeks debugging off-by-one errors in reconciliation logic. Use bank APIs or established aggregators.
- Mixing payment gateways without abstraction: If your checkout code contains if-statements for Razorpay, SSLCommerz, and Stripe, you've already failed. Abstract the gateway interface and route at the service layer.
- Ignoring currency display: Customers don't convert currencies in their head. Always show prices in their local currency, not a "converted" rate.
- Not handling webhook delays or retries: Regional gateways have less reliable webhook delivery than Stripe. Build idempotent handlers and manually poll for order status if webhooks don't arrive.
Key takeaways
- Cards remain valid but are not your primary growth driver in South Asia. Local payment methods unlock the majority of the market.
- Use Stripe for international cards and regions it supports natively; layer regional gateways (Razorpay, SSLCommerz) for local payment method depth.
- Implement a gateway abstraction layer so you can add regions and payment methods without rewriting checkout logic.
- Bank transfer reconciliation requires automated reference matching, checksums, and timeout handling—build this from the start, not after launch.
- Always display prices in local currency. Your customers' certainty directly impacts conversion rate.
- Plan for webhook unreliability from regional gateways; implement polling and idempotent handlers as fallbacks.






Comments
Loading comments…