"Just add Stripe" is the default advice for a solo dev who needs to charge money. It works right up until you're an Indian merchant trying to sell to a buyer in San Francisco and a buyer in Bangalore in the same afternoon — at which point you discover that a single provider can't actually do both, and the fix rearranges more of your code than you'd expect.
I hit this building Skill Exchange (a marketplace for reusable AI skills — link at the end). The marketplace itself isn't the point here; it's the harness I used to figure out how to take money from two people on opposite sides of the world without a US entity and without Stripe. These are the things I'd tell another dev before they start.
PayPal in India is cross-border only — so it can't serve your Indian buyers at all
The first assumption to kill: PayPal is not a domestic payment method in India. PayPal shut down India-to-India domestic payments in April 2021. What's left works only cross-border — an Indian merchant can receive money from abroad, but a rupee-holding buyer in India cannot pay an India-based merchant through it.
I learned this the way you'd expect: I wired up PayPal, tested it with my own (Indian) account against my own (Indian) merchant account, and got a cheerful, useless "Things don't appear to be working at the moment." No error code, no docs entry — the transaction is simply classified as domestic and refused. It works flawlessly the moment the buyer is international, which is exactly what makes the failure so confusing when you're testing from your own country.
Takeaway: if any of your buyers are in the same country as your PayPal merchant account, PayPal alone will strand them. You need a second rail.
You need two providers, and the split is by currency, not features
So the architecture is two providers, chosen by the buyer's currency:
- International buyers → PayPal, charged in USD.
- Indian buyers → Razorpay, charged in INR via UPI (one-tap, near-zero friction, far higher conversion than cards in India).
The important design decision is that the buyer chooses the rail, at checkout, by choosing a currency. I default the toggle by timezone and let them override it — no IP lookup, no external call:
const defaultCurrency = (() => {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz === "Asia/Kolkata" || tz === "Asia/Calcutta" ? "INR" : "USD";
} catch { return "USD"; }
})();
Enter fullscreen mode Exit fullscreen mode
A timezone heuristic is wrong for NRIs and travellers — which is exactly why it's only a default, never a lock. The toggle is always there.
Then the buy endpoint routes on that one field:
async function buySkill(user, skillId, body) {
const skill = await getSkill(skillId);
const wantsInr = String(body?.currency).toUpperCase() === "INR";
if (wantsInr) {
const paise = usdCentsToInrPaise(skill.priceCents);
const order = await razorpay.createOrder({ amount: paise, currency: "INR",
notes: { skillId, buyerId: user.id } }); // notes bind it to this buyer
return { provider: "razorpay", orderId: order.id, amount: paise, currency: "INR" };
}
const order = await paypal.createOrder({ amountUsd: skill.priceCents / 100,
skillId, buyerId: user.id }); // custom_id binds it
return { provider: "paypal", orderId: order.id, amount: skill.priceCents, currency: "USD" };
}
Enter fullscreen mode Exit fullscreen mode
Don't show an exact FX conversion — round to local price points
The naive move is to convert the USD price at some rate and show the result. Don't. A $12 skill at ₹86/\$ is ₹1,032, which looks like a bug and feels expensive — Indian willingness-to-pay for digital goods is lower than a dollar conversion implies. Every serious seller into India uses regional price points instead.
So the conversion isn't usd * rate — it rounds to a familiar ₹x99:
const INR_RATE = 86; // one knob; change it to re-price the whole catalogue
function usdCentsToInrPaise(usdCents) {
if (!usdCents) return 0;
const rupees = (usdCents / 100) * INR_RATE;
const clean = Math.max(1, Math.round(rupees / 100) * 100 - 1); // → ₹x99
return clean * 100; // paise
}
// $5→₹399 $6→₹499 $9→₹799 $12→₹999 $15→₹1,299
Enter fullscreen mode Exit fullscreen mode
That single INR_RATE constant re-prices 200+ listings at once, and the rounding doubles as a light purchasing-power discount without any per-item work.
Store the currency and the commission on the transaction — never recompute
This is the one that bites later if you skip it. The moment you have more than one currency (and a platform fee), you cannot derive money after the fact. Two rules:
-
Persist the currency on every purchase row. A bare
amountCentsis ambiguous the second some of them are paise. - Compute the platform commission at purchase time and store it. If your rate ever changes — mine went 10% → 5% — historical rows must keep the rate they were sold under. A commission you recompute on read is a commission you'll eventually get wrong.
purchase = {
skillId, buyerId,
amount: chargedMinorUnits, // cents for USD, paise for INR
currency, // "USD" | "INR" — never omit
commission: Math.round(chargedMinorUnits * 0.05), // frozen at sale time
provider, providerPaymentId, purchasedAt: now,
};
Enter fullscreen mode Exit fullscreen mode
And keep the seller-revenue aggregates per currency — summing cents and paise into one integer is how you produce a beautiful, meaningless number.
Verify the charge server-side against the order you created — not what the client says
Both rails let the browser lie to you, so the confirm step trusts neither the amount nor the "it succeeded" flag from the client. It re-derives the truth from the provider and checks it belongs to this exact buyer + item:
-
PayPal: capture the order server-side, require
status === "COMPLETED", and check thecustom_idyou set at creation equalsskillId|buyerId. A captured order can't be replayed against a different purchase. -
Razorpay: verify the checkout HMAC signature, then fetch the order back and confirm its
notesmatch the skill and buyer you created it for — the order amount/currency you record come from that fetch, not from the request body.
// Razorpay confirm
if (!verifyCheckoutSignature({ orderId, paymentId, signature })) return forbid();
const order = await razorpay.fetchOrder(orderId); // source of truth
if (order.notes.skillId !== skillId || order.notes.buyerId !== user.id) return forbid();
await recordPurchase({ ...order fields..., currency: order.currency, amount: order.amount });
Enter fullscreen mode Exit fullscreen mode
The rule is the same on both: the amount and currency you write to your ledger come from the provider's record of the order you opened, cryptographically tied to the buyer — never from the client that's telling you it paid.
What I'd tell the next person
- If a PayPal buyer and merchant share a country, PayPal strands them. Plan for a second rail from day one.
- Route by currency, default it, and always let the buyer switch.
- Convert to local price points, not exact FX.
- Freeze currency + commission onto each transaction; aggregate revenue per currency.
- Re-derive the charge server-side from the order you created, tied to the buyer. Trust the provider's record, not the client's word.
None of this is exotic — it's just the stuff nobody mentions when the answer is "add Stripe," and all of it is invisible until the first "Things don't appear to be working" from your own test account.
I built this while making Skill Exchange — a marketplace for reusable AI skills where every listing has to prove it works. Happy to answer payments questions in the comments.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.