Mesrai
Back to blog
// essayTechnical Deep Dive

Razorpay Webhook Amount in Paisa vs Rupees: The 100x Bug

Real PR catch: Razorpay amount treated as rupees — 100× overbilling. Paise convention fix.

Mesrai TeamAugust 24, 20268 min read

A Razorpay payment webhook handler. The amount field is treated as rupees, but Razorpay's API spec always sends it in paise. Every payment is recorded as 100× the actual amount.

The vulnerable diff

tswebhooks/razorpay-payment.ts
// webhooks/razorpay-payment.ts
razorpayRouter.post("/webhook", verifySignature, async (req, res) => {
  const event = JSON.parse(req.body.toString());
  if (event.event === "payment.captured") {
    const payment = event.payload.payment.entity;
    // BUG: payment.amount is in PAISE (smallest unit), not rupees.
    // Treating as rupees inflates by 100×.
    await markOrderPaid(payment.order_id, payment.amount);  // expects rupees
  }
  res.status(200).end();
});

What is wrong

Provider APIs use specific units that the integrating code must respect. Razorpay's `amount` field is always in paise (smallest unit of INR — 1 rupee = 100 paise). Stripe's `amount` is cents. PayPal's `amount.value` is a decimal string of the major unit. Unit conventions are documented but easy to miss. The fix is to capture the convention at the integration boundary, store internally as paise/cents (single canonical unit), and convert only at display.

The attack

Symptom:

textexploit
User pays ₹500 (= 50,000 paise via Razorpay).
Webhook payload: { amount: 50000, currency: "INR" }
Handler stores: amount_rupees = 50000  // WRONG — that's ₹50,000.

Customer charged ₹500. Internal order shows ₹50,000.
Next webhook (refund partial): customer ends up owed money.

Catastrophic at scale.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 30 sec ago

[mesrai] [code-review] [Logic] [Unit-Mismatch] [critical]

Razorpay's amount field is in paise (1 INR = 100 paise). Treating
as rupees results in 100× overbilling.

Fix:

  const amountPaise = payment.amount;   // always paise from Razorpay
  await markOrderPaid(payment.order_id, amountPaise);

Use paise internally throughout (cluster 8 post 71 covers this).
Convert to rupees only at display:

  function formatRupees(paise: number): string {
    return (paise / 100).toFixed(2);
  }

Add a type guard to make the unit explicit:

  type Paise = number & { __brand: "Paise" };
  function asPaise(n: number): Paise { return n as Paise; }

The fix

tswebhooks/razorpay-payment.ts (fixed)
// webhooks/razorpay-payment.ts — fixed
razorpayRouter.post("/webhook", verifySignature, async (req, res) => {
  const event = JSON.parse(req.body.toString());
  if (event.event === "payment.captured") {
    const payment = event.payload.payment.entity;
    const amountPaise = payment.amount;  // explicit: paise
    await markOrderPaid(payment.order_id, amountPaise);
  }
  res.status(200).end();
});

Variable named explicitly `amountPaise`. Internal API takes paise. Display layer converts. Type brand (cluster 6 post 53 pattern) makes unit explicit in the type system.

Why human review missed it

Provider unit mismatch is one of the most damaging integration bugs because it's invisible at small dev amounts (₹1 looks like 100 paise in dev logs) but catastrophic at production scale. Mesrai catches provider integrations where the variable name suggests rupees but the source field is paise (Razorpay, Stripe).

Related rules + further reading

Mesrai rule pack: logic/provider-unit-mismatch — flags amount field reads where naming conflicts with provider convention.

Razorpay API docs: 'All amounts are in smallest unit'.

Stripe API docs: similar convention.

Takeaway

Provider sends paise. Store paise. Display rupees. Never mix. Mesrai catches the 100× error.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.