UPI Payment Integration for Indian Businesses — PhonePe, Paytm, Razorpay UPI Intent
The three UPI integration paths
| Path | Setup time | Fees | Reconciliation | Best for |
|---|---|---|---|---|
| Razorpay | 1-2 days | 2% + GST | Automatic webhook | Most businesses |
| Paytm Business | 2-3 days | 1.99%+ | Automatic webhook | Paytm-strong audience |
| PhonePe Business | 3-5 days | Varies | Automatic | Large B2B volumes |
| Static UPI link (upi://) | 5 minutes | 0% | Manual (from bank statement) | Low volume, freelancers, donations |
Path 1 — Razorpay (recommended for most)
Razorpay is the standard for Indian SaaS. Full UPI support including UPI Intent (tap the app icon), collect requests, and autopay for subscriptions.
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)
composer require razorpay/razorpay
```
Step 2 — Create an order (server-side)
PHP:
use RazorpayApiApi;
$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:
$attributes = [
'razorpay_order_id' => $_POST['razorpay_order_id'],
'razorpay_payment_id' => $_POST['razorpay_payment_id'],
'razorpay_signature' => $_POST['razorpay_signature'],
];
try {
$api->utility->verifyPaymentSignature($attributes);
// Signature valid — mark order paid in DB
markOrderPaid($attributes['razorpay_order_id'], $attributes['razorpay_payment_id']);
} catch (RazorpayApiErrorsSignatureVerificationError $e) {
http_response_code(400);
echo 'Invalid signature';
}Node.js:
const crypto = require('crypto');
app.post('/api/verify', (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');
if (expected !== razorpay_signature) {
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 (expected !== 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 has deeper penetration in Tier-2/3 cities. Their SDK is similar to Razorpay; refer to business.paytm.com docs. Fees: 1.99% on UPI, slightly cheaper than Razorpay for high volumes.
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:
- Use ICICI/HDFC/Kotak collect-payment APIs (instant SMS → parse reference)
- Or a lightweight service like Decentro / Setu — webhook on every credit to your UPI ID
Subscriptions (recurring billing)
Razorpay + UPI AutoPay supports mandates up to ₹15,000/day (NPCI limit).
// 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
});UPI refunds hit the customer's bank in 3-5 days typically.
Common pitfalls
FAQ
For UPI specifically: Paytm Business (1.99%) < Razorpay (2%) < Stripe (doesn't support UPI directly). For cards: all are similar (~2%).
Only banks can be direct NPCI participants. Even Flipkart / Amazon use gateways. Accept the 2%.
Yes — both server-side SDK (PHP/Node.js) and webhook endpoints work on shared cPanel/DirectAdmin/Plesk with SSL.
Razorpay handles Indian cards + UPI. Stripe handles international cards better. Many Indian SaaS companies use Razorpay (INR) + Stripe (USD) together.
Server-side rzp.payments.refund(id) — always initiated by you, not by customer app. UPI refund reaches their bank within 3-5 working days.
UPI payments on a DomainIndia-hosted website — webhook-ready in under a day. See our web hosting plans