Skip to main content

UPI Native Link Handling for Custom Checkout

When processing INR payments through ZenPays, the confirm API returns a redirectUrl pointing to a TSP payment page that displays a QR code. The problem: on mobile, users can't scan a QR code shown on the same device.

This guide shows you how to extract the upi:// deep link from that QR code so you can:

  • Mobile: Open the customer's UPI app directly (GPay, PhonePe, Paytm)
  • Desktop: Show a clean QR code + copy button on your own page

How It Works

1. Confirm payment  -->  Get redirectUrl (TSP payment page with QR)
2. Extract upi:// link from the QR code (3 approaches below)
3. Mobile: window.location.href = upiLink --> UPI app opens
Desktop: Show your own QR + "Copy UPI Link" button

The upi:// deep link looks like:

upi://pay?pa=merchant@bank&pn=MerchantName&am=500.00&cu=INR&tr=txn123
ParameterDescription
paPayee VPA (UPI ID)
pnPayee name
amAmount
cuCurrency (always INR)
trTransaction reference

Approach 1: Poll ZenPays API (Easiest)

ZenPays extracts the QR code server-side for you. Just poll the endpoint after confirming payment.

Endpoint

GET /payment/api/v1/payment-intent/{intentId}/upi-link?cashierOrderNo={orderNo}&payPageUrl={redirectUrl}
ParameterRequiredDescription
intentIdYesThe payment intent ID
payPageUrlYesThe redirectUrl from the confirm response (URL-encoded)
cashierOrderNoNoThe orderNo query param from the redirectUrl (for faster lookup)

Response

{
"success": true,
"data": {
"upiDeepLink": "upi://pay?pa=merchant@bank&pn=Store&am=499.25&cu=INR&tr=abc123"
}
}

Code Examples

async function pollForUpiLink(intentId, redirectUrl, maxAttempts = 15) {
const baseUrl = 'https://api.zenpayz.com';

// Extract cashierOrderNo from the redirectUrl for faster lookup
let cashierOrderNo = '';
try {
cashierOrderNo = new URL(redirectUrl).searchParams.get('orderNo') || '';
} catch {}

for (let i = 0; i < maxAttempts; i++) {
try {
const params = new URLSearchParams();
if (cashierOrderNo) params.set('cashierOrderNo', cashierOrderNo);
params.set('payPageUrl', redirectUrl);

const res = await fetch(
`${baseUrl}/payment/api/v1/payment-intent/${intentId}/upi-link?${params}`
);
const data = await res.json();

if (data.success && data.data?.upiDeepLink) {
return data.data.upiDeepLink;
}
} catch (err) {
// Retry on network error
}

// Wait 4 seconds before next attempt
await new Promise(r => setTimeout(r, 4000));
}

return null; // Extraction failed — fall back to showing the iframe
}

// Usage after confirm:
const upiLink = await pollForUpiLink(intentId, result.data.redirectUrl);
const isMobile = /Mobi|Android|iPhone/i.test(navigator.userAgent);

if (upiLink) {
if (isMobile && upiLink.startsWith('upi://')) {
// Mobile: open UPI app with 600ms delay (lets UI update first)
setTimeout(() => { window.location.href = upiLink; }, 600);
} else {
// Desktop: show copy button + your own QR code
showUpiLinkUI(upiLink);
}
} else {
// Fallback: show the TSP page in iframe or redirect
window.open(result.data.redirectUrl, '_blank');
}

Open the redirectUrl in a headless browser (Playwright/Puppeteer), find the QR code image, decode it, and return the upi:// link. This gives you full control and works with any TSP payment page.

Why This Works

The TSP payment page (redirectUrl) returns 403 for raw HTTP fetches (bot protection), but loads fine in a real browser. Playwright/Puppeteer acts as a real browser, so the page renders normally including the QR code.

caution

Some cashier pages are behind Cloudflare and will block data center IPs. If your server is on AWS/Azure/GCP, you may need a residential proxy. This works out of the box from residential IPs (home machines, non-datacenter VPS).

Code Examples

Install:

pip install playwright pyzbar Pillow httpx
playwright install chromium
# On Linux: apt-get install -y libzbar0

This is the exact production code used by ZenPays:

import asyncio
import base64
import time
from io import BytesIO
from typing import Optional

from pyzbar.pyzbar import decode as decode_qr
from PIL import Image


def _try_decode_qr(image_bytes: BytesIO) -> Optional[str]:
"""Try to decode a UPI link from image bytes."""
try:
pil_image = Image.open(image_bytes)
decoded_list = decode_qr(pil_image)
for decoded in decoded_list:
data = decoded.data.decode("utf-8").strip()
if data.startswith("upi://") or "pa=" in data:
return data
except Exception:
pass
return None


async def _scan_page_for_qr(page) -> Optional[str]:
"""Scan all images on page for a UPI QR code."""
import httpx

image_sources = await page.evaluate("""() => {
const results = [];
document.querySelectorAll('img').forEach(img => {
const src = img.src || img.getAttribute('src');
if (src && (img.naturalWidth > 40 || src.startsWith('data:image'))) {
results.push({ src, w: img.naturalWidth, h: img.naturalHeight });
}
});
results.sort((a, b) => {
const aBase64 = a.src.startsWith('data:') ? 1 : 0;
const bBase64 = b.src.startsWith('data:') ? 1 : 0;
if (aBase64 !== bBase64) return bBase64 - aBase64;
return (b.w * b.h) - (a.w * a.h);
});
return results.map(r => r.src);
}""")

for src in image_sources[:8]:
try:
qr_buffer = None
if src.startswith("data:image"):
b64_data = src.split(",", 1)[1]
qr_buffer = BytesIO(base64.b64decode(b64_data))
elif src.startswith("http"):
async with httpx.AsyncClient() as client:
resp = await client.get(src, timeout=5)
qr_buffer = BytesIO(resp.content)
if not qr_buffer:
continue

upi_link = _try_decode_qr(qr_buffer)
if upi_link:
return upi_link
except Exception:
continue
return None


async def extract_upi_link(redirect_url: str) -> Optional[str]:
"""
Open the payment page in headless Chrome, find the QR code,
decode it, and return the upi:// deep link.
"""
from playwright.async_api import async_playwright

async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=[
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-blink-features=AutomationControlled",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-web-security",
"--disable-features=IsolateOrigins,site-per-process",
],
)
context = await browser.new_context(
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/134.0.0.0 Safari/537.36"
),
viewport={"width": 430, "height": 932},
)
page = await context.new_page()

try:
# Try networkidle first, fall back to domcontentloaded
try:
await page.goto(redirect_url, wait_until="networkidle", timeout=20000)
except Exception:
try:
await page.goto(redirect_url, wait_until="domcontentloaded", timeout=15000)
except Exception:
pass

# Wait for page JS to fully render (cashier pages load content async)
await asyncio.sleep(5)

# Some payment pages hide QR behind a "Show QR code" button
clicked_show_qr = False
show_qr_selectors = [
"text=Show QR code",
"text=Show QR Code",
"text=Show QR",
"text=show qr code",
"text=show qr",
"button:has-text('Show QR')",
"a:has-text('Show QR')",
"div:has-text('Show QR'):not(:has(div:has-text('Show QR')))",
"[class*='show'] >> text=QR",
]
for sel in show_qr_selectors:
try:
btn = await page.wait_for_selector(sel, timeout=1000)
if btn and await btn.is_visible():
await btn.click()
clicked_show_qr = True
await asyncio.sleep(2)
break
except Exception:
continue

# Fallback: try get_by_text for more flexible matching
if not clicked_show_qr:
try:
locator = page.get_by_text("Show QR", exact=False)
if await locator.count() > 0:
await locator.first.click()
clicked_show_qr = True
await asyncio.sleep(2)
except Exception:
pass

# Remove any CSS blur/overlay hiding the QR
await page.evaluate("""() => {
document.querySelectorAll('img, [class*="qr"], [class*="blur"]').forEach(el => {
el.style.filter = 'none';
el.style.opacity = '1';
el.style.visibility = 'visible';
});
document.querySelectorAll('[class*="overlay"], [class*="mask"]').forEach(el => {
el.style.display = 'none';
});
}""")

# Retry loop — some cashiers load QR asynchronously after button click
max_attempts = 8 if clicked_show_qr else 5
for attempt in range(max_attempts):
upi_link = await _scan_page_for_qr(page)
if upi_link:
return upi_link
if attempt < max_attempts - 1:
await asyncio.sleep(2)

# Final fallback: screenshot entire page and decode
screenshot = await page.screenshot(full_page=True, type="png")
return _try_decode_qr(BytesIO(screenshot))

finally:
await browser.close()

# Usage:
# upi_link = asyncio.run(extract_upi_link("https://cashier.ppco.lol?orderNo=C123"))

Approach 3: Frontend Screen Capture (Desktop Chrome Only)

Use Chrome's getDisplayMedia() + BarcodeDetector API to capture the QR code from the screen. This is instant but only works on desktop Chrome.

caution

This approach requires the QR code to be visible on screen when the user clicks "Allow". It only works in desktop Chrome (not mobile, not Safari/Firefox).

async function scanQRFromScreen() {
// BarcodeDetector is only available in Chrome
const BarcodeDetectorClass = window.BarcodeDetector;
if (!BarcodeDetectorClass) {
console.log('BarcodeDetector not supported — use Chrome');
return null;
}

try {
// Request screen capture (shows browser permission dialog)
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' },
preferCurrentTab: true,
audio: false,
});

// Grab one frame
const track = stream.getVideoTracks()[0];
const imageCapture = new ImageCapture(track);
const bitmap = await imageCapture.grabFrame();
track.stop(); // Stop screen sharing immediately

// Detect QR codes
const detector = new BarcodeDetectorClass({ formats: ['qr_code'] });
const barcodes = await detector.detect(bitmap);

for (const barcode of barcodes) {
const decoded = barcode.rawValue?.trim();
if (decoded?.startsWith('upi://') || decoded?.includes('pa=')) {
return decoded;
}
}

return null;
} catch {
return null; // User cancelled or not supported
}
}

// Usage: Show the TSP page in an iframe first, then:
const upiLink = await scanQRFromScreen();
if (upiLink) {
const isMobile = /Mobi|Android|iPhone/i.test(navigator.userAgent);
if (isMobile) {
setTimeout(() => { window.location.href = upiLink; }, 600);
} else {
// Desktop: show copy buttons (don't redirect — upi:// triggers wrong apps on desktop)
showUpiLinkUI(upiLink);
}
}

Once you have the upi:// link, handle it differently based on the device:

function handleUpiLink(upiLink) {
const isMobile = /Mobi|Android|iPhone/i.test(navigator.userAgent);

if (isMobile) {
// Mobile: redirect to UPI app with brief delay
// The 600ms delay lets any UI transition complete first
setTimeout(() => {
window.location.href = upiLink;
// This opens GPay, PhonePe, Paytm, etc.
}, 600);
} else {
// Desktop: DON'T redirect (upi:// triggers wrong protocol handlers)
// Instead, show your own QR code + copy buttons
showDesktopUpiUI(upiLink);
}
}

function showDesktopUpiUI(upiLink) {
// Parse UPI params for display
const params = new URLSearchParams(upiLink.split('?')[1]);
const vpa = params.get('pa');
const amount = params.get('am');

document.getElementById('upi-container').innerHTML = `
<div style="text-align: center; padding: 20px;">
<p>Scan with any UPI app to pay</p>
<div id="qr-code"></div>
<p style="margin-top: 10px;">
<strong>UPI ID:</strong> ${vpa}
</p>
<button onclick="navigator.clipboard.writeText('${upiLink}')">
Copy UPI Payment Link
</button>
<button onclick="navigator.clipboard.writeText('${vpa}')">
Copy UPI ID
</button>
</div>
`;

// Render QR code using your preferred library (qrcode.react, qrcode, etc.)
new QRCode(document.getElementById('qr-code'), upiLink);
}

Customer clicks "Pay" on your checkout page
|
v
Confirm payment via API --> get redirectUrl
|
v
Start TWO things in parallel:
|
|--- 1. Poll ZenPays /upi-link API every 4 seconds
| (ZenPays extracts QR server-side via headless browser)
|
|--- 2. Show TSP payment page in iframe
| (customer can scan QR with another phone as fallback)
|
v
Whichever returns the upi:// link first:
|
|--- Mobile: setTimeout(() => window.location.href = upiLink, 600)
|--- Desktop: Show your own QR + "Copy UPI Link" button
|
v
Payment completes --> Webhook received

Full Example: Dual-Race Implementation

async function handleINRPayment(intentId, redirectUrl) {
const isMobile = /Mobi|Android|iPhone/i.test(navigator.userAgent);

// Extract cashierOrderNo from the redirectUrl
let cashierOrderNo = '';
try {
cashierOrderNo = new URL(redirectUrl).searchParams.get('orderNo') || '';
} catch {}

// Show iframe with TSP payment page (fallback — user can scan QR manually)
const iframe = document.createElement('iframe');
iframe.src = redirectUrl;
iframe.style.cssText = 'width:100%;height:500px;border:none;';
document.getElementById('payment-container').appendChild(iframe);

// Poll ZenPays API for UPI link (runs in parallel)
let found = false;
for (let i = 0; i < 15 && !found; i++) {
try {
const params = new URLSearchParams();
if (cashierOrderNo) params.set('cashierOrderNo', cashierOrderNo);
params.set('payPageUrl', redirectUrl);

const res = await fetch(
`https://api.zenpayz.com/payment/api/v1/payment-intent/${intentId}/upi-link?${params}`
);
const data = await res.json();

if (data.success && data.data?.upiDeepLink) {
found = true;
const link = data.data.upiDeepLink;

if (isMobile && link.startsWith('upi://')) {
// Mobile: open UPI app with brief delay
setTimeout(() => { window.location.href = link; }, 600);
} else {
// Desktop: show copy buttons + your own QR
showDesktopUpiUI(link);
}
return;
}
} catch {}

await new Promise(r => setTimeout(r, 4000));
}

// If polling fails, iframe is still showing — user can scan QR manually
console.log('UPI extraction timed out. User can scan QR from iframe.');
}

Summary

ApproachBest ForSpeedComplexity
Poll ZenPays APIAll backends5-25 secLow (just HTTP calls)
Headless BrowserFull control needed5-15 secMedium (Playwright/Puppeteer)
Screen CaptureDesktop Chrome usersInstantLow (browser API, Chrome only)
Recommended

Use the ZenPays polling API as primary, show the iframe as fallback. For faster extraction, run your own headless browser service.


Notes

  • UPI deep links work only for INR payments. Non-INR currencies use standard redirects.
  • The redirectUrl expires after the payment timer runs out (usually 8-10 minutes). Extract quickly.
  • Some TSP payment pages hide the QR behind a "Show QR code" button — the headless browser approach handles this by trying multiple selectors and clicking the button automatically.
  • Some cashier pages are behind Cloudflare and will block data center IPs. Use a residential proxy or the ZenPays polling API if your server is on a cloud provider.
  • On desktop, do not redirect to upi:// links — it triggers wrong protocol handlers (e.g. WhatsApp). Instead, show copy buttons and your own QR code.
  • Payment detection (webhook) works regardless of how the customer pays — extracted link, scanned QR, or TSP page directly.
  • The poll uses a 600ms delay before mobile redirect to allow UI transitions to complete smoothly.