# UPI Payment Integration for Indian Businesses — PhonePe, Paytm, Razorpay UPI Intent
TL;DR
UPI is India's dominant payment method — 40%+ of all digital payments. 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 (zero-fee but manual reconciliation).
## 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
1
Sign up at razorpay.com (KYC: PAN, GST, bank statement)
2
Dashboard → Settings → API Keys → Generate Test Key (start here) and Live Key (after KYC)
3
Note: rzp_live_XXX and secret_XXX
### Step 2 — Create an order (server-side)
**PHP:**
```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:**
```javascript
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)
```html
Pay ₹500
```
### Step 4 — Verify signature server-side
**Critical:** never trust client-provided `payment_id`. Always verify signature.
**PHP:**
```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:**
```javascript
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):**
```javascript
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](https://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+1234
```
Parameters:
- `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:
```python
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).
```javascript
// 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:**
```javascript
await rzp.payments.refund(payment_id, {
speed: 'normal', // or 'optimum' for faster
});
```
**Partial refund:**
```javascript
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
Q
Which gateway is cheapest?
For UPI specifically: Paytm Business (1.99%) < Razorpay (2%) < Stripe (doesn't support UPI directly). For cards: all are similar (~2%).
Q
Can I skip gateways with a direct NPCI integration?
Only banks can be direct NPCI participants. Even Flipkart / Amazon use gateways. Accept the 2%.
Q
Does this work on DomainIndia shared hosting?
Yes — both server-side SDK (PHP/Node.js) and webhook endpoints work on shared cPanel/DirectAdmin/Plesk with SSL.
Q
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.
Q
How do I refund if customer disputes on phone?
Server-side rzp.payments.refund(id) — always initiated by you, not by customer app. UPI refund reaches their bank within 3-5 working days.