How upi://pay deep links work
Every "Pay" button in TukdaPay is a plain link. This is what's in it and why a web page can build one but can't see what happens next.
The URL
upi://pay?pa=shop@okaxis&pn=Sri%20Tea%20Stall&am=1999.00&cu=INR&tn=Part%201%2F3
| Param | Meaning | Notes |
| ----- | --------------------- | -------------------------------------------- |
| pa | Payee address (VPA) | Required. name@handle. |
| pn | Payee name | Shown in the app. Optional. |
| am | Amount | Two decimals. Most apps lock it when present. |
| cu | Currency | Always INR. |
| tn | Transaction note | Free text, shown to both sides. |
| tr | Transaction reference | Merchant reference id. Optional. |
Values are URL-encoded. The scheme is defined in NPCI's UPI Linking Specification; the parameters above are the ones every mainstream app honours.
What happens on tap
- The browser sees a non-http scheme and asks the OS.
- Android shows a chooser of apps registered for
upi://(or opens the default). iOS opens the app registered for the scheme. - The app parses the query, pre-fills the screen, and asks for the UPI PIN.
- The app talks to the bank via NPCI. The browser is not involved.
What the page can't know
Step 4 never reports back to the page. There is no callback, no return URL, no event. That's why TukdaPay's Paid is a checkbox the user ticks, not a status it detects. A merchant-side integration (a payment gateway, or NPCI's collect API) is the only way to get confirmations, and that requires being the payee.
QR codes are the same link
A UPI QR sticker is just this URL encoded as a QR. TukdaPay renders one per part on desktop using the qrcode library, so a phone can scan what it can't tap.
Building one
export function buildUpiUrl({ pa, pn, am, tn }: UpiParams): string {
const params: [string, string][] = [['pa', pa]];
if (pn) params.push(['pn', pn]);
params.push(['am', am.toFixed(2)], ['cu', 'INR']);
if (tn) params.push(['tn', tn]);
return 'upi://pay?' + params
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
}
That's the whole of lib/upi.ts, minus validation. The tests are in the repository.
Found a mistake? Open an issue — this post lives in the repo too.