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
| Parameter | Description |
|---|---|
pa | Payee VPA (UPI ID) |
pn | Payee name |
am | Amount |
cu | Currency (always INR) |
tr | Transaction 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}
| Parameter | Required | Description |
|---|---|---|
intentId | Yes | The payment intent ID |
payPageUrl | Yes | The redirectUrl from the confirm response (URL-encoded) |
cashierOrderNo | No | The 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
- JavaScript
- Python
- PHP
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');
}
import requests
import time
from urllib.parse import urlparse, parse_qs
def poll_for_upi_link(intent_id, redirect_url, max_attempts=15):
base_url = "https://api.zenpayz.com"
# Extract cashierOrderNo from the redirectUrl
cashier_order_no = ""
try:
parsed = urlparse(redirect_url)
cashier_order_no = parse_qs(parsed.query).get("orderNo", [""])[0]
except Exception:
pass
for attempt in range(max_attempts):
try:
params = {"payPageUrl": redirect_url}
if cashier_order_no:
params["cashierOrderNo"] = cashier_order_no
resp = requests.get(
f"{base_url}/payment/api/v1/payment-intent/{intent_id}/upi-link",
params=params,
timeout=30
)
data = resp.json()
if data.get("success") and data.get("data", {}).get("upiDeepLink"):
return data["data"]["upiDeepLink"]
except Exception:
pass
time.sleep(4)
return None
# Usage:
upi_link = poll_for_upi_link("pi_12345", "https://cashier.ppco.lol?orderNo=C123")
if upi_link:
print(f"UPI Link: {upi_link}")
# Return to your frontend for redirect
function pollForUpiLink($intentId, $redirectUrl, $maxAttempts = 15) {
$baseUrl = "https://api.zenpayz.com";
// Extract cashierOrderNo from redirectUrl
$cashierOrderNo = '';
$parsed = parse_url($redirectUrl);
if (isset($parsed['query'])) {
parse_str($parsed['query'], $queryParams);
$cashierOrderNo = $queryParams['orderNo'] ?? '';
}
for ($i = 0; $i < $maxAttempts; $i++) {
try {
$params = ['payPageUrl' => $redirectUrl];
if ($cashierOrderNo) {
$params['cashierOrderNo'] = $cashierOrderNo;
}
$query = http_build_query($params);
$url = "{$baseUrl}/payment/api/v1/payment-intent/{$intentId}/upi-link?{$query}";
$response = file_get_contents($url);
$data = json_decode($response, true);
if ($data['success'] && !empty($data['data']['upiDeepLink'])) {
return $data['data']['upiDeepLink'];
}
} catch (Exception $e) {
// Retry
}
sleep(4);
}
return null;
}
// Usage:
$upiLink = pollForUpiLink("pi_12345", "https://cashier.ppco.lol?orderNo=C123");
if ($upiLink) {
echo json_encode(["upiLink" => $upiLink]);
}
Approach 2: Server-Side QR Extraction with Headless Browser (Recommended for Speed)
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.
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
- Python (Playwright + pyzbar)
- JavaScript (Puppeteer + jsQR)
- PHP
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"))
Install:
npm install puppeteer jsqr pngjs
const puppeteer = require('puppeteer');
const jsQR = require('jsqr');
const { PNG } = require('pngjs');
async function extractUpiLink(redirectUrl) {
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
],
});
const page = await browser.newPage();
await page.setViewport({ width: 430, height: 932, deviceScaleFactor: 2 });
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/134.0.0.0 Safari/537.36'
);
try {
// Try networkidle, fall back to domcontentloaded
try {
await page.goto(redirectUrl, { waitUntil: 'networkidle0', timeout: 20000 });
} catch {
await page.goto(redirectUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
}
await new Promise(r => setTimeout(r, 5000));
// Click "Show QR" button if present
try {
const btn = await page.waitForSelector('text/Show QR', { timeout: 2000 });
if (btn) {
await btn.click();
await new Promise(r => setTimeout(r, 3000));
}
} catch {}
// Remove CSS blur
await page.evaluate(() => {
document.querySelectorAll('img, [class*="qr"], [class*="blur"]').forEach(el => {
el.style.filter = 'none';
el.style.opacity = '1';
el.style.visibility = 'visible';
});
});
// Retry loop — scan for QR multiple times
for (let attempt = 0; attempt < 5; attempt++) {
const screenshotBuffer = await page.screenshot({ type: 'png', fullPage: true });
const png = PNG.sync.read(screenshotBuffer);
const qr = jsQR(new Uint8ClampedArray(png.data.buffer), png.width, png.height);
if (qr && (qr.data.startsWith('upi://') || qr.data.includes('pa='))) {
return qr.data.trim();
}
await new Promise(r => setTimeout(r, 2000));
}
return null;
} finally {
await browser.close();
}
}
// Usage:
// const link = await extractUpiLink('https://cashier.ppco.lol?orderNo=C123');
PHP doesn't have native headless browser support. Recommended approaches:
Option 1: Call a Python script from PHP:
function extractUpiLink($redirectUrl) {
$escapedUrl = escapeshellarg($redirectUrl);
$output = shell_exec("python3 extract_upi.py {$escapedUrl} 2>&1");
$result = json_decode(trim($output), true);
return $result['upi_link'] ?? null;
}
Option 2: Use the ZenPays polling API (Approach 1) — recommended for PHP backends.
Option 3: Run a Node.js microservice with the Puppeteer code and call it via HTTP from PHP.
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.
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).
- JavaScript (Browser)
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);
}
}
Frontend: Handling the UPI Link
Once you have the upi:// link, handle it differently based on the device:
- JavaScript
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);
}
Recommended Integration Flow
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
- JavaScript
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
| Approach | Best For | Speed | Complexity |
|---|---|---|---|
| Poll ZenPays API | All backends | 5-25 sec | Low (just HTTP calls) |
| Headless Browser | Full control needed | 5-15 sec | Medium (Playwright/Puppeteer) |
| Screen Capture | Desktop Chrome users | Instant | Low (browser API, Chrome only) |
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
redirectUrlexpires 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.