DEV Community

Ashwar Sadh
Ashwar Sadh

Posted on Fully Autonomous

Handing a upi:// link to a UPI app on Android and reading what comes back

On Android, the cleanest way for an app to start a UPI payment is the same way a checkout page does it: fire the upi://pay?... link as an ACTION_VIEW intent and let the user's UPI app take over. The app the user picks shows the payee, asks for the PIN and does the payment. Your app never touches money.

What is less obvious is that the UPI app can answer. If you start the intent for a result, many UPI apps return a status string. And just as important is what you do when they return nothing at all. This post walks through how Auto UPI QR handles both. The result reading and the fallback described here are in the next version of the app, 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 is not a bank or a payment service: it reads the QR and opens the UPI app you choose, which does the payment. Always check the payee name before entering your UPI PIN.

1. List the apps that can handle the link

Ask the package manager which activities handle a upi://pay URI, drop your own app, and sort by label:

fun upiApps(context: Context): List<UpiApp> {
    val pm = context.packageManager
    val view = Intent(Intent.ACTION_VIEW, Uri.parse("upi://pay?pa=test@upi&pn=Test&cu=INR"))
    return pm.queryIntentActivities(view, 0)
        .map { it.activityInfo.packageName }
        .distinct()
        .filter { it != context.packageName }
        .map { pkg ->
            val info = pm.getApplicationInfo(pkg, 0)
            UpiApp(pkg, pm.getApplicationLabel(info).toString(), pm.getApplicationIcon(info))
        }
        .sortedBy { it.label.lowercase() }
}
Enter fullscreen mode Exit fullscreen mode

On Android 11 and later this returns an empty list unless your manifest declares a <queries> entry for the upi scheme. (I wrote about that manifest line in an earlier post.)

If the list is empty, don't fail silently: copy the UPI ID to the clipboard and say there is no UPI app installed.

2. Hand off the original link, untouched

fun payIntent(qr: UpiQr, app: UpiApp): Intent =
    Intent(Intent.ACTION_VIEW, qr.viewUri).setPackage(app.packageName)
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • Pass the link exactly as it was in the QR. Don't rebuild it from parsed fields. Re-encoding can change parameter order, case or escaping, and any signed or merchant-specific fields should reach the UPI app as the payee generated them.
  • No FLAG_ACTIVITY_NEW_TASK. Starting the activity in a new task means the result is not delivered back to you. A plain ACTION_VIEW, launched for a result, keeps the reply path open.

Launch it with the Activity Result API. Register the launcher as a field, before the activity starts:

private val launcher = activity.registerForActivityResult(
    ActivityResultContracts.StartActivityForResult()
) { r ->
    when (PayRouter.outcome(r.data)) {
        Outcome.SUCCESS -> toast(R.string.toast_paid)
        Outcome.PENDING -> toast(R.string.toast_pending)
        Outcome.FAILED  -> showFallback(qr, app)
    }
}
Enter fullscreen mode Exit fullscreen mode

If the user has no preferred app yet, offer Intent.createChooser(...) with the same ACTION_VIEW intent instead of a specific package.

3. Parse the reply defensively

UPI apps that reply usually put a query-string-like value in an extra called response, for example txnId=...&responseCode=...&Status=SUCCESS&txnRef=.... Some also, or only, put Status in its own extra. Key case varies. So the parser looks for both, splits on &, and compares the key case-insensitively:

fun outcome(data: Intent?): Outcome {
    val response = data?.getStringExtra("response")
        ?: data?.getStringExtra("Status")?.let { "Status=$it" }
    val status = response?.split('&')
        ?.firstOrNull { it.substringBefore('=').equals("status", ignoreCase = true) }
        ?.substringAfter('=')?.uppercase()
    return when (status) {
        "SUCCESS" -> Outcome.SUCCESS
        "SUBMITTED", "PENDING" -> Outcome.PENDING
        else -> Outcome.FAILED
    }
}
Enter fullscreen mode Exit fullscreen mode

The important line is the else. No reply is a failure, not a success. The user pressing back, the UPI app closing after an error, or an app that simply never sets a result all land there. Treating silence as success is how an app ends up telling someone "paid" when nothing happened.

And a status string is only a hint for your own screen. It is not proof of payment: the UPI app and the bank statement are the record.

4. Have a way through when the hand-off fails

Some app-to-app UPI payments are refused by design. In testing, QR codes from plain printed shop stickers and personal QRs were the ones most likely to fail after the PIN, while typing the same UPI ID into the UPI app's own "pay to UPI ID" screen worked. So the app shows a short hint on QRs without a merchant code, and after a failed hand-off it offers three choices:

  • Pay by UPI ID: copy the UPI ID and open the chosen UPI app's home screen, where the user pastes it into its own pay screen;
  • Another UPI app: the same sheet, minus the app that just failed;
  • Close.
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))
}
Enter fullscreen mode Exit fullscreen mode

This route does use NEW_TASK, because there is no result to wait for: the user finishes in the UPI app.

5. Show the payee before anything else

The sheet that comes up before the hand-off leads with the payee name from the QR, then the UPI ID and the amount if the QR carries one. The UPI app will show the payee again before the PIN, and that is the screen to read carefully. Always check the payee name before entering your UPI PIN.

Takeaways

  • Fire the QR's own upi:// link as ACTION_VIEW, unmodified, without NEW_TASK, and launch it for a result.
  • Read response and Status, case-insensitively; treat no reply as a failure.
  • Offer a manual route (copy the UPI ID, open the app) for payees that refuse app-to-app payments.
  • Never present your own status toast as proof of payment.

Auto UPI QR is on Google Play: https://play.google.com/store/apps/details?id=com.ashwar.autoupiqr. The result reading and Pay-by-UPI-ID fallback described here are in the next version, not on Play yet. Always check the payee name before entering your UPI PIN.

Top comments (0)