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
| Platform | Use |
|---|---|
| Android | Chrome Custom Tabs |
| iOS | ASWebAuthenticationSession, or SFSafariViewController |
| React Native, Flutter, Capacitor, Cordova | the 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
http(s) URL to the operating systemThis 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 catchActivityNotFoundException - iOS:
UIApplication.shared.open(url, options: [.universalLinksOnly: true]), then checkopened
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
redirect_uri and origin_uriWhen 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_urishould 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 /
ASWebAuthenticationSessionrather than a webview - If a webview: all four rules, not just rule 1
-
<queries>declared on Android -
assetlinks.jsonandapple-app-site-associationserved, no redirect, correct fingerprints -
adb shell pm get-app-linksreportsverified - A "Return to app" fallback on your
redirect_uripage - Payment status confirmed from your backend, not from the return URL
Updated 11 days ago