Client Area

UPI Payment Integration for Indian Businesses — PhonePe, Paytm, Razorpay UPI Intent

ByDomain India Team·DomainIndia Engineering
6 min readPublished 21 Apr 2026Updated 23 Jun 2026810 views

In this article

  • 1The three UPI integration paths
  • 2Path 1 — Razorpay (recommended for most)
  • 3Step 1 — Setup
  • 4Step 2 — Create an order (server-side)
  • 5Step 3 — Client-side checkout (web)

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

PathSetup timeFeesReconciliationBest for
Razorpay1-2 days2% + GSTAutomatic webhookMost businesses
Paytm Business2-3 days1.99%+Automatic webhookPaytm-strong audience
PhonePe Business3-5 daysVariesAutomaticLarge B2B volumes
Static UPI link (upi://)5 minutes0%Manual (from bank statement)Low volume, freelancers, donations

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
  4. Install SDK:

```bash

# Node.js

npm install razorpay

# PHP (Composer)

composer require razorpay/razorpay

```

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
<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:

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 docs. Fees: 1.99% on UPI, slightly cheaper than Razorpay for high volumes.

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.

UPI payments on a DomainIndia-hosted website — webhook-ready in under a day. See our web hosting plans

Was this article helpful?

Your feedback helps us improve our documentation

Still need help? Submit a support ticket