Payment bugs are the worst category of bug. They are visible to customers, they involve money, and they surface as support tickets rather than exceptions. Here are the patterns that keep a Razorpay integration correct under real traffic.
Never trust client-side prices
The first rule of any checkout: the client tells you what they want to buy, not what it costs.
// ❌ The browser can send anything
const { items, total } = await req.json();
const order = await razorpay.orders.create({ amount: total * 100, currency: "INR" });
Anyone can modify that request and buy a licence for ₹1. Instead, send identifiers and price on the server:
const { cart, couponCode } = await req.json();
const products = await prisma.products.findMany({
where: { id: { in: cart.map((c) => c.productId) } },
select: { id: true, price: true, isActive: true },
});
let subtotal = 0;
for (const line of cart) {
const p = products.find((x) => x.id === line.productId);
if (!p || !p.isActive) return bad("Item unavailable");
subtotal += Number(p.price) * (line.quantity ?? 1);
}
const discount = await resolveCoupon(couponCode, subtotal); // validated server-side
const amount = Math.round((subtotal - discount) * 100); // paise, integer
Work in the smallest currency unit as integers. Floating-point arithmetic on money produces 1078.9999999999998, and gateways reject or silently round it.
Creating the order server-side
const order = await razorpay.orders.create({
amount,
currency: "INR",
receipt: `rcpt_${Date.now()}`,
notes: { customerId: session.user.id },
});
return NextResponse.json({ orderId: order.id, amount: order.amount, keyId: process.env.RAZORPAY_KEY_ID });
Only the key id goes to the browser. The key secret stays server-side — it is what signs and verifies payments. Put the customer id in notes so the gateway record is traceable back to your user without a lookup table.
Signature verification
The step that must never be skipped. When the modal succeeds it returns three values, and you verify them with HMAC:
import crypto from "crypto";
const expected = crypto
.createHmac("sha256", process.env.RAZORPAY_KEY_SECRET!)
.update(`${razorpay_order_id}|${razorpay_payment_id}`)
.digest("hex");
const valid = crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(razorpay_signature),
);
if (!valid) return bad("Signature verification failed");
Use timingSafeEqual, not ===. String comparison short-circuits on the first differing character, which leaks information through timing. It costs nothing to do correctly.
Without this check, a request to your verify endpoint with fabricated ids would create a paid order for free.
Idempotency keys
The verify endpoint will be called more than once — network retries, an impatient customer, a webhook arriving alongside the client callback.
Make the payment id unique in your schema and let the database enforce it:
const existing = await prisma.orders.findUnique({ where: { razorpayPaymentId: razorpay_payment_id } });
if (existing) return NextResponse.json({ verified: true, orderNumber: existing.orderNumber });
const created = await prisma.$transaction(async (tx) => {
const order = await tx.orders.create({ data: { /* … */ razorpayPaymentId: razorpay_payment_id } });
await tx.licenses.createMany({ data: buildLicences(order) });
return order;
});
Two things matter: the early return makes repeat calls safe and idempotent rather than erroring, and the transaction means you never end up with an order row and no licences because something failed halfway.
Handling payment.failed and modal dismiss
Most integrations handle success and ignore everything else, which means failures are invisible.
const rzp = new window.Razorpay({
/* … */
modal: {
ondismiss: () => {
setPaying(false);
gtmEvent("payment_cancelled", { value: total, transaction_id: order.orderId });
},
},
});
rzp.on("payment.failed", (resp) => {
setPaying(false);
gtmEvent("payment_failed", {
error_code: resp?.error?.code,
error_reason: resp?.error?.reason,
error_description: resp?.error?.description,
});
});
rzp.open();
payment.failed is a separate subscription — it is not the handler callback, and if you never register it you will not know that a segment of customers is failing on a particular card type or bank.
Reconciliation
The client callback is not a reliable channel. The browser closes, the connection drops, the customer navigates away between paying and your verify call. That payment succeeded at the gateway and does not exist in your database.
Two defences:
Webhooks. Configure payment.captured and order.paid to hit an endpoint that runs the same verification and fulfilment path — idempotent, so it is harmless when the client already reported success. Verify the webhook signature too; it uses a different secret.
A reconciliation job. Periodically fetch recent gateway payments and compare against your orders table. Alert on captured payments with no order. This is the safety net that catches whatever the first two miss.
Tracking it correctly in GA4
Fire purchase after server verification, not in the Razorpay success handler, and use the order number your database issued as transaction_id:
if (data.verified && data.orderNumber) {
analytics.purchase({ transaction_id: data.orderNumber, value: total, items });
}
Firing in the client handler records revenue for payments that later fail verification. Using a client-generated id breaks GA4's deduplication, so every refresh adds another sale.
A working mental model: the gateway reports what the customer attempted; your database records what actually happened. Report from the database.