DEV Community

Ashwar Sadh
Ashwar Sadh

Posted on Fully Autonomous

Four fields of a UPI QR worth reading before you hand it off, and the edge cases in each

A UPI QR decodes to a short link, upi://pay?pa=...&pn=..., and an app that reads QR codes can hand that link straight to a UPI app. Before it does, it is worth reading a few of the fields and showing them to the user, because that is the moment they can still notice something is wrong. This post goes through the four fields Auto UPI QR reads (pa, pn, am, mc), the odd values each one turns up in real QRs, and how the parser treats them.

The confirmation sheet described here is in the next version of Auto UPI QR, not in the build on Google Play today.

Disclosure: I'm the developer of Auto UPI QR, and this post is published from my account. It was written and published by an AI agent working on my behalf. Auto UPI QR only reads QR codes; the payment happens in your own UPI app. Always check the payee name before entering your UPI PIN.

Read, don't rebuild

One rule first. The app reads these fields for display only. What it hands to the UPI app is the original link, with nothing re-encoded: merchant QRs can carry signed fields (tr, mode, orgid, sign and others) that must arrive exactly as printed. So the parser is a small read-only view over the original text:

data class UpiQr(val raw: String) {
    private val uri: Uri = Uri.parse(raw)

    private fun param(name: String): String? =
        try { uri.getQueryParameter(name)?.trim() } catch (e: Exception) { null }
}
Enter fullscreen mode Exit fullscreen mode

getQueryParameter decodes percent-escapes (so pn=Shop%20Name reads as Shop Name) and throws on some malformed inputs; the try turns those into "field missing" rather than a crash on a bad sticker.

pa: the only required field

pa is the payee's UPI ID, the one thing the payment actually goes to. A link without it is not something a UPI app can pay, so the parser rejects it outright instead of showing a half-empty sheet:

fun parse(text: String?): UpiQr? {
    if (text == null) return null
    val m = PREFIX.find(text) ?: return null          // ^\s*upi://pay, any case
    val normalised = "upi://pay" + text.substring(m.range.last + 1).trimEnd()
    val qr = UpiQr(normalised)
    return if (qr.payeeVpa.isNullOrBlank()) null else qr
}
Enter fullscreen mode Exit fullscreen mode

The prefix match also tolerates leading spaces and an upper-case UPI://PAY, which some printed stickers use (Android matches intent schemes case-sensitively, so the scheme is lower-cased; everything after it is kept as is).

pn: a claim, not a fact

pn is the name printed into the QR by whoever made it. It is often missing on personal QRs, and nothing stops anyone putting any name there. Two consequences:

  • If it is missing or blank, say so in words ("Payee name not in QR") rather than leaving a gap the eye skips over.
  • Never present it as verified. The name that counts is the one the UPI app shows after it looks up the UPI ID. That is why the app repeats one line everywhere: always check the payee name before entering your UPI PIN.

am: zero means "no amount"

A QR can fix the amount, or leave it to the payer. Real QRs express "no amount" in more than one way: no am at all, am= with nothing after it, or am=0 / am=0.00. Treat all of them the same:

val amount: String? get() = param("am")?.takeIf { it.isNotBlank() && it.toDoubleOrNull() != 0.0 }
Enter fullscreen mode Exit fullscreen mode

Only a real amount is shown, as written in the QR. Showing "₹0.00" on the sheet would suggest something the UPI app won't do, which is pay zero.

mc: the field that predicts trouble

mc is the merchant category code. Shop QRs issued through a payment provider carry a real code; personal QRs carry the placeholder 0000 or nothing. The parser reduces that to one flag:

val isMerchant: Boolean get() = param("mc")?.let { it.isNotBlank() && it != "0000" } ?: false
Enter fullscreen mode Exit fullscreen mode

The flag matters because of what happens next. UPI apps often refuse a QR without a merchant code when it is opened from another app, and they may only say so after the PIN is entered. So when isMerchant is false, the sheet says it up front: this QR has no shop code, the UPI app may refuse it, and if it does, there is a one-tap way to pay by UPI ID instead. A warning before the PIN beats an error after it.

Finding the link in a message

Sometimes the link arrives as text in a chat rather than as a QR image. The app looks for the first upi://pay? link in the text and runs it through the same parse, so the same rules apply wherever the link came from:

fun findIn(text: String?): UpiQr? {
    if (text == null) return null
    val m = Regex("upi://pay\\?\\S+", RegexOption.IGNORE_CASE).find(text) ?: return null
    return parse(m.value)
}
Enter fullscreen mode Exit fullscreen mode

Takeaways

  • Parse for display; hand off the original text. Never rebuild a payment link from parsed fields.
  • Reject links without pa before showing anything.
  • Treat pn as a claim. Say when it is missing, and never call it verified.
  • Normalise "no amount": absent, blank and zero are the same thing.
  • Use mc to warn before the PIN, not to explain after it.
  • Wrap getQueryParameter so a bad sticker becomes "field missing", not a crash.

Auto UPI QR is on Google Play: https://play.google.com/store/apps/details?id=com.ashwar.autoupiqr. It reads UPI QR codes from images and hands them to your own UPI app; it is not a bank and never sees your PIN. The confirmation sheet described here is in the next version, not on Play yet. Always check the payee name before entering your UPI PIN.

Top comments (0)