Razorpay's order-then-verify flow trips up developers coming from Stripe's checkout-session model, because the two platforms structure the same underlying problem — confirming a payment actually happened — differently enough that copying patterns between them causes real bugs.
Razorpay is a payments platform built for the Indian market, supporting UPI, cards, netbanking, and wallets, with deep integration into India-specific payment rails that international platforms often support less natively. Its core flow centers on creating an "order" server-side, collecting payment client-side against that order, then verifying the payment signature server-side before considering it complete.
Why Razorpay Integration Matters (and When to Skip It)
For businesses operating in India, Razorpay's native UPI support and familiarity with Indian payment methods and compliance requirements (like RBI regulations) makes it a strong default choice over international-first platforms. Its checkout widget supports the payment methods Indian users actually prefer, which matters directly for conversion rates.
Skip Razorpay if you're not operating in or targeting the Indian market specifically — for a primarily international user base, a platform with broader native international payment method support might fit better.
Getting Started with Razorpay
Creating an order server-side:
import Razorpay from "razorpay";
const razorpay = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID!,
key_secret: process.env.RAZORPAY_KEY_SECRET!,
});
const order = await razorpay.orders.create({
amount: 50000, // in paise (₹500.00)
currency: "INR",
receipt: `order_${orderId}`,
});
Client-side checkout using the order ID:
const options = {
key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
amount: order.amount,
currency: order.currency,
order_id: order.id,
handler: async (response: any) => {
await fetch("/api/verify-payment", {
method: "POST",
body: JSON.stringify(response),
});
},
};
const rzp = new (window as any).Razorpay(options);
rzp.open();
Verifying the payment signature server-side — the step that actually confirms the payment:
import crypto from "crypto";
function verifySignature(orderId: string, paymentId: string, signature: string) {
const expected = crypto
.createHmac("sha256", process.env.RAZORPAY_KEY_SECRET!)
.update(`${orderId}|${paymentId}`)
.digest("hex");
return expected === signature;
}
Core Razorpay Concepts Every Developer Should Know
The client-side handler callback is not proof of payment — signature verification is. Same underlying principle as Stripe's webhook-over-redirect rule: a client-side callback can be spoofed or interrupted, so fulfillment must be gated on verifying razorpay_signature server-side, never on the callback firing alone.
Webhooks provide a more reliable confirmation path than relying solely on the client-side handler, especially for cases where the user closes the browser before the handler fires:
app.post("/webhooks/razorpay", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-razorpay-signature"] as string;
const expected = crypto
.createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET!)
.update(req.body)
.digest("hex");
if (signature !== expected) return res.status(400).send("Invalid signature");
const event = JSON.parse(req.body);
if (event.event === "payment.captured") {
// fulfill order
}
res.json({ status: "ok" });
});
Amounts are always in the smallest currency unit (paise for INR, same pattern as Stripe's cents) — a common off-by-100x bug comes from forgetting this and passing rupee amounts directly.
UPI-specific flows have their own considerations, like handling the UPI intent flow on mobile versus the QR/collect flow on desktop — testing across these different UPI payment paths matters specifically for the Indian market's payment method mix.
Common Razorpay Mistakes and How to Fix Them
Mistake 1: fulfilling orders based on the client-side handler callback alone. Same category of risk as trusting a Stripe redirect — exploitable and unreliable if the browser closes early. Fix: always verify the payment signature server-side (and prefer webhooks as the primary fulfillment trigger) before granting access.
Mistake 2: amount unit confusion (rupees vs. paise). Passing 500 instead of 50000 for a ₹500 charge either fails or charges the wrong amount. Fix: be explicit and consistent about currency units throughout the codebase, ideally with a dedicated conversion helper.
Mistake 3: not verifying webhook signatures, the same risk as any webhook integration — an unverified payload could be spoofed. Fix: always verify x-razorpay-signature against your webhook secret before trusting event data.
When Should You Use Razorpay Instead of Stripe?
Use Razorpay when your primary market is India and you need native UPI support, familiarity with Indian payment methods, and RBI-compliant handling. Use Stripe (or a similar international-first platform) for a primarily international user base, or when you need Stripe's specific feature depth in subscription billing and global payment method coverage.
Razorpay Integration in Production
Test thoroughly with Razorpay's test mode across the different payment method flows your users will actually use (UPI, cards, netbanking) since each has distinct edge cases. Also treat webhook-based fulfillment as the primary confirmation path, with the client-side handler as a UX nicety only — the same discipline that keeps any payment integration from having exploitable gaps.
Before launch, verify that signature checking happens on every payment confirmation path (both the handler-triggered verification endpoint and the webhook) — that's the detail most likely to be missed under a launch deadline.