Node.js integration

Razorpay payment verification in Node.js: signatures and webhooks

Checkout opening is the easy part. Here is how to make sure an order is marked paid only when the money really arrived, exactly once, even if the customer closes the tab.

Sumit KumarSoftware engineer, Vashi

· 10 min read

Getting Razorpay's checkout to open takes ten minutes. Making sure an order is marked paid only when the money has really arrived takes more care, and that is where most tutorials stop short. This guide covers both checks you need in Node.js: the payment signature after checkout, and webhooks for the payments your browser never hears about.

The short answer

To verify a Razorpay payment in Node.js, compute an HMAC-SHA256 of order_id + "|" + razorpay_payment_id using your key secret, and compare it with the razorpay_signature that checkout returns. Use the order_id you created on your server, not the one the browser sends back. Then also listen for the payment.capturedwebhook, verified with your webhook secret against the raw request body, because the browser step doesn't always happen.

Why the success screen isn't proof

When checkout finishes, your frontend receives three values: razorpay_payment_id, razorpay_order_id and razorpay_signature. Anyone can call your “payment successful” endpoint with made-up values, so the server must check the signature before it believes them.

Even a genuine payment can go missing on the way back: the customer pays by UPI on their phone and closes the tab, the network drops, or the bank takes a few minutes to confirm. The money arrives, but your success handler never runs. Webhooks are Razorpay telling your server directly, so they catch those cases.

The reliable flow has four steps:

  1. Your server creates a Razorpay order with the amount it calculated itself.
  2. The browser opens checkout with that order ID.
  3. After checkout, the browser sends the three values to your server, which verifies the signature.
  4. Razorpay's webhook confirms the payment to your server, whether or not step 3 happened.

Steps 3 and 4 both lead to the same function, which marks the order paid exactly once.

Step 1: create the order on the server

routes/payments.js
const Razorpay = require('razorpay');

const razorpay = new Razorpay({
  key_id: process.env.RAZORPAY_KEY_ID,
  key_secret: process.env.RAZORPAY_KEY_SECRET,
});

app.post('/api/payments/order', express.json(), async (req, res) => {
  // Work out the total from your database, never from the request
  const order = await Order.findById(req.body.orderId);
  const amountPaise = Math.round(order.total * 100); // ₹499.00 -> 49900

  const rzpOrder = await razorpay.orders.create({
    amount: amountPaise,
    currency: 'INR',
    receipt: order.number, // up to 40 characters
  });

  order.razorpayOrderId = rzpOrder.id;
  order.amountPaise = amountPaise;
  await order.save();

  res.json({ razorpayOrderId: rzpOrder.id, amount: amountPaise, keyId: process.env.RAZORPAY_KEY_ID });
});

The amount is in paise, the smallest unit of the currency. Forgetting to multiply by 100 is the most common Razorpay bug: the customer is asked to pay ₹4.99 instead of ₹499.

Step 2: open checkout in the browser

checkout.js (browser)
// Load https://checkout.razorpay.com/v1/checkout.js on the page first
const { razorpayOrderId, amount, keyId } = await fetch('/api/payments/order', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ orderId }),
}).then((r) => r.json());

const checkout = new Razorpay({
  key: keyId, // the key id is public; the secret never leaves the server
  order_id: razorpayOrderId,
  amount,
  currency: 'INR',
  name: 'Your Store',
  prefill: { name: customer.name, email: customer.email, contact: customer.phone },
  handler: async (response) => {
    // response has razorpay_payment_id, razorpay_order_id and razorpay_signature
    const result = await fetch('/api/payments/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ orderId, ...response }),
    }).then((r) => r.json());
    window.location.href = result.ok ? `/orders/${orderId}` : '/payment-failed';
  },
});
checkout.open();

Step 3: verify the payment signature

Razorpay signs order_id|payment_id with your key secret. Recreate that signature on your server and compare. Take the order_idfrom your own database: Razorpay's docs specifically say not to use the one checkout returns.

lib/razorpay-verify.js
const crypto = require('crypto');

// Constant-time comparison, so the check doesn't leak how many characters matched
function safeEqual(a, b) {
  const x = Buffer.from(String(a));
  const y = Buffer.from(String(b));
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function isValidPaymentSignature({ razorpayOrderId, paymentId, signature }) {
  const expected = crypto
    .createHmac('sha256', process.env.RAZORPAY_KEY_SECRET)
    .update(`${razorpayOrderId}|${paymentId}`)
    .digest('hex');
  return safeEqual(expected, signature);
}

module.exports = { safeEqual, isValidPaymentSignature };
routes/payments.js
app.post('/api/payments/verify', express.json(), async (req, res) => {
  const order = await Order.findById(req.body.orderId);
  const valid = order && isValidPaymentSignature({
    razorpayOrderId: order.razorpayOrderId, // ours, not req.body.razorpay_order_id
    paymentId: req.body.razorpay_payment_id,
    signature: req.body.razorpay_signature,
  });
  if (!valid) return res.status(400).json({ ok: false });

  await markOrderPaid({
    razorpayOrderId: order.razorpayOrderId,
    paymentId: req.body.razorpay_payment_id,
    amountPaise: order.amountPaise,
  });
  res.json({ ok: true });
});

Step 4: confirm with webhooks

In the Razorpay dashboard, add a webhook pointing to your server, set a webhook secret (a different value from your key secret), and subscribe to payment.captured. Three details matter:

  • Verify against the raw body. The signature in X-Razorpay-Signature is an HMAC-SHA256 of the exact bytes Razorpay sent. If Express has already parsed the JSON, re-serialising it can change those bytes and the check fails. Use express.raw() on this one route.
  • Expect duplicates. Razorpay can deliver the same event more than once. Each event has a unique x-razorpay-event-id header; record it and skip events you have already handled.
  • Expect events out of order. For example, payment.captured can arrive before payment.authorized. Decide based on the event itself, not the order they arrive in.
routes/razorpay-webhook.js
// Register this route BEFORE app.use(express.json()), so the body stays raw
app.post('/api/payments/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const expected = crypto
    .createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET)
    .update(req.body) // a Buffer: the exact bytes Razorpay sent
    .digest('hex');
  if (!safeEqual(expected, req.get('X-Razorpay-Signature'))) {
    return res.sendStatus(400);
  }

  // Skip events we've already handled (unique index on eventId)
  const eventId = req.get('x-razorpay-event-id');
  try {
    await WebhookEvent.create({ eventId });
  } catch (err) {
    if (err.code === 11000) return res.sendStatus(200); // duplicate: already done
    throw err;
  }

  const event = JSON.parse(req.body.toString('utf8'));
  if (event.event === 'payment.captured') {
    const payment = event.payload.payment.entity;
    await markOrderPaid({
      razorpayOrderId: payment.order_id,
      paymentId: payment.id,
      amountPaise: payment.amount,
    });
  }
  res.sendStatus(200);
});

Mark the order paid exactly once

The browser check and the webhook will often both arrive for the same payment, sometimes within milliseconds. Make the update conditional, so only the first one changes anything and only that one triggers emails and shipping:

lib/orders.js
async function markOrderPaid({ razorpayOrderId, paymentId, amountPaise }) {
  // Only a pending order with the expected amount can become paid
  const order = await Order.findOneAndUpdate(
    { razorpayOrderId, status: 'pending', amountPaise },
    { $set: { status: 'paid', razorpayPaymentId: paymentId, paidAt: new Date() } },
    { new: true }
  );
  if (!order) return; // already paid, or the amount doesn't match

  await sendOrderConfirmation(order);
  await bookShipping(order);
}

Because the filter includes status: 'pending', the database itself guarantees that a second call finds nothing to update. Checking amountPaise too means a payment for a different amount can never confirm the order.

Testing before going live

  • Use test-mode keys and Razorpay's test payment methods; no real money moves.
  • Webhooks need a public URL. Deploy a preview, or use a tunnel such as ngrok, and point a test-mode webhook at it.
  • Test the unhappy paths on purpose: close the tab right after paying, send the verify request twice, and change the amount in the browser's developer tools. The order should still end up paid exactly once, and only for the right amount.
  • Remember to switch both the keys and the webhook to live mode at launch, with a new webhook secret.

Common mistakes

  • Sending the amount in rupees instead of paise.
  • Taking the amount from the browser instead of calculating it on the server.
  • Marking the order paid in the frontend handler without any server check.
  • Verifying with the razorpay_order_id the browser sent instead of your stored one.
  • Parsing the webhook body as JSON before checking its signature.
  • Using the key secret to verify webhooks (they use the separate webhook secret).
  • Sending two confirmation emails because both the browser and the webhook “won”.

I use this setup in an online fashion store, where orders are confirmed only after Razorpay confirms the payment and shipping is booked only after that (the shipping part is in my Shiprocket and Node.js guide). If you need payments in your store or app, see what I build or get in touch.

Questions people also ask

How do I verify a Razorpay payment signature in Node.js?

Create an HMAC-SHA256 of order_id + "|" + razorpay_payment_id with your key secret using the crypto module, and compare the hex digest with razorpay_signature using a constant-time comparison. Use the order ID stored on your server.

How do I verify a Razorpay webhook?

Compute an HMAC-SHA256 of the raw request body with your webhook secret and compare it with the X-Razorpay-Signature header. In Express, use express.raw() on the webhook route so the body is not parsed first.

Why does my Razorpay webhook signature not match?

Usually because the body was parsed as JSON before verifying, the key secret was used instead of the webhook secret, or the secret was changed while older events were still being retried.

Do I need webhooks if I already verify the signature?

Yes, for reliability. If the customer closes the tab or loses connection after paying, your success handler never runs. The payment.captured webhook still reaches your server, so the order is not left unpaid.

Is the Razorpay amount in rupees or paise?

Paise, the smallest currency unit. Multiply rupees by 100: ₹499 is sent as 49900.

Read next