DEV Community

Nikil .r
Nikil .r

Posted on

The 3 Razorpay integration bugs I kept finding in code review

I've read through a lot of Razorpay + Next.js integrations over the past few months. Different teams, different codebases, different levels of experience — and the same three mistakes show up almost every time.

They're cheap to fix and expensive to miss. Writing them up because if you're shipping payments this week, you probably have at least one of these.

1. The price comes from the client

This is the one that costs real money.

The flow usually looks like this. The frontend posts the amount to your server:

// Don't do this.
await fetch("/api/create-order", {
  method: "POST",
  body: JSON.stringify({ amount: 50000 }) // ₹500
});
Enter fullscreen mode Exit fullscreen mode

And the server trusts it:

// Don't do this either.
const order = await razorpay.orders.create({
  amount: req.body.amount,   // ← whatever the browser said
  currency: "INR"
});
Enter fullscreen mode Exit fullscreen mode

Anyone can open devtools, change 50000 to 100, and buy your ₹5000 product for ₹1. There's no exploit required. It's just a number the client controls and the server believes.

The fix: the client never sends a price. It sends a product id. The server looks up the real amount from its own data.

// lib/pricing.ts
const PRODUCTS = {
  "kit-v1": { amount: 150000, currency: "INR" }, // ₹1500, in paise
} as const;

export function getPrice(productId: string) {
  const product = PRODUCTS[productId];
  if (!product) throw new Error("Unknown product");
  return product; // server-owned. the client can't touch this.
}
Enter fullscreen mode Exit fullscreen mode
// pages/api/create-order.ts
const { amount, currency } = getPrice(req.body.productId);
const order = await razorpay.orders.create({ amount, currency });
Enter fullscreen mode Exit fullscreen mode

The client's productId is a lookup key, not a value. That's the whole distinction.

One more thing while you're here: Razorpay amounts are in paise. ₹500 is 50000, not 500. I have personally shipped this bug and charged someone ₹5 for a ₹500 product. It's the single most common off-by-100 in Indian payment code.

2. The webhook is trusted without verification

Your Razorpay webhook URL is a public endpoint. If you process payment.captured without checking the signature, anyone who guesses the URL can mark orders as paid for free.

The check itself is simple — HMAC SHA256 of the request body, compared against the x-razorpay-signature header.

The part that trips everyone up is that the signature covers the raw bytes of the request. Not the parsed object. The raw body, exactly as it arrived on the wire.

If you do this:

// Broken. Always rejects. No idea why.
export default async function handler(req, res) {
  const body = req.body; // ← already parsed by Next.js
  const signature = req.headers["x-razorpay-signature"];

  const valid = verifySignature(JSON.stringify(body), signature);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

...the signature will never match. JSON.stringify re-serialises the object with different key ordering and whitespace than the original. The bytes don't line up. HMAC fails. And the only error you get is invalid signature, with no hint that the real problem is your body parser.

The fix: disable the body parser, read the raw stream, verify, then parse.

// pages/api/webhooks/razorpay.ts

// The body parser must be OFF — the signature covers the raw bytes.
export const config = { api: { bodyParser: false } };

async function readRawBody(req): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  return Buffer.concat(chunks);
}

export default async function handler(req, res) {
  const rawBody = await readRawBody(req);

  const valid = verifyWebhookSignature(
    rawBody,
    req.headers["x-razorpay-signature"] as string
  );
  if (!valid) return res.status(400).json({ error: "Invalid signature." });

  // Only NOW is the payload trustworthy.
  const event = JSON.parse(rawBody.toString("utf8"));
  // ...
}
Enter fullscreen mode Exit fullscreen mode

And compare in constant time — a plain === on the signature leaks timing information:

import { createHmac, timingSafeEqual } from "crypto";

export function verifyWebhookSignature(rawBody: Buffer, signature: string) {
  const expected = createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET!)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length) return false;

  return timingSafeEqual(a, b);
}
Enter fullscreen mode Exit fullscreen mode

The raw-body trap is the single most-asked question in every Razorpay + Next.js thread I've seen. It's four lines of config and it saves an afternoon.

3. Webhooks are assumed to arrive exactly once

Razorpay retries webhooks. If your endpoint is slow, returns a non-2xx, or the network blips, the same event is delivered again. And again.

If your handler fulfils the order every time it sees payment.captured, you ship the product twice and get paid once. On a licence product that's two keys for one payment. On a physical product it's two parcels.

The fix: an atomic check-and-set. The first delivery claims the order and fulfils. Every later delivery sees the claim already taken, does nothing, and returns 200.

// Atomic: returns true for exactly ONE delivery per order.
const { claimed } = await orderStore.claimForFulfilment(orderId, paymentId);

if (!claimed) {
  // Duplicate delivery. Already handled. Acknowledge and move on.
  return res.status(200).json({ duplicate: true });
}

// First and only delivery. Fulfil here.
await deliverProduct(orderId);
return res.status(200).json({ ok: true });
Enter fullscreen mode Exit fullscreen mode

The subtle part: a duplicate must return 200, not an error. If you return 500 on a duplicate, Razorpay assumes the webhook failed and retries it again. You've turned one duplicate into an infinite retry loop. Duplicates aren't errors — they're the system working as designed.

These three are the whole surface area

Client-trusted prices, unverified webhooks, non-idempotent fulfilment. Get those right and your Razorpay integration is genuinely production-grade. Everything else is polish.

I put the corrected version of all three — plus refunds, a typed webhook payload, and a test suite — into a free MIT starter you can read right now:

→ github.com/Nikil-git/razorpay-nextjs-starter

If you want the fuller version — idempotent fulfilment wired end to end, a refund endpoint with a balance check, a troubleshooting README, and 49 passing assertions across the signature and webhook handlers — that's here:

→ razorpay-kit.vercel.app

Either way, check your own code for #1 first. It's the one that's actually costing you money right now.

Top comments (0)