Mobile app integration

How to open Connect from an Android or iOS app so bank apps open and the payer returns to your order.

If your app opens Connect inside an embedded webview, payments will fail at the bank step for a
large share of your payers. This page explains why, what to do instead, and — if a webview is
unavoidable — the four rules that keep the journey alive.

Use a browser surface, not a webview

PlatformUse
AndroidChrome Custom Tabs
iOSASWebAuthenticationSession, or SFSafariViewController
React Native, Flutter, Capacitor, Cordovathe InAppBrowser / Browser plugin, not the native webview component

These are real browser processes. App-to-app handoff, link verification and the return journey all
behave normally, and there is no special handling to write.

A common objection is that keeping the payer inside your own webview is safer. For a payment page
the opposite is true: a browser surface runs out of process and your app cannot read or inject into
it, whereas a webview you host is inside your own process.

Why a webview breaks the bank step

To authenticate the payer, most banks need to open their own app. They do that with a link the
operating system understands but a web page cannot render — a custom scheme such as bankapp://,
or an intent:// URL.

A stock WebView or WKWebView has no route to the OS for those. The navigation fails with
ERR_UNKNOWN_URL_SCHEME on Android, or silently does nothing on iOS, and the payment stops with
the payer looking at an error. Nothing is wrong with your integration or with the bank; this is the
default behaviour of an embedded webview.

Adding the handling in rule 1 below is what makes the difference. It is a few lines, and it is
entirely on your side.

If you must use a webview: four rules

1. Hand every non-http(s) URL to the operating system

This is the rule that makes bank apps open. Without it, nothing else on this page matters.

2. Offer other hosts to the OS, but never eject the payer into a browser

Some banks authenticate in their app via a verified https link; most authenticate on an ordinary
web page. You want the first to leave your app and the second to stay in it.

On both platforms, ask the OS to open the link only if a real app claims it, and fall back to
loading it in the webview:

  • Android: Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER, then catch ActivityNotFoundException
  • iOS: UIApplication.shared.open(url, options: [.universalLinksOnly: true]), then check opened

Without that guard on Android, startActivity with an https URL is claimed by the browser, and
every bank that authenticates on the web throws your payer out of your app mid-payment.

3. Intercept your own redirect_uri and origin_uri

When the payment finishes inside your webview, the redirect to your return URL is just another
navigation inside the webview. The OS is never asked to route it, so an App Link or Universal
Link cannot fire. Catch the URL yourself, close the webview, and show your order confirmation.

4. Confirm on the server

Treat the return as a hint that the payer is back, not as proof of payment. Re-read the payment
status from your backend, and rely on webhooks for the authoritative outcome. A payer can close the
app before any redirect happens, and the payment may still have succeeded.

Android

webView.webViewClient = object : WebViewClient() {
    override fun shouldOverrideUrlLoading(view: WebView, req: WebResourceRequest): Boolean {
        if (!req.isForMainFrame) return false
        val url = req.url.normalizeScheme(); val web = url.scheme in setOf("http", "https")

        // 3. your own return: match the exact origin and path, not a prefix
        if (url.scheme == "https" && url.host == "checkout.merchant.com" &&
            url.port in setOf(-1, 443) && url.encodedPath == "/pay/return") {
            onPaymentReturn(url); return true
        }
        // No REQUIRE_NON_BROWSER below API 30, so keep every web page inside.
        if (web && Build.VERSION.SDK_INT < 30) return false

        return try {
            val launch = if (url.scheme == "intent") Intent.parseUri(url.toString(), Intent.URI_INTENT_SCHEME)
                         else Intent(Intent.ACTION_VIEW, url)
            val scheme = launch.data?.scheme?.lowercase()
            if (scheme == null || scheme in setOf("intent", "file", "content", "javascript", "data", "blob", "about"))
                throw URISyntaxException(url.toString(), "Unsupported target")
            val targetWeb = scheme in setOf("http", "https")
            if (targetWeb && Build.VERSION.SDK_INT < 30) throw ActivityNotFoundException()
            // An intent: URL can name its own component; never launch what the page chose.
            launch.component = null; launch.selector = null; launch.action = Intent.ACTION_VIEW
            launch.addCategory(Intent.CATEGORY_BROWSABLE); launch.flags = Intent.FLAG_ACTIVITY_NEW_TASK
            launch.removeExtra("browser_fallback_url")
            // REQUIRE_DEFAULT too: a chooser the payer cancels would swallow the navigation.
            if (targetWeb && Build.VERSION.SDK_INT >= 30)
                launch.addFlags(Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER or Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT)
            startActivity(launch); true
        } catch (e: Exception) {
            if (e !is ActivityNotFoundException && e !is SecurityException && e !is URISyntaxException) throw e
            if (!web) Toast.makeText(view.context, "Unable to open bank app", Toast.LENGTH_LONG).show()
            !web // web: keep loading in the webview. other schemes: stay on the current page.
        }
    }
}

Needs API 24+, compileSdk 30 or later, and an Activity context. On Android 10 and below there is
no bank handoff: REQUIRE_NON_BROWSER does not exist there, so every web page stays in the webview
rather than risk ejecting the payer into a browser mid-payment.

Android 11 and above also needs package visibility. Without this your app cannot see that a bank
app exists, so rule 2 never fires and the handoff silently fails:

<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" />
    </intent>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="*" />
    </intent>
</queries>

iOS

func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
             decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    guard let url = action.request.url else { return decisionHandler(.allow) }

    if url.host == "checkout.merchant.com", url.path.hasPrefix("/pay/return") {
        onPaymentReturn(url); return decisionHandler(.cancel)
    }
    if !["http", "https"].contains(url.scheme ?? "") {
        UIApplication.shared.open(url); return decisionHandler(.cancel)
    }
    if !(url.host ?? "").hasSuffix("fintecture.com") {
        UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { opened in
            if !opened { webView.load(URLRequest(url: url)) }
        }
        return decisionHandler(.cancel)
    }
    if action.targetFrame == nil { webView.load(action.request) }
    decisionHandler(.allow)
}

Getting the payer back into your app

Register your redirect_uri as an Android App Link and an iOS Universal Link. That is the right
foundation, but plan for it not to fire every time.

Verify the association actually happened. This is the most common cause of "the return opens in
the browser". Links verify at install time, against a file served from your own domain:

https://your.domain/.well-known/assetlinks.json     # Android
https://your.domain/.well-known/apple-app-site-association   # iOS

The file must be served over https, with no redirect on the way to it, and must list your package
name and signing certificate fingerprint. Debug and release builds have different fingerprints;
list both, or the link stops verifying the moment you switch build type.

On Android, check the result rather than assuming:

adb shell pm get-app-links <your.package.name>

You want verified. Re-install the app after deploying or changing the file — verification does not
re-run on its own.

Do not depend on the redirect alone. The payer reaches your return URL at the end of a chain of
redirects, and depending on platform, browser and how the journey started, the OS may hand the link
to your app or may keep it in the browser. Design for both:

  • if your app opens, read the parameters and show the order;
  • if the browser keeps it, the page at your redirect_uri should show the payment status and a
    "Return to app" button linking to the same URL. A link the payer taps is the most reliable way
    to reach an installed app on both platforms.

And expect more than one return. A journey can bounce between your app, the bank app and the
browser several times. Re-read the payment status each time your app comes to the foreground, until
you have a terminal outcome.

Test it in sandbox

Real banks only hand off to their own app in production, so the handoff and the return are usually
the two things you cannot try before going live. The Fintecture Demo Bank App removes that gap:
it is an installable Android app that plays the role of the bank in sandbox and test.

Download it from https://github.com/Fintecture/demo-bank-app/releases and install it on an Android
device (8.0 or later). Then start a payment from your app and select Demo Bank in the Connect
bank list. With the app installed, Android opens it instead of a browser, exactly as a real bank
app would be opened. Pick the outcome you want to rehearse, confirm, and the journey returns
through your redirect_uri.

It handles the Demo Bank only. Every other bank in the list behaves as before, and the app reaches
sandbox and test alone.

Verify the APK against the SHA-256 published with the release before installing. If you install it
before your own association file is live, reinstall it afterwards: verification runs at install
time.

Checklist

  • Custom Tabs / ASWebAuthenticationSession rather than a webview
  • If a webview: all four rules, not just rule 1
  • <queries> declared on Android
  • assetlinks.json and apple-app-site-association served, no redirect, correct fingerprints
  • adb shell pm get-app-links reports verified
  • A "Return to app" fallback on your redirect_uri page
  • Payment status confirmed from your backend, not from the return URL

Did this page help you?