The default choice for international SaaS payments is Stripe. We chose Razorpay. The reason is simple: we're India-first and Razorpay's international card support is good enough for our current user base, while its domestic payment methods (UPI, net banking, EMI on Indian cards) are far superior to what Stripe offers in India.
Currency detection
We determine the currency at checkout from two signals: the user's profile (which they set during signup) and a header-based country detection (x-vercel-ip-country in production). If the country is IN, we default to INR. Everything else defaults to USD. The user can override this at checkout.
Razorpay handles international cards for USD orders. The order amount is passed in the lowest denomination of the selected currency: paise for INR (₹1499 = 149900 paise), cents for USD ($19 = 1900 cents).
Order creation
const order = await razorpay.orders.create({
amount: currency === "INR" ? priceInr * 100 : priceUsd * 100,
currency,
receipt: `lapi_${userId}_${planId}_${Date.now()}`,
notes: {
user_id: userId,
plan: planId,
billing_cycle: "monthly",
},
});The notes object is passed through to the webhook payload, which is how we know which user and plan to activate on successful payment. This avoids any state storage between order creation and webhook receipt.
Webhook signature verification
Razorpay webhooks are verified with HMAC-SHA256. The signature is computed over the raw request body and the webhook secret:
import crypto from "crypto";
const expectedSignature = crypto
.createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
if (expectedSignature !== receivedSignature) {
return new Response("Invalid signature", { status: 400 });
}This is why we must read the raw body in the webhook route — JSON parsing before signature verification would break it.
Plan activation and idempotency
On a successful payment.captured webhook event, we upsert the subscription record in Supabase:
await supabaseAdmin
.from("lapi_subscriptions")
.upsert({
user_id: notes.user_id,
plan: notes.plan,
status: "active",
razorpay_payment_id: payload.payment.entity.id,
current_period_start: new Date().toISOString(),
current_period_end: addMonths(new Date(), 1).toISOString(),
}, { onConflict: "user_id" });The upsert on user_id makes the webhook handler idempotent. If Razorpay delivers the same webhook twice (which it will), the second upsert is a no-op.
Why not Stripe
Stripe is excellent but its Indian payment method support requires Stripe India (a separate entity with additional KYC requirements). For a bootstrapped product where 70% of early users are Indian developers, Razorpay's UPI and net banking support is a real conversion advantage. We'll evaluate Stripe when we need it.