UPI is the payment method most Indian customers reach for first, so an Indian website that takes money online should accept it. This guide shows how to add UPI with a payment gateway or with a simple UPI link, with working PHP and Node.js code.
UPI is India's dominant digital payment method. Most Indian websites should accept UPI. This guide covers the three practical integration paths: Razorpay (fastest to ship, covers all UPI apps), Paytm Business (good for Paytm-heavy audiences), and static UPI Intent links (no gateway fee, but manual reconciliation).
The three UPI integration paths
| Path | Typical setup | Fees | Reconciliation | Best for |
|---|---|---|---|---|
| Razorpay | 1-2 days | Gateway fee, see provider pricing | Automatic webhook | Most businesses |
| Paytm Business | 2-3 days | Gateway fee, see provider pricing | Automatic webhook | Paytm-strong audience |
| PhonePe Business | 3-5 days | Gateway fee, see provider pricing | Automatic | Large B2B volumes |
| Static UPI link (upi://) | 5 minutes | No gateway fee | Manual (from bank statement) | Low volume, freelancers, donations |
Path 1 — Razorpay (recommended for most)
Razorpay is widely used by Indian online businesses. It supports UPI fully, including UPI Intent (tap the app icon), collect requests and AutoPay for subscriptions. Menu names below match Razorpay's dashboard at the time of writing; check their documentation if they have moved.
Step 1 — Setup
- Sign up at razorpay.com (KYC: PAN, GST, bank statement)
- Dashboard → Settings → API Keys → Generate Test Key (start here) and Live Key (after KYC)
- Note:
rzp_live_XXXandsecret_XXX - Install SDK:
bash # Node.js npm install razorpay # PHP (Composer): run this on your own computer or in CI, then # upload the project with its vendor/ folder, or run php composer.phar over jailed SSH. composer require razorpay/razorpay
Step 2 — Create an order (server-side)
PHP:
use Razorpay\Api\Api;
$api = new Api(getenv('RAZORPAY_KEY_ID'), getenv('RAZORPAY_KEY_SECRET'));
$order = $api->order->create([
'receipt' => 'order_' . time(),
'amount' => 50000, // ₹500 in paise
'currency' => 'INR',
'notes' => ['user_id' => 42],
]);
echo json_encode([
'orderId' => $order['id'],
'key' => getenv('RAZORPAY_KEY_ID'),
]);Node.js:
const Razorpay = require('razorpay');
const rzp = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID,
key_secret: process.env.RAZORPAY_KEY_SECRET,
});
app.post('/api/order', async (req, res) => {
const order = await rzp.orders.create({
amount: 50000, // paise
currency: 'INR',
receipt: `order_${Date.now()}`,
notes: { userId: req.user.id },
});
res.json({ orderId: order.id, key: process.env.RAZORPAY_KEY_ID });
});Step 3 — Client-side checkout (web)
<script src="https://checkout.razorpay.com/v1/checkout.js"></script>
<button onclick="pay()">Pay ₹500</button>
<script>
async function pay() {
const { orderId, key } = await (await fetch('/api/order', { method: 'POST' })).json();
new Razorpay({
key,
order_id: orderId,
amount: 50000,
currency: 'INR',
name: 'Your Company',
description: 'Order #1234',
prefill: {
name: 'Rajesh Kumar',
email: '[email protected]',
contact: '9876543210',
},
method: {
upi: true, // show UPI prominently
card: true,
netbanking: true,
wallet: true,
},
handler: async (response) => {
// response: { razorpay_payment_id, razorpay_order_id, razorpay_signature }
const verified = await fetch('/api/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(response),
});
if (verified.ok) window.location = '/success';
else alert('Payment verification failed');
},
theme: { color: '#0f172a' },
}).open();
}
</script>Step 4 — Verify signature server-side
Critical: never trust client-provided payment_id. Always verify signature.
PHP:
// The checkout handler above sends JSON, so read the raw body
$in = json_decode(file_get_contents('php://input'), true) ?? [];
$attributes = [
'razorpay_order_id' => $in['razorpay_order_id'] ?? '',
'razorpay_payment_id' => $in['razorpay_payment_id'] ?? '',
'razorpay_signature' => $in['razorpay_signature'] ?? '',
];
try {
$api->utility->verifyPaymentSignature($attributes);
// Signature valid — mark order paid in DB
markOrderPaid($attributes['razorpay_order_id'], $attributes['razorpay_payment_id']);
} catch (Razorpay\Api\Errors\SignatureVerificationError $e) {
http_response_code(400);
echo 'Invalid signature';
}Node.js:
const crypto = require('crypto');
app.post('/api/verify', async (req, res) => {
const { razorpay_order_id, razorpay_payment_id, razorpay_signature } = req.body;
const expected = crypto
.createHmac('sha256', process.env.RAZORPAY_KEY_SECRET)
.update(`${razorpay_order_id}|${razorpay_payment_id}`)
.digest('hex');
const ok = expected.length === razorpay_signature?.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(razorpay_signature));
if (!ok) {
return res.status(400).send('Invalid signature');
}
// Mark paid
await db.order.update({ where: { id: razorpay_order_id }, data: { status: 'paid' } });
res.json({ ok: true });
});Step 5 — Webhook (the reliable path)
The handler above works for most payments, but browser can close mid-payment. Always configure webhook as the source of truth.
Razorpay dashboard → Settings → Webhooks → Add:
- URL:
https://yourcompany.com/webhook/razorpay - Events:
payment.captured,payment.failed - Secret: generate random 32-char string
Handler (Node.js):
app.post('/webhook/razorpay',
express.raw({ type: 'application/json' }), // must be raw for signature
async (req, res) => {
const sig = req.headers['x-razorpay-signature'];
const expected = crypto
.createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!sig || sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(400).send('Invalid');
}
const event = JSON.parse(req.body);
if (event.event === 'payment.captured') {
const p = event.payload.payment.entity;
await markOrderPaid(p.order_id, p.id, p.amount);
}
res.json({ ok: true });
}
);Webhooks are the canonical source of truth — handle even if the browser never comes back.
Path 2 — Paytm Business
Paytm is strong with audiences that already use the Paytm app. Its payment gateway flow (create an order server-side, open checkout, verify, then confirm with a webhook) is similar to Razorpay's; refer to business.paytm.com docs. Check Paytm's current pricing page for fees; they change and differ by plan.
Path 3 — Static UPI Intent link (zero-fee option)
For low-volume, personal, or donation use cases. No gateway fees, but manual reconciliation.
Create a link that opens any UPI app:
upi://pay?pa=yourname@hdfcbank&pn=Your%20Name&am=500&cu=INR&tn=Invoice+1234Parameters:
pa= payee VPA (UPI ID)pn= payee name (URL-encoded)am= amount (optional; user can edit)cu= currency (INR)tn= note / reference
Generate QR code server-side:
import qrcode
data = "upi://pay?pa=yourname@hdfcbank&pn=Your%20Name&am=500&cu=INR&tn=Invoice+1234"
img = qrcode.make(data)
img.save('upi-qr.png')Mobile users tap the link, pick their UPI app, confirm. You reconcile from your bank statement — no automated webhook.
Verifying payments (manual)
For a business where UPI reconciliation matters:
- Ask your bank whether it offers a collections API or credit notifications for your business account.
- Or use a payments-infrastructure provider (for example Decentro or Setu) that can send a webhook for each credit to your UPI ID. Check their current products and pricing.
Subscriptions (recurring billing)
Razorpay supports UPI AutoPay mandates. NPCI caps how much each recurring debit can be without extra customer authentication, and the limit varies by category, so check the current NPCI and Razorpay limits for your use case.
// Create subscription
const sub = await rzp.subscriptions.create({
plan_id: 'plan_XXX',
customer_notify: 1,
total_count: 12, // monthly × 12
notes: { userId: 42 },
});Customer approves the UPI mandate once; subsequent charges auto-debit. Critical: handle subscription.charged webhook, grant/deny access based on status.
Refunds
Full refund:
await rzp.payments.refund(payment_id, {
speed: 'normal', // or 'optimum' for faster
});Partial refund:
await rzp.payments.refund(payment_id, {
amount: 20000, // refund ₹200 from ₹500 payment
});Refund timelines are set by the gateway and the banks; check Razorpay's documentation for the current figures before you promise a date to a customer.
Common pitfalls
express.raw for webhook endpoint, else body is already parsed and signature fails.payment_id and skip if seen.success@razorpay (sandbox success) or failure@razorpay (failure). Real UPIs don't work in test.FAQ
Which gateway is cheapest?
Gateway fees change often and differ by plan, payment method and volume, so compare the current pricing pages of Razorpay, Paytm Business and PhonePe Business before you sign up. A static UPI link has no gateway fee, but you reconcile payments by hand.
Can I skip gateways with a direct NPCI integration?
No. Direct UPI connectivity with NPCI is for banks and licensed payment apps working through a bank. A normal business collects UPI through a gateway or its bank, and the gateway's fee pays for automatic reconciliation.
Does this work on Domain India shared hosting?
Usually, with two caveats. Composer isn't pre-installed on shared hosting, so install the Razorpay PHP SDK with composer.phar over jailed SSH, or on your own computer and upload the vendor folder. The SDK calls the API with cURL: on cPanel single cURL requests work, but on most DirectAdmin sites curl_exec is disabled, so test an API call on your plan first. Webhook endpoints need HTTPS, and free SSL is included with hosting. A VPS or the App Platform avoids these limits.
Razorpay or Stripe for Indian customers with international cards?
Razorpay handles Indian cards + UPI. Stripe handles international cards better. Many Indian SaaS companies use Razorpay (INR) + Stripe (USD) together.
How do I refund if customer disputes on phone?
Issue it from your server with rzp.payments.refund(id) (or from the Razorpay dashboard). The refund is always started by you, not by the customer's app. The time it takes to reach the customer's bank is set by the gateway and banks; check Razorpay's current documentation.
Ready to take UPI payments on your own site? Compare cPanel hosting, DirectAdmin hosting and the App Platform, or open a support ticket if an API call fails on your plan.
Hosting with free SSL for HTTPS webhook endpoints, PHP and Node.js app support, and 24/7 live chat and tickets if an API call fails.
See hosting plans