Razorpay Payment Integration: UPI and Card Payments for Your Project Website

How do you add UPI and card payments to a project website? With Razorpay, the flow is: your server creates an order (amount in paise, never trusted from the client), the Checkout popup collects the payment, your server verifies the HMAC-SHA256 payment signature, and a webhook confirms capture even if the buyer closes the tab. This guide walks through test mode, order creation, signature verification, webhooks, refunds, and the going-live checklist — with server-side Node.js code throughout.

Written by Projectech15 min readPublished
For B.E./B.Tech Computer Science and IT students adding UPI and card payments to an e-commerce or marketplace project Topics: Razorpay, Node.js, Express, UPI, Webhooks
Illustration of online payments: a shopping cart on a laptop connected by flowing lines to credit cards and a smartphone with a UPI-style payment graphic and rupee symbols.
Illustration generated for this guide.
In this guide

Nothing makes a project demo land like a real payment going through: the buyer scans a UPI QR, the money moves, and your dashboard marks the order paid. For Indian student projects, Razorpay is the natural gateway — it bundles UPI, cards, netbanking and wallets behind one integration, and its test mode lets you run the entire flow without moving a single real rupee. This guide covers the complete integration the way production systems do it: orders created on your server, payment signatures verified with HMAC, webhooks as the source of truth, and a going-live checklist that keeps you out of trouble.

Short answer: how a Razorpay payment flows

  1. Your server creates an order on Razorpay with the amount in paise and gets back an order_id. The amount comes from your database, never from the browser — a client that dictates its own price is a discount engine for attackers.
  2. Your page opens Razorpay Checkout with that order_id. The buyer pays by UPI, card, netbanking or wallet inside Razorpay's popup.
  3. Razorpay returns a razorpay_payment_id, the order_id, and a razorpay_signature to your page.
  4. Your page forwards those three values to your server, which recomputes the HMAC-SHA256 signature with your secret key. Match = the payment is genuine.
  5. Separately, Razorpay POSTs a webhook to your server confirming the payment was captured. The webhook — not the browser callback — is what marks the order paid, because buyers close tabs.

Steps 4 and 5 are the ones student integrations most often skip, and skipping them is how fake "successful" payments happen.

Test mode first: the sandbox where mistakes are free

Create a Razorpay account and stay in Test Mode (the dashboard toggle at the top). Test mode gives you:

  • A key pair starting with rzp_test_ — a public key_id for the frontend and a key_secret that lives only on your server.
  • A simulated Checkout that walks through card and UPI flows without touching real money or real bank accounts.
  • Full dashboard visibility: orders, payments, refunds and webhooks all appear exactly as they will in live mode.

House rule: build the entire integration — order creation, checkout, signature verification, webhook handling, refunds — in test mode before you even think about live keys. Every mistake below is cheap in test mode and expensive in live mode.

Step 1: create the order on your server

An order is Razorpay's record of "someone owes this much for this receipt". Your server creates it with the Razorpay Node SDK (npm install razorpay):

const Razorpay = require('razorpay');

const razorpay = new Razorpay({
  key_id: process.env.RAZORPAY_KEY_ID,         // rzp_test_... (public)
  key_secret: process.env.RAZORPAY_KEY_SECRET, // stays on the server, in .env
});

app.post('/api/orders', async (req, res) => {
  // Price comes from YOUR database, keyed by the product in the cart.
  // Never accept an amount field from the request body.
  const cart = await db.carts.getForUser(req.user.sub);
  const totalPaise = cart.items.reduce((sum, it) => sum + it.pricePaise * it.qty, 0);

  const order = await razorpay.orders.create({
    amount: totalPaise,        // integer paise: Rs 499.00 -> 49900
    currency: 'INR',
    receipt: 'rcpt_' + cart.id,
    notes: { userId: req.user.sub },
  });

  await db.orders.save({ id: order.id, userId: req.user.sub, amountPaise: totalPaise, status: 'created' });
  res.json({ orderId: order.id, amountPaise: totalPaise, keyId: process.env.RAZORPAY_KEY_ID });
});

Two details that prevent whole categories of bugs: amounts are integer paise (floating-point rupees invite rounding errors — Rs 499.00 is 49900, never 499.00), and the receipt ties Razorpay's order back to your own records for reconciliation.

The equivalent with plain curl, useful for testing without touching your app:

curl -u "rzp_test_YOURKEY:YOURSECRET"   -X POST https://api.razorpay.com/v1/orders   -H "Content-Type: application/json"   -d '{"amount":49900,"currency":"INR","receipt":"rcpt_test_001"}'

Step 2: collect the payment with Checkout

Razorpay's hosted Checkout library renders the payment popup (UPI, cards, netbanking, wallets) so card numbers and UPI handles never touch your server — which keeps you out of PCI-DSS scope. Your page loads the Checkout library, opens it with the order_id your server just created, and receives the result in a callback. The options your page passes look like this (shown as data, not code):

{
  "key": "rzp_test_YOURKEY",
  "amount": 49900,
  "currency": "INR",
  "order_id": "order_9A33XWu170gUtm",
  "name": "Campus Canteen Store",
  "description": "Order rcpt_cart_4821",
  "prefill": { "email": "aarav@example.com", "contact": "9876543210" }
}

On success the callback hands your page three values: razorpay_payment_id, razorpay_order_id, and razorpay_signature. Your page's only job now is to POST those three to your server. It must not mark the order paid itself — the browser is an untrusted environment, and "payment successful" from JavaScript is a claim, not a fact.

Step 3: verify the signature — the step students skip

This is the security core of the integration. Razorpay signs every successful payment with your secret key; your server recomputes that signature and compares. If they match, the payment genuinely came from Razorpay for this order and this amount.

const crypto = require('crypto');

app.post('/api/payments/verify', async (req, res) => {
  const p = req.body; // { razorpay_order_id, razorpay_payment_id, razorpay_signature }
  const body = p.razorpay_order_id + '|' + p.razorpay_payment_id;
  const expected = crypto
    .createHmac('sha256', process.env.RAZORPAY_KEY_SECRET)
    .update(body)
    .digest('hex');

  if (expected !== p.razorpay_signature) {
    return res.status(400).json({ error: 'signature mismatch: payment not verified' });
  }
  // Signature is genuine. The webhook (next section) is what flips the order to paid.
  res.json({ ok: true, paymentId: p.razorpay_payment_id });
});

Compare with a constant-time comparison in production (crypto.timingSafeEqual) to avoid leaking information through response timing. And note what this defends against: an attacker who crafts a fake "payment successful" callback cannot produce a valid signature without your secret, so the verification fails and the order stays unpaid.

Step 4: webhooks — the browser is not the source of truth

After a payment is captured, Razorpay POSTs events like payment.captured to a URL you configure in the dashboard. This matters because the browser callback is unreliable: buyers close the popup, lose network, or their phone dies mid-payment. The webhook arrives regardless.

Verify webhooks too. Each webhook carries an X-Razorpay-Signature header — an HMAC-SHA256 of the raw request body with your webhook secret (a separate secret you set in the dashboard, not your API secret):

app.post('/api/webhooks/razorpay', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-razorpay-signature'];
  const expected = crypto
    .createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET)
    .update(req.body) // raw bytes, before JSON parsing
    .digest('hex');

  if (signature !== expected) return res.status(400).end();

  const event = JSON.parse(req.body.toString());
  if (event.event === 'payment.captured') {
    const payment = event.payload.payment.entity;
    // Idempotency: payment ids are unique; a DB unique constraint makes
    // duplicate deliveries harmless.
    await db.orders.markPaidIfPending(payment.order_id, payment.id);
  }
  res.status(200).end(); // acknowledge fast; Razorpay retries on non-2xx
});

Three webhook rules that save you in production: use express.raw so the HMAC is computed over the exact bytes Razorpay signed (parsed-and-reserialized JSON has different whitespace); make your handler idempotent — Razorpay retries deliveries, so processing the same event twice must be harmless; and respond 200 quickly, doing slow work (emails, inventory) asynchronously, because slow webhook endpoints get retried and then rate-limited.

Amounts, paise and the GST math

All Razorpay amounts are integer paise. Do the tax arithmetic in paise on your server and keep one worked example in your project report:

Item Maths
Product price Rs 499.00 → 49900 paise
GST 18% Math.round(49900 * 0.18) = 8982 paise
Total charged 49900 + 8982 = 58882 paise (Rs 588.82)

Rounding per line-item vs on the total can differ by a paise or two — pick one policy, document it, and make the order-creation code the single place that computes totals. When the webhook arrives, compare the captured amount against your stored amountPaise; a mismatch means something changed the price mid-flow and the order needs manual review, not auto-fulfilment.

Test credentials: what to use in test mode

Method Test credential Expected result
Card (success) 4111 1111 1111 1111, any future expiry, any CVV Payment succeeds
Card (failure) 4000 0000 0000 0002 Payment fails — exercise your failure path
UPI Test mode simulates the UPI collect/intent flow Approve in the simulated app to succeed
Netbanking Any test bank option Completes without real debit

Run the failure case deliberately. Your payment.failed webhook and your frontend's error state are part of the integration — an examiner will ask what happens when a payment fails, and "I never tried it" is a weak answer.

Refunds: the feature every store project needs

Refunds go through your server with the API secret — never from the browser:

curl -u "rzp_test_YOURKEY:YOURSECRET"   -X POST https://api.razorpay.com/v1/payments/pay_29QQoUBi66xm2f/refunds   -H "Content-Type: application/json"   -d '{"amount":49900,"notes":{"reason":"item out of stock"}}'

Partial refunds are supported (refund less than the captured amount), and each refund gets its own id for tracking. Your order state machine needs the states to match: paid → refund_initiated → refunded, with the refund.processed webhook as the confirmation. A freelance services marketplace that cannot refund a cancelled gig is not a marketplace, it is a liability.

Cards, EMI and tokenisation: where card numbers go

Card numbers never touch your server — and since the RBI's card-tokenisation mandate, they barely touch Razorpay's either. What actually happens: the buyer's card details go to the card network, which returns a token (a surrogate value usable only by that merchant), and Razorpay stores the token for repeat payments. Your project sees only the last four digits and the card brand for display.

Two consequences for your build: "save my card" is a checkbox that stores a token reference, not card data — implementing it is an API flag, not a security project. And EMI options (credit-card EMI, cardless EMI) appear in Checkout automatically for eligible amounts; your order code does not change, though your pricing page should note that EMI availability depends on the buyer's bank. If an examiner asks about PCI-DSS scope, the correct answer is that by using hosted Checkout and tokenisation, card data never enters your systems, which is precisely why the integration is shaped this way.

Failure handling: the diagnosis table

Symptom Likely cause Check
BAD_REQUEST_ERROR on order creation Amount not an integer, currency wrong, or auth header malformed Log the exact payload; amounts must be integer paise
Checkout opens but payment fails instantly Test card wrong, or live key used against test dashboard Confirm rzp_test_ key matches the dashboard mode
Signature mismatch on verify key_secret wrong, or body string built in the wrong order The signed string is exactly `order_id + "
Webhook never arrives URL not publicly reachable (localhost), or wrong events subscribed Expose via a tunnel for local testing; check dashboard webhook logs
Webhook signature fails Body was JSON-parsed before HMAC, changing whitespace Use the raw body bytes for the HMAC computation
Order marked paid twice Webhook retried, handler not idempotent Unique constraint on payment_id; markPaidIfPending semantics
GATEWAY_ERROR / SERVER_ERROR on capture Bank-side or Razorpay-side transient failure Retry with backoff; these are the errors webhooks exist to resolve

Going live: the checklist

  • Business KYC completed in the Razorpay dashboard (PAN, bank account for settlements, business proof) — live mode stays locked until this is done.
  • Live key pair generated (rzp_live_...); test keys removed from every production config and env file.
  • Webhook URL switched to the production HTTPS endpoint with a fresh webhook secret.
  • Settlement expectations set: domestic settlements typically land T+2 working days — your cash-flow slides in the report should reflect this, not instant availability.
  • Pricing confirmed: Razorpay's standard domestic pricing has historically been around 2% + GST per successful transaction — re-check the current pricing page before you put a number in your report, since it changes.
  • Refund and failure paths re-tested against live mode with a small real transaction (Rs 10–50), then refunded.
  • Secrets in environment variables or a secrets manager; key_secret appears nowhere in frontend code, git history, or screenshots in your report.

UPI deep-dive: collect, intent and QR

Razorpay's Checkout hides UPI's complexity, but your viva may not — so know the three UPI flows your buyers actually use:

  • Collect request — the buyer enters their UPI ID (something like aarav@okhdfc) in the Checkout popup; their UPI app gets a collect request to approve. Works on desktop and mobile browsers alike, and it is the flow most first-time testers try.
  • Intent — on a phone, Checkout can deep-link straight into the buyer's UPI app (GPay, PhonePe, Paytm) with the payment pre-filled; the buyer approves and returns. Fewer keystrokes, higher success rates on mobile — which is where most of your demo audience will pay from.
  • QR code — Checkout renders a QR the buyer scans with any UPI app. The reliable choice for desktop demos and for in-person project exhibitions, where you want the audience to pay from their own phones.

All three end the same way: Razorpay captures the payment and your server sees the same payment.captured webhook. Design your frontend for the buyer's device — intent-first on mobile, QR-visible on desktop — and your backend stays identical across all three.

The order state machine your database needs

A payment is not a boolean; it is a lifecycle. Model it explicitly or your reconciliation reports will lie:

State Meaning Allowed next states
created Order created on Razorpay, buyer hasn't paid attempted, expired
attempted Buyer opened Checkout / payment attempted paid, failed, created (retry)
paid payment.captured webhook verified refund_initiated
failed payment.failed webhook or Checkout error created (new attempt, new Razorpay order)
expired Order aged out (set a TTL, e.g. 30 minutes) created (fresh order)
refund_initiated → refunded Refund API called → refund.processed webhook terminal

Two rules keep this honest: a Razorpay order that has seen a failed attempt should generally be replaced with a fresh order on retry (amounts and receipts stay clean), and every transition is written by a webhook handler or a verified callback — never by frontend JavaScript. When your report shows "order funnel: created → paid conversion", this table is the data behind it.

Razorpay vs the alternatives: a decision table

Gateway Strengths Watch-outs Pick it when
Razorpay UPI + cards + netbanking + wallets in one integration; mature test mode; strong docs Pricing per transaction; KYC needed for live Default choice for Indian student e-commerce/marketplace projects
Cashfree Competitive UPI handling; payouts API popular for marketplaces Smaller community; fewer tutorials to copy from when stuck You need split payouts to vendors (marketplace disbursement)
Stripe Excellent international cards; superb docs Weak UPI story; needs international business setup for INR settlement Your buyers are outside India
Paytm Payment Gateway Brand familiarity; wallet user base Integration docs and dashboard lag the others Your project specifically targets Paytm wallet users

For a final-year project demoed in India, Razorpay's combination of UPI-first flows and a test mode that exercises the whole lifecycle is why it is the recommended default — but the comparison above is what turns "we used Razorpay" from a default into a decision in your report.

Settlements and reconciliation: following the money

Captured money does not teleport to your bank account. Razorpay batches settlements — typically T+2 working days for domestic transactions — and your project should account for the lag:

  1. Match every settlement to orders. The dashboard's settlement reports list which payments each payout covers. Keep a settlements table (settlement id, amount paise, date, status) and link payments to it — this is the "accounts" section of your report and it impresses precisely because most student projects omit it.
  2. Handle the mismatch cases. A payment captured but never settled (bank holiday, account verification pending) shows as receivable, not revenue. A refund issued after settlement creates a debit in the next cycle. Your state machine plus the settlement link is what lets you answer "where is the money right now" for any order.
  3. Reconcile on a schedule. A daily job (or a manual dashboard check during the project) that flags paid orders with no matching settlement after 5 working days catches integration and account problems early — long before your demo.

Security checklist for your project report

  • Order amounts computed server-side from the database; the client never sends a price.
  • Payment signature verified on every success callback before any fulfilment.
  • Webhook signatures verified with the webhook secret over raw request bytes.
  • Webhook handler idempotent; orders transition created → paid exactly once.
  • Captured amount compared against the stored order amount before fulfilment.
  • key_secret server-side only; test keys never shipped to production.
  • Refunds issued server-side with recorded reasons and webhook confirmation.

Where payments fit in student projects

Payments turn a catalogue into a business: an e-commerce website with payment gateway is the canonical Razorpay project; a freelance services marketplace adds escrow-style holds and milestone releases on top of the same primitives; an online grocery store with delivery slots combines slot booking with prepaid UPI checkout. More web-build concepts live in the Computer / IT branch hub.

Putting it together

The integration, reduced to its load-bearing walls: the server prices the order, Razorpay moves the money, the signature proves the money moved, and the webhook — verified and idempotent — is what your system believes. Test mode makes every mistake free; the checklists above make sure you make them all there. Build it once end-to-end and "how do payments work in your project" becomes a walkthrough, not a question.

More project guides

More in Computer / IT

JWT Authentication for Students: Tokens, Signatures, Refresh Flows and a Node.js Implementation

How does JWT login actually work? When a user logs in, the server issues a signed token in three parts — header, payload, signature. The client sends it back as an Authorization: Bearer header, and the server verifies the signature instead of looking up a session. This guide decodes a real token by hand, walks through the full login and refresh flow, and builds a working Node.js implementation with bcrypt password hashing, token rotation, and storage rules that survive a viva.

Read guide

SQL vs NoSQL for Final-Year Projects: Which Database Should You Pick?

MySQL or MongoDB for your final-year project? SQL databases store data in related tables with enforced schemas, joins and transactions — the right default when your data is structured and money or records must stay consistent. NoSQL document stores trade the rigid schema for flexible, nested documents that ship faster when your data shape keeps changing. This guide compares them with a worked hospital-appointment example in both, a decision table, and rules matched to common project archetypes.

Read guide

Docker for Student Projects: Images, Containers and Compose from Zero

End ‘it works on my machine’ failures: learn what Docker images and containers actually are, write lean Dockerfiles that exploit layer caching, persist data with volumes, orchestrate app-plus-database with Compose, and package an evaluator-proof submission — with the debugging table for every error you will definitely meet.

Read guide