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
- 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. - Your page opens Razorpay Checkout with that
order_id. The buyer pays by UPI, card, netbanking or wallet inside Razorpay's popup. - Razorpay returns a
razorpay_payment_id, theorder_id, and arazorpay_signatureto your page. - Your page forwards those three values to your server, which recomputes the HMAC-SHA256 signature with your secret key. Match = the payment is genuine.
- 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 publickey_idfor the frontend and akey_secretthat 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_secretappears 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:
- Match every settlement to orders. The dashboard's settlement reports list which payments each payout covers. Keep a
settlementstable (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. - 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.
- Reconcile on a schedule. A daily job (or a manual dashboard check during the project) that flags
paidorders 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 → paidexactly once. - Captured amount compared against the stored order amount before fulfilment.
-
key_secretserver-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.