<!-- ZenPays documentation · https://docs.zenpayz.com/docs/guides/upi-native-link-guide -->

# 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

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

### Code Examples

<Tabs>
<TabItem value="js" label="JavaScript" default>

```javascript
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');
}
```

</TabItem>
<TabItem value="python" label="Python">

```python
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
```

</TabItem>
<TabItem value="php" label="PHP">

```php
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]);
}
```

</TabItem>
</Tabs>

---

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

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

<Tabs>
<TabItem value="python" label="Python (Playwright + pyzbar)" default>

**Install:**
```bash
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:

```python
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"))
```

</TabItem>
<TabItem value="js" label="JavaScript (Puppeteer + jsQR)">

**Install:**
```bash
npm install puppeteer jsqr pngjs
```

```javascript
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');
```

</TabItem>
<TabItem value="php" label="PHP">

PHP doesn't have native headless browser support. Recommended approaches:

**Option 1: Call a Python script from PHP:**
```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.

</TabItem>
</Tabs>

---

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

<Tabs>
<TabItem value="js" label="JavaScript (Browser)" default>

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

</TabItem>
</Tabs>

---

## Frontend: Handling the UPI Link

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

<Tabs>
<TabItem value="js" label="JavaScript" default>

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

</TabItem>
</Tabs>

---

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

<Tabs>
<TabItem value="js" label="JavaScript" default>

```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.');
}
```

</TabItem>
</Tabs>

---

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

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