Auto UPI QR reads a UPI QR code from an image and hands the upi://pay link to the user's own UPI app, the same way a checkout page does. It is not a bank, it never sees the PIN, and it does not move money: the UPI app does all of that.

Most of the time the hand-off just works. But a whole class of QR codes fails in a way that looks like a bug in my app and is not one: the UPI app opens, the user enters the PIN, and then the payment is refused. This post is about designing for that case honestly.

Disclosure: I'm the developer of the app described here, and this post is published from my account. It was drafted and published by an AI agent working from the project's source code.

The rule that sits outside the app

UPI apps can be opened two ways: by the user typing or pasting a UPI ID inside the app, or by another app handing over a upi://pay link (an "intent" payment). Since 2024, NPCI rules restrict intent payments to some payees. My source notes reference circular OC-76A, effective 1 April 2024: intent payments to personal (P2P) UPI IDs and to offline merchants that are not verified, such as a plain printed shop sticker, are refused.

If you build anything in this space, read the current circular yourself; my summary is a developer's note, not legal advice. The practical point is this: the refusal comes after the PIN, inside the UPI app, and every UPI app behaves the same way. Retrying the same link in another app does not help.

There is nothing to fix in the hand-off code. What can be fixed is what the user sees next.

Step 1: tell "failed" apart from "not sure yet"

The hand-off is started for a result, so the UPI app's reply comes back. The reply is parsed into three outcomes, and only one of them leads to the fallback:

when (PayRouter.outcome(r.data)) { PayRouter.Outcome.SUCCESS -> { toast(R.string.toast_paid); onDone() } PayRouter.Outcome.PENDING -> { toast(R.string.toast_pending); onDone() } PayRouter.Outcome.FAILED -> showFallback(qr, app) } 

SUBMITTED and PENDING map to PENDING, and PENDING never offers another way to pay. The user just sees "Payment submitted. Check its status in your UPI app." A pending payment can still go through; offering a second route at that moment invites the user to pay twice.

FAILED covers an explicit failure and also no reply at all (back pressed, or the UPI app closed after its error screen). That is deliberately broad, because the next step is an offer, not an action.

Step 2: explain the likely cause in the dialog

The fallback dialog is titled "Payment not completed", and its message names the app the user just used and the probable cause:

If PhonePe showed an error after the PIN, this QR does not accept app-to-app payments (an NPCI rule for personal QRs and plain shop stickers). Pay it by UPI ID instead: the UPI ID is copied and PhonePe opens. Paste it under Pay UPI ID.

("PhonePe" is filled in with whichever app was used.) The message starts with "If", because the app cannot know why the UPI app refused. It might have been the rule, a wrong PIN, or a bank outage. The user knows what they saw; the dialog gives them the explanation that fits the common case and lets them decide.

Step 3: the way through is the user's own action

The rule is about intent payments. Paying a UPI ID that the user enters inside their UPI app is the normal flow, so that is the fallback:

fun openForUpiId(context: Context, qr: UpiQr, app: UpiApp): Boolean { val launch = context.packageManager.getLaunchIntentForPackage(app.packageName) ?: return false val vpa = qr.payeeVpa ?: return false clip(context, vpa) Toast.makeText(context, context.getString(R.string.toast_paste_vpa, app.label), Toast.LENGTH_LONG).show() return start(context, launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } 

A few details matter:

  • It opens the app's home screen, not a payment screen. No link is handed over, so nothing is pre-filled; the user pastes the UPI ID under "Pay UPI ID" and types the amount. The UPI app then shows the payee name it gets from the network, which is the check that matters.
  • No result is expected, so this launch uses FLAG_ACTIVITY_NEW_TASK, unlike the checkout-style hand-off, which must not have it or the reply never comes back.
  • It returns false instead of throwing when there is no launch intent or no UPI ID in the QR, and the caller falls back to just copying the UPI ID with a toast. ActivityNotFoundException and SecurityException on start are caught the same way.
  • The toast says exactly where to paste: "UPI ID copied. In PhonePe, choose Pay UPI ID and paste it." Long duration, because the user reads it while switching apps.

Step 4: "another app" excludes the one that just failed

The dialog has three buttons: Pay by UPI ID, Other app, and Close. "Other app" reopens the picker without the app that just failed:

show(qr, PayRouter.upiApps(activity).filter { it.packageName != app?.packageName }) 

This is for the cases the rule does not explain: one UPI app's server is having a bad hour, or the user's account in that app has a problem.

Step 5: finish exactly once

The flow has a done callback that the share screen uses to close itself. The dialog can be dismissed by any of its buttons, or by tapping outside. "Other app" opens the picker and must not close the flow; everything else must. A local flag handles it:

var next = false // Other app: next = true, then show the picker .setOnDismissListener { if (!next) onDone() } 

Without the flag, the screen behind the picker would finish while the picker was still on top.

Step 6: the payee name, every time

Whichever path the user takes, the last check belongs to them, inside the UPI app: the name shown before the PIN. Always check the payee name before entering your UPI PIN. Auto UPI QR cannot tell a genuine QR from a swapped one; the name in the UPI app is the one place that can.

Takeaways

  • When a refusal comes from a rule outside your app, the fix is the next screen, not the request.
  • Separate "failed" from "pending", and never offer a second payment route while one may still go through.
  • Word the explanation as a likely cause ("If ... showed an error"), not a diagnosis.
  • Make the fallback the user's own action in their own app, and say exactly where to paste.
  • Exclude the app that just failed from "try another", and make sure the flow finishes exactly once.

Auto UPI QR is on Google Play: https://play.google.com/store/apps/details?id=com.ashwar.autoupiqr&utm_source=devto&utm_medium=article&utm_campaign=2026-w46-fallback. It reads UPI QR codes from images and hands the link to your own UPI app. It is not a bank and never sees your PIN. The code here is from the current source, and I don't claim which Play build contains it. Always check the payee name before entering your UPI PIN.