Node.js integration

PayU payment gateway integration in Node.js

From a no-code payment link to a full hosted checkout: the exact hash formulas, a reverse-hash check, the verify_payment API and the mistakes that cause hash mismatches.

Sumit KumarSoftware engineer, Vashi

· 9 min read

PayU is one of India's most widely used payment gateways, and its hosted checkout is simple once you know two things: how to build the hash, and how to check the hash PayU sends back. Most failed integrations get one of those slightly wrong. This guide covers the whole flow in plain Node.js, from a no-code payment link to a full checkout that marks orders paid only when the money has really arrived.

The short answer

To integrate PayU in Node.js, build a SHA-512 hash on your server from key|txnid|amount|productinfo|firstname|email|udf1|udf2|udf3|udf4|udf5||||||SALT, then POST the payment fields and the hash to https://secure.payu.in/_payment (https://test.payu.in/_paymentwhile testing). PayU sends the result back to your success or failure URL. Before marking the order paid, check PayU's reverse hash and confirm the status with the verify_payment API.

Need this built into your store? I’ll do the integration.

Get it integrated →

If you only need customers to pay an amount you have already agreed, such as an advance against a quote or an invoice, you may not need an integration at all. PayU lets you create payment links and payment pages from its dashboard. You put the link behind a “Pay now” button on your website or send it on WhatsApp, and PayU handles the rest.

The catch is that your website doesn't know which order was paid. Someone has to match payments to customers by hand. That is fine for a service business taking a few payments a day, and not fine for an online store. For a store, you want option 2.

Option 2: hosted checkout in Node.js

With PayU's hosted checkout, your server prepares the payment, the customer pays on PayU's page, and PayU posts the result back to your server. You need:

  1. Your merchant key and salt from the PayU dashboard. Test and live mode have different ones.
  2. Two public HTTPS URLs on your server: one for success (surl) and one for failure (furl). PayU posts the result to them.
  3. The salt kept on the server only. Anyone with your salt can forge a valid hash.
.env
PAYU_KEY=your-merchant-key
PAYU_SALT=your-merchant-salt
PAYU_BASE=https://test.payu.in
# Live: PAYU_BASE=https://secure.payu.in

Build the request hash

PayU uses SHA-512 over the fields joined with pipes, in a fixed order, with the salt at the end. The five udf fields are optional, but their places in the string are not: an unused udf is an empty string between two pipes, and there are always six pipes between udf5 and the salt.

lib/payu.js
const crypto = require('crypto');

const KEY = process.env.PAYU_KEY;
const SALT = process.env.PAYU_SALT;
const sha512 = (text) => crypto.createHash('sha512').update(text).digest('hex');

// key|txnid|amount|productinfo|firstname|email|udf1|udf2|udf3|udf4|udf5||||||SALT
function requestHash({ txnid, amount, productinfo, firstname, email, udf = [] }) {
  const udfs = [0, 1, 2, 3, 4].map((i) => udf[i] ?? '');
  return sha512([KEY, txnid, amount, productinfo, firstname, email, ...udfs, '', '', '', '', '', SALT].join('|'));
}

module.exports = { KEY, SALT, sha512, requestHash };

Create the payment and send the customer to PayU

When the customer clicks Pay, create the order in your database first, then return a page with a form that posts straight to PayU. The amount comes from your database, never from the browser.

routes/payu.js
const express = require('express');
const { KEY, requestHash } = require('../lib/payu');
const router = express.Router();

const escapeHtml = (v) => String(v).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');

router.post('/pay/:orderId', async (req, res) => {
  const order = await Order.findById(req.params.orderId);
  if (!order || order.status !== 'pending') return res.sendStatus(404);

  const fields = {
    key: KEY,
    txnid: order.txnid, // unique per attempt, e.g. "ORD1042-1"
    amount: order.total.toFixed(2), // "499.00", the same string goes into the hash
    productinfo: 'Order ' + order.number,
    firstname: order.customer.firstName,
    email: order.customer.email,
    phone: order.customer.phone,
    surl: 'https://yourstore.com/payu/success',
    furl: 'https://yourstore.com/payu/failure',
  };
  fields.hash = requestHash(fields);

  // A form that submits itself to PayU
  const inputs = Object.entries(fields)
    .map(([name, value]) => `<input type="hidden" name="${name}" value="${escapeHtml(value)}">`)
    .join('');
  res.send(`<form id="payu" method="post" action="${process.env.PAYU_BASE}/_payment">${inputs}</form>
    <script>document.getElementById('payu').submit()</script>`);
});

txnidmust be unique for every payment attempt. If a customer's first attempt fails and they try again, give the retry a new txnid and keep the link to the same order.

Check PayU's response with the reverse hash

After payment, PayU sends a form POST to your surl or furl with fields such as status, txnid, amount, mihpayid(PayU's payment ID) and a hash. That hash uses the same fields in reverse order, starting with the salt:

SALT|status||||||udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|txnid|key

If PayU added charges, the response includes additional_charges, which goes in front: additional_charges|SALT|status|…. Check the hash before anything else, then check the amount against your own order:

lib/payu.js
function responseHashMatches(p) {
  const parts = [SALT, p.status, '', '', '', '', '',
    p.udf5 ?? '', p.udf4 ?? '', p.udf3 ?? '', p.udf2 ?? '', p.udf1 ?? '',
    p.email, p.firstname, p.productinfo, p.amount, p.txnid, KEY];
  if (p.additional_charges) parts.unshift(p.additional_charges);

  const expected = Buffer.from(sha512(parts.join('|')));
  const received = Buffer.from(String(p.hash ?? '').toLowerCase());
  return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}
routes/payu.js
// PayU posts a URL-encoded form, not JSON
router.post('/payu/success', express.urlencoded({ extended: false }), async (req, res) => {
  const p = req.body;
  if (!responseHashMatches(p)) return res.status(400).send('Invalid response');

  const order = await Order.findOne({ txnid: p.txnid });
  if (!order || order.total.toFixed(2) !== p.amount) return res.status(400).send('Amount mismatch');

  if (p.status === 'success') await markPaid(order, p.mihpayid);
  res.redirect('/orders/' + order.number);
});

Confirm the status with the verify_payment API

The browser redirect can fail: the customer closes the tab, the network drops, or a status arrives as pending and changes later. PayU's verify_payment API asks PayU directly. Its hash is sha512(key|command|var1|SALT), where var1 is your txnid:

lib/payu.js
const VERIFY_URL = {
  'https://test.payu.in': 'https://test.payu.in/merchant/postservice.php?form=2',
  'https://secure.payu.in': 'https://info.payu.in/merchant/postservice.php?form=2',
}[process.env.PAYU_BASE];

async function verifyPayment(txnid) {
  const command = 'verify_payment';
  const body = new URLSearchParams({
    key: KEY,
    command,
    var1: txnid,
    hash: sha512([KEY, command, txnid, SALT].join('|')),
  });
  const res = await fetch(VERIFY_URL, { method: 'POST', body });
  const data = await res.json();
  return data.transaction_details?.[txnid]; // { status: 'success' | 'failure' | 'pending', mihpayid, amt, ... }
}

Run it from a small scheduled job for orders still marked pending after 15 to 30 minutes, and when a customer contacts you saying they paid but the order shows unpaid.

Add webhooks as a backup

PayU can also send payment events to your server directly, server to server, so you hear about a payment even when the customer never comes back to your site. Add the webhook URL in the PayU dashboard, verify the hash in every webhook the same way as the redirect, and handle each event idempotently, because the same event can arrive more than once and in addition to the redirect.

lib/orders.js
// Safe to call from the redirect, the webhook and the verify job
async function markPaid(order, mihpayid) {
  await Order.updateOne(
    { _id: order._id, status: { $ne: 'paid' } }, // only the first call changes anything
    { $set: { status: 'paid', payuId: mihpayid, paidAt: new Date() } }
  );
}

Testing

Use your test key and salt with https://test.payu.in/_payment. PayU publishes test payment details in its docs, including:

PayU test payment details
MethodDetailsResult
Card5123 4567 8901 2346, any future expiry, CVV 123, OTP 123456Success
Card5123 4567 8901 2340Failure
UPIanything@payu or 9999999999@payuSuccess

Test both paths: a successful payment, a failed one, and closing the tab on PayU's page to make sure your verify job picks up the order later.

Going live

  1. Switch the PayU dashboard to live mode and copy the live key and salt into your server's environment.
  2. Change PAYU_BASE to https://secure.payu.in, which also switches the verify URL to info.payu.in.
  3. Make one small real payment and confirm the redirect, the webhook and the order status all agree.

Common mistakes

  • Wrong number of pipes. Leaving out empty udf places, or the six pipes after udf5, is the most common hash mismatch.
  • Test salt with live key, or the other way round. They come in pairs.
  • Trusting the success redirect without the reverse hash. Anyone can post a form to your success URL.
  • Reading the amount from the browser. Always take it from your own order.
  • Reusing a txnid for a retry. PayU expects a new, unique txnid for every attempt.

Prefer Razorpay? The same ideas apply there: see verifying Razorpay payments in Node.js. For shipping after payment, see the Shiprocket API guide. If you'd rather have it built, see e-commerce website development or tell me about your store.

Questions people also ask

What is the PayU hash formula?

For the hosted checkout request, the hash is SHA-512 of key|txnid|amount|productinfo|firstname|email|udf1|udf2|udf3|udf4|udf5||||||SALT. Unused udf fields are empty strings, but their pipes stay, and there are six pipes between udf5 and the salt.

How do I verify the PayU response hash?

PayU sends a hash built from the same fields in reverse order: SHA-512 of SALT|status||||||udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|txnid|key. If the response includes additional_charges, put it first, before the salt. Compare it with the hash PayU sent before marking the order paid.

Why does my PayU hash not match?

Usually a missing empty udf place, the wrong number of pipes after udf5, an amount formatted differently in the hash and the form (499 vs 499.00), extra spaces in a field, or a test salt used with a live key.

How do I check a PayU payment status from the server?

Call the verify_payment command on PayU’s postservice API with key, command=verify_payment, var1 set to your txnid, and a hash of SHA-512 of key|command|var1|SALT. The response has transaction_details with the status for that txnid.

What are the PayU test card details?

PayU’s docs list card 5123456789012346 with any future expiry, CVV 123 and OTP 123456 for a successful test payment, and UPI IDs like anything@payu for UPI tests. Use them with your test key and salt on test.payu.in.

Get it integrated

Tell me your stack and what you need: Razorpay, Shiprocket or both. I’ll reply by email with how I’d do it and the price. Or book a call.

Based in
Vashi, Navi Mumbai

By submitting this form, you agree that I may contact you at the email address you provide about your enquiry.

Read next