Third-Party API Integrations

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

By Domain India Team · DomainIndia EngineeringPublished 8 min read
Knowledge base article
Contents (14 sections)

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.

Key takeaways

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

PathTypical setupFeesReconciliationBest for
Razorpay1-2 daysGateway fee, see provider pricingAutomatic webhookMost businesses
Paytm Business2-3 daysGateway fee, see provider pricingAutomatic webhookPaytm-strong audience
PhonePe Business3-5 daysGateway fee, see provider pricingAutomaticLarge B2B volumes
Static UPI link (upi://)5 minutesNo gateway feeManual (from bank statement)Low volume, freelancers, donations

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

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

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:

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

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

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 (!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.

For low-volume, personal, or donation use cases. No gateway fees, but manual reconciliation.

Create a link that opens any UPI app:

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

  • 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.

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
});

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

Amount mismatch
Customer pays ₹500, your server expected ₹550. Always cross-check amount on verify + webhook against DB.
Signature verification with Express body parsing
Need express.raw for webhook endpoint, else body is already parsed and signature fails.
Using test key on prod
Costs nothing but no real money. Hard-fail check on key prefix in prod env.
No idempotency on webhook
Retries duplicate orders. Store processed payment_id and skip if seen.
Webhook URL not HTTPS
Payment data should only travel over HTTPS. Free SSL is included with almost all Domain India hosting.
Forgot UPI ID in test mode
Razorpay test UPI is 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.

Host your payment-ready website

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

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app
UPI Payment Integration: Razorpay, Paytm, PhonePe, UPI Links