Dollars and rupees without Stripe: what building Skill Exchange's checkout taught me (PayPal + UPI) A developer building Skill Exchange, a marketplace for reusable AI skills, found that PayPal cannot handle domestic payments in India, so they built a dual-provider checkout that routes Indian buyers to Razorpay and international buyers to PayPal. The system uses a timezone-based default currency toggle and regional pricing that rounds to ₹x99, with a single INR_RATE constant re-pricing the entire catalogue. "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. 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. So the architecture is two providers, chosen by the buyer's currency: 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: js const defaultCurrency = = { try { const tz = Intl.DateTimeFormat .resolvedOptions .timeZone; return tz === "Asia/Kolkata" || tz === "Asia/Calcutta" ? "INR" : "USD"; } catch { return "USD"; } } ; 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: js 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" }; } 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: js 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 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. 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: amountCents is ambiguous the second some of them are paise. 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, }; And keep the seller-revenue aggregates per currency — summing cents and paise into one integer is how you produce a beautiful, meaningless number. 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: status === "COMPLETED" , and check the custom id you set at creation equals skillId|buyerId . A captured order can't be replayed against a different purchase. notes match 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 } ; 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. 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.