How to Add UPI Payments to a Custom Website (Developer Walkthrough)

VT

VyaparGateway Team

Payments Editorial

11 min read
Updated
How to Add UPI Payments to a Custom Website (Developer Walkthrough)
#custom website upi #upi integration developer #react node upi #django upi

Adding UPI to a custom website — one not built on Shopify, WooCommerce, or another off-the-shelf platform — is a small, well-bounded engineering project. If you're a developer who knows your stack, you should be able to ship a production-ready UPI integration in a single afternoon. This walkthrough gives you the full architecture, the three API surfaces involved, the gotchas to avoid, and a deployment checklist for going live with real money.

The architecture in one paragraph

A UPI integration on a custom site has three components, each doing exactly one thing: a backend endpoint that creates payment intents (POST /api/checkout calls VyaparGateway's POST /v1/intents and returns the QR + intent ID to your frontend); a frontend that renders the QR and polls a status endpoint while the customer pays; and a webhook handler (POST /webhooks/vyapargateway) that verifies the gateway's signature and marks orders paid in your DB. That's the whole system — usually under 200 lines of code.

Architecture diagram concept
Three components: intent creation API, frontend QR + polling, webhook verification. Everything else is just business logic.

Step 1: Set up your environment

Before writing code:

  1. Sign up for VyaparGateway and complete KYB.
  2. Generate API keys for test mode (you'll switch to live keys at the end).
  3. Add VG_API_KEY and VG_WEBHOOK_SECRET to your environment variables (never commit them).
  4. Install the relevant SDK for your stack: vyapargateway-node, vyapargateway-python, vyapargateway-php, etc. (Or just use fetch/requests against the REST API.)

Step 2: Backend intent creation endpoint

Your frontend calls this when the customer clicks Pay. It creates a payment intent at the gateway and stores a record in your DB linking the gateway intent ID to your order:

// Node + Express + Prisma example: app.post('/api/checkout', async (req, res) => { const { orderId } = req.body; const order = await db.orders.findUnique({ where: { id: orderId } }); const intent = await vg.intents.create({ amount: order.totalPaise, currency: 'INR', order_id: order.id }); await db.payments.create({ data: { orderId, intentId: intent.id, status: 'pending' } }); res.json({ qr: intent.qr_payload, intentId: intent.id }); });

Step 3: Frontend QR rendering and status polling

On the checkout page, render the QR and start polling:

// React example: const { qr, intentId } = await fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ orderId }) }).then(r => r.json()); renderQR(qr); const poll = setInterval(async () => { const { status } = await fetch(`/api/intents/${intentId}/status`).then(r => r.json()); if (status === 'paid') { clearInterval(poll); window.location.href = `/orders/${orderId}/success`; } if (status === 'expired') { clearInterval(poll); showExpiredUI(); } }, 3000);

The status endpoint is a thin DB lookup — your webhook handler updates the payment record, and the polling endpoint just reads it.

Step 4: Webhook handler with signature verification

The webhook handler is the most security-sensitive code in your integration. It must verify the HMAC signature on the raw body before doing anything else:

// Node + Express example: app.post('/webhooks/vyapargateway', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.header('X-VyaparGateway-Signature'); const ts = req.header('X-VyaparGateway-Timestamp'); const expected = crypto.createHmac('sha256', process.env.VG_WEBHOOK_SECRET).update(req.body).digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.status(401).end(); if (Math.abs(Date.now()/1000 - Number(ts)) > 300) return res.status(401).end(); const event = JSON.parse(req.body); const payment = await db.payments.findUnique({ where: { intentId: event.data.intent_id } }); if (payment.status === 'paid') return res.status(200).end(); await db.$transaction([ db.payments.update({ where: { intentId: event.data.intent_id }, data: { status: 'paid', utr: event.data.utr } }), db.orders.update({ where: { id: payment.orderId }, data: { status: 'paid' } }) ]); await fulfilOrder(payment.orderId); res.status(200).end(); });

"Use express.raw() (not express.json()) for the webhook endpoint. The signature is computed over the exact request body bytes; if you parse-then-stringify, the bytes change and the signature won't match."

Step 5: Status endpoint for polling

Thin DB lookup, cacheable for ~1 second:

app.get('/api/intents/:id/status', async (req, res) => { const payment = await db.payments.findUnique({ where: { intentId: req.params.id }, select: { status: true, utr: true } }); res.json(payment); });

Step 6: Pre-launch checklist

Before pointing real customers at the integration:

  • Test mode: full flow works end-to-end with test credentials.
  • Signature verification: a tampered payload returns 401 (test by manually editing the body in a test request).
  • Timestamp check: a stale timestamp (older than 5 minutes) returns 401.
  • Idempotency: replaying the same webhook event doesn't re-fulfil the order.
  • Expiry handling: the frontend shows a sensible 'expired' state if the customer doesn't pay in time.
  • Error logging: webhook handler errors are captured in your error tracker with the event ID.
  • Refund flow: tested with a small live transaction and refund cycle.
  • Switched from test to live API keys + webhook secret in production.
  • Webhook URL in VyaparGateway dashboard points to your production endpoint.
Developer at workstation with multiple screens
The integration is a small project. The checklist is what separates a working integration from a production-ready one.

Common architectural decisions

A few choices that come up in custom integrations:

  • Sync vs async fulfilment — fulfil directly in the webhook handler (sync, simple) or enqueue a background job (async, scales better). Start sync; switch to async if your fulfilment is slow or flaky.
  • Polling vs WebSocket — polling at 3-second intervals is enough for almost all use cases. WebSockets only matter if you're running a flash-sale-style checkout where sub-second latency converts.
  • Server-side analytics — fire your GA4/Meta 'purchase' event from the webhook handler, not from the success page, so abandoned-tab payments still count.
  • Mobile UX — on mobile, prefer rendering an 'Open UPI app' intent URL button over a QR code (the customer can't scan their own phone screen).

Going live

Once your checklist is complete, switch API keys to live, do one small real transaction (₹1–₹10) yourself to verify the end-to-end flow with real money, and then announce UPI to your customers. Monitor your gateway dashboard for any webhook delivery failures during the first week — anything anomalous will show up there before customers notice. VyaparGateway helps you issue dynamic UPI QR codes, verify payments, and notify your stack via webhooks—without charging a per-transaction platform fee on top of your plan.

Share this guide

Frequently asked questions

How long does it take to integrate UPI on a custom website?
For a developer working in a modern stack (Node/Python/PHP), the basic integration is a single afternoon — about 100–200 lines of code split across an intent-creation endpoint, frontend polling, and a webhook handler with signature verification. Add another day for proper testing and a thorough pre-launch checklist before going live.
Do I need to use a specific framework or stack?
No. UPI gateways like VyaparGateway provide REST APIs that work with any backend language — Node, Python, Django, Flask, PHP, Laravel, Java, Go, .NET, Ruby on Rails. SDKs are available for the most popular stacks, but the underlying API is straightforward enough that you can use raw HTTP requests if no SDK exists for your stack.
Where should I host the webhook endpoint?
Wherever your backend runs — same server as your application, ideally. The endpoint needs to be publicly reachable over HTTPS (with a valid TLS certificate). Behind a load balancer is fine; behind basic auth is not (gateways can't authenticate to call your webhook).
What if I'm building a SPA without a server-side backend?
You'll need to add a thin server (even a single serverless function on Vercel, Netlify, or AWS Lambda is enough). UPI integration requires a server because API keys and webhook secrets must never live in client-side code — anyone could view-source and extract them. The smallest viable server is just two endpoints: intent creation and webhook handling.

Free tools for Indian merchants

No sign-up, no ads, no data selling

Use our free, browser-only tools whenever you need them. We don't store the values you enter or track you across the web.

See all free tools →
VT

About the Author

VyaparGateway Team

Payments Editorial

The VyaparGateway editorial team writes practical, India-first guides on UPI payments, merchant onboarding, and fintech compliance — informed by what we ship, debug, and operate every day at vyapargateway.com.

Related reading

Start with VyaparGateway

Create an account to connect your merchant profile, get API keys, and ship dynamic UPI checkout in minutes.