Payments in a WebView

Serving the hosted payment page inside a native app, with the bank-app hand-off and the return leg.

Confirm hands you a payment_url and the customer has to end up on it. In a browser storefront that is a redirect and there is nothing more to say. In a native app you will almost certainly show it in a WebView, which needs help with two moments a browser handles on its own:

  1. Leaving for another app. Most European payment methods, bank-redirect ones in particular, do not finish on a web page. The customer picks their bank and the page navigates to that bank's app. A WebView cannot render a banking app, and by default it does not hand the navigation over either: it fails, and the customer is stranded on a dead screen with the money not taken.
  2. Coming back. Once the customer is in their bank's app, your app may be in the background, or gone, reclaimed by the operating system. The return has to find its way home whether your process survived or not.

Handle those two and the rest of the payment is the ordinary flow from The checkout lifecycle.

📘

There is a simpler option if the payment page does not have to sit inside your own UI.

Opening payment_url in Chrome Custom Tabs (Android) or SFSafariViewController (iOS) gives you a real browser, so the hand-off and the cookies work without any of the navigation handling below. What you give up is the in-app chrome and the ability to watch the navigation. The return leg is yours either way.

Two ways this page applies

You can reach a payment page in a WebView from either side of the integration, and the second one looks like it has nothing to configure.

You drive the payment. You call Confirm, you get payment_url, you load it in your WebView. Everything below is yours to implement.

You embed a Funtrips-hosted flow. A hosted checkout or voucher redemption runs inside your WebView, and you never see payment_url at all. The flow navigates to the payment page itself, from inside the frame you are hosting.

The hand-off does not care which it is. A bank-redirect step emits the same navigation either way, your WebView is still the thing that cannot render it, and your app is still the only thing that can pass it to the operating system. Embedding a hosted flow does not embed the app-switch handling along with it: that part is a property of the WebView, and the WebView is yours.

So in the embedded case you implement Classifying a navigation exactly as written. What changes is the return leg:

  • The return URL is not yours. The hosted flow sets its own return_url, so the payment comes back inside the frame rather than to an address you registered. Do not pattern-match on URLs you see go past to decide the flow has finished; how your app is told is settled when the embed is scoped.
  • Surviving a process kill still needs a deep link into your app. If the operating system reclaims your process while the customer is in their banking app, the trip back has to reach your app, and nothing inside the frame can make that happen. That return has to be configured for your app as part of the embed rather than added from the outside, so raise it with us when you scope the integration rather than discovering it in testing.

The shape of the round trip

sequenceDiagram
    participant A as Your app
    participant W as WebView
    participant OS as Operating system
    participant B as Bank app
    participant P as Funtrips API

    A->>P: POST /checkouts/{id}/confirm (return_url)
    P-->>A: payment_url
    A->>W: Load payment_url
    W->>W: Customer picks a payment method
    W--xW: Navigation the WebView cannot render
    W->>A: Navigation offered for a decision
    A->>OS: Hand it over
    OS->>B: Bank app opens
    B->>OS: Redirect to your return_url
    OS->>A: Warm: back into the WebView<br/>Cold: deep link into a fresh launch
    A->>P: GET /checkouts/{id} → poll to terminal

One decision point in that diagram, "navigation offered for a decision", is the whole integration. Every platform gives you a hook there. Your job is to answer one question for each navigation: can this WebView render it, is it my return, or does it belong to the operating system?

Classifying a navigation

Answer it on the URL scheme. The rules are the same on every platform:

NavigationWhat it isWhat to do
https://… on the payment provider's own hostsThe payment page and its stepsLoad it in the WebView
about:, data:, blob:The WebView's own businessLoad it
Your return_url, or your app's own schemeThe round trip is overDo not load it. Leave the WebView, go to your result screen
intent://…#Intent;…;endAndroid's encoding of an app hand-offDo not load it. Unpack it, then give it to the OS
Anything else: bankid://, a bank's own scheme, itms-apps://, tel:, mailto:Another app entirelyDo not load it. Give it to the OS

Write that as a pure function that takes a URL and returns a decision, separate from your view. It is the rule that decides whether someone's payment can complete, and it deserves its own unit tests more than the widget around it does.

Unpacking intent: URLs

Chrome-family WebViews emit intent:// URLs for app hand-off. They are not URLs the OS resolves; they encode a target app in the fragment:

intent://issuer/path#Intent;scheme=bankapp;S.browser_fallback_url=https%3A%2F%2Fbank.example%2Fpay;end

Read the fragment, split on ;, and:

  • If S.browser_fallback_url is present, prefer it. A customer without the app installed should land on the bank's web page, not on a failed launch.
  • Otherwise rebuild the address with scheme='s value in place of intent, and hand that to the OS.

On Android you can let the platform do it with Intent.parseUri(url, Intent.URI_INTENT_SCHEME), but you still have to handle the fallback and the not-installed case yourself.

The https hand-off you cannot see

One case does not classify on the scheme. Some banks are reached through a verified app link: an ordinary https:// URL that the OS knows belongs to an installed app. Inside a WebView it is indistinguishable from any other page, and the WebView will load the bank's web page instead of opening the app.

That degrades rather than breaks, since the customer can still pay on the web. If you want the app switch, ask the OS whether it can take the URL and fall back to loading it when the answer is no. The per-platform sections below show how.

There is no list of bank schemes to maintain

If you are looking for the set of banking-app URL schemes to allow-list, there isn't one, and you do not need it.

The question comes from iOS's LSApplicationQueriesSchemes. That key gates canOpenURL, not open. An app may launch any URL it likes; it may only ask in advance about schemes it has declared. So an integration that calls UIApplication.shared.open(url) and reads the completion handler declares nothing, while one that pre-flights with canOpenURL owes itself every scheme it will ever ask about: every bank, in every country the campaign sells in, kept current as banks ship new apps.

The list is also capped at 50 entries, and a payment provider's own SDK can consume that whole budget for a single payment method, leaving you no room to declare your own.

Android works the same way. Declaring an interest in the VIEW intent, the <queries> block below, is enough for the OS to resolve a hand-off. Enumerating bank package names adds nothing and goes stale on its own.

So do not enumerate banks. Hand the URL to the OS and treat "nothing could open it" as the error case: the customer does not have that bank's app installed. That is a message to show, not a configuration to prevent.

The allow-list approach fails in a way that does not look like failure while you are building it. You allow-list the one scheme you tested with, it works, and you ship. Every other bank's scheme now falls through your handler to "let the WebView load it", which it cannot, so those customers get a dead screen. The integration is correct for one payment method and silently broken for the rest, and which customers hit it depends on which bank they use.

Classify by what the WebView can render, not by which schemes you recognise. The default branch has to be "hand it to the OS", never "load it".

📘

This changes if you adopt a provider's native payment SDK instead of the hosted page.

Those SDKs do the app-switching themselves, and some of them do require a declared scheme list. That is an SDK requirement rather than a property of the payment methods, and it is a different integration from the one on this page.

The return leg

Your return_url is reached by two different roads, and an integration that only handles one fails intermittently in a way that is miserable to reproduce.

Warm. The customer never left, or your process was still alive when they came back. The return arrives as a navigation inside the WebView. You intercept it and never load it.

Cold. The OS reclaimed your process while the customer was in their banking app. Your app launches fresh, with nothing in memory, and the return arrives as a deep link. This is not an edge case; it is what a trip through a banking app invites on a low-memory device.

Both roads must end on the same screen. Three consequences:

  • Register return_url as something your app can receive. A verified App Link or Universal Link is the better choice, since it stays a working web page for anyone without the app. A custom scheme (your-app://payment/result) is simpler, and enough when the return leg is only ever reached from your own app.
  • Put your checkout_id in the return_url. On the cold road it is the only thing your result screen has to work with.
  • Expect it more than once. A provider can hit the return URL twice. Guard the transition so a second navigation does not push a second result screen.
❗

A custom scheme parses its first path segment as the host.

your-app://payment/result is host payment, path /result, so a router matching on the path sees /result and answers "not found". The customer comes back from their bank to an error screen, and only on the cold road, which is why it survives testing: the warm return never goes through the OS and never hits the parse.

Fold the host back into the path before routing, and build the return URL from your route definition rather than writing it out by hand. A hand-written URL that guesses a query parameter's name will match the route and then fail on the read.

The URL that comes back is not the one you sent

Two layers add query parameters to your return_url before the customer lands on it:

  • The platform adds payment_id to the URL it hands the provider. Your own query parameters are preserved.
  • The provider adds its own, naming its session and its result. What these are called is the provider's business and can change; treat them as opaque.

So the URL your app receives carries parameters you did not put there, and you cannot predict the full set. Three rules follow:

  • Match the return on origin and path, never on the whole string. A comparison against the return_url you sent will fail the moment a parameter is added.
  • Let your own parameters win when you merge the returned query into your route. A stray id in the provider's query must never be able to point the customer at another order.
  • Do not read the provider's result parameter as the outcome. It is a handle for the provider's own status call, not a verdict, and it arrives on a URL you do not control. The outcome comes from GET /checkouts/{checkout_id}.

Landing there is not a result

In an app the redirect feels like a callback, and it is not one. The customer is sent to return_url whatever happened: paid, cancelled, or abandoned. It carries no trustworthy status.

Treat it as "the customer came back", then poll GET /checkouts/{checkout_id} until the status is terminal, exactly as a web storefront would. Never show tickets or a success screen off the redirect alone.

The window is shorter than it looks

The reservation lives 30 minutes, and the payment session is bounded to land inside it. A customer who wanders off in their banking app can come back to a payment that is no longer payable.

expires_at on the checkout is your budget. When it passes, the recovery is a new checkout with the same basket, not a retry of the same payment_url.

Android (Kotlin/Java)

Settings. The payment page is script-driven and stores state; it is inert without these.

webView.settings.apply {
    javaScriptEnabled = true
    domStorageEnabled = true
    // Some payment steps open in a new window rather than navigating.
    setSupportMultipleWindows(true)
    javaScriptCanOpenWindowsAutomatically = true
}
webView.webChromeClient = object : WebChromeClient() { /* handle onCreateWindow */ }
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)

Leave the user agent alone. Providers serve app-switch flows based on it, and a desktop UA gets you the desktop flow with no hand-off in it.

The hook is WebViewClient.shouldOverrideUrlLoading. Return true for anything you are handling yourself:

override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
    val url = request.url
    return when (classify(url)) {
        Decision.LoadHere -> false
        Decision.Return   -> { finishPayment(url); true }
        Decision.HandOff  -> { handOff(url); true }
    }
}

private fun handOff(url: Uri) {
    val intent = Intent.parseUri(url.toString(), Intent.URI_INTENT_SCHEME)
    try {
        startActivity(intent)
    } catch (e: ActivityNotFoundException) {
        intent.getStringExtra("browser_fallback_url")
            ?.let { webView.loadUrl(it) }
            ?: showNoBankAppMessage()
    }
}

Package visibility. On Android 11 and above your app cannot see other packages unless it declares an interest. Without this block the hand-off silently resolves to nothing and the customer sits on the bank-select step with a dead button:

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

The https app link. To app-switch rather than render, try the intent with FLAG_ACTIVITY_REQUIRE_NON_BROWSER (API 30+) and treat ActivityNotFoundException as "no app for this, load it in the WebView after all".

Receiving the return. Add an intent-filter for your return URL, set the activity launchMode to singleTask, and handle both onCreate's intent and onNewIntent.

A custom scheme needs nothing further. A verified App Link needs android:autoVerify="true" plus an assetlinks.json on the host, and that file pins the signing certificate fingerprint. List the fingerprint of the key the build is actually signed with, including the upload or app-signing key Play uses, not just your debug key. Get it wrong and verification fails quietly: the link stops opening your app and starts opening a browser, on release builds only. adb shell pm verify-app-links --re-verify <package> and the Play Console's App Links section both report the real status.

iOS (Swift)

The hook is WKNavigationDelegate.decidePolicyFor navigationAction. Cancel anything you are handling yourself:

func webView(_ webView: WKWebView,
             decidePolicyFor navigationAction: WKNavigationAction,
             decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    guard let url = navigationAction.request.url else {
        decisionHandler(.cancel); return
    }
    switch classify(url) {
    case .loadHere:
        decisionHandler(.allow)
    case .returnToApp:
        decisionHandler(.cancel); finishPayment(url)
    case .handOff:
        decisionHandler(.cancel)
        UIApplication.shared.open(url, options: [:]) { opened in
            if !opened { self.showNoBankAppMessage() }
        }
    }
}

Use open's completion handler, never canOpenURL. open launches any scheme. canOpenURL answers only for schemes declared in LSApplicationQueriesSchemes, so pre-flighting commits you to a bank list you cannot keep current and caps you at 50 entries. Attempt the open, and treat opened == false as "the customer does not have that app". See There is no list of bank schemes to maintain.

LSApplicationQueriesSchemes is an Info.plist declaration, not an entitlement. There is nothing to enable on the App ID and nothing that can differ between a debug and a release build. The entitlement in this flow is on the return leg, below.

The https app link. WKWebView never follows Universal Links on an in-page navigation. To get the app switch, try UIApplication.shared.open(url, options: [.universalLinksOnly: true]) and, in the completion handler, allow the navigation when it returns false.

Popups. Implement WKUIDelegate.webView(_:createWebViewWith:for:windowFeatures:). Returning nil, the default, silently drops target="_blank" navigations, which some payment steps use. Load the request in the existing WebView and return nil instead of doing nothing.

Cookies. Use the default WKWebsiteDataStore, not .nonPersistent(). An ephemeral store loses the payment session across the app switch.

Receiving the return. Two routes, with different configuration and different ways of failing.

A custom scheme needs one CFBundleURLSchemes entry in Info.plist. That is the whole setup, and it behaves the same in every build.

A Universal Link needs the Associated Domains entitlement, com.apple.developer.associated-domains, with an applinks:your-host entry, plus an apple-app-site-association file served from that host over HTTPS with no redirect and no file extension. The entitlement is a signed capability, so it also has to be enabled on the App ID and present in the provisioning profile the build was signed with.

That last part is why this is the configuration that passes in development and fails in TestFlight. A profile without the capability drops the association silently: no error, no warning, the OS simply opens your return URL in Safari and the customer never comes back to the app. If the return leg works on your device and not on a distributed build, check the entitlement before anything else.

Handle scene(_:openURLContexts:) for the scheme and scene(_:continue:) for the Universal Link, and read the launch URL on a cold start, not only the live callback.

Flutter

With webview_flutter:

_controller = WebViewController()
  // The payment page is script-driven; without this it is inert.
  ..setJavaScriptMode(JavaScriptMode.unrestricted)
  ..setNavigationDelegate(
    NavigationDelegate(
      onNavigationRequest: (request) {
        final uri = Uri.tryParse(request.url);
        if (uri == null) return NavigationDecision.prevent;
        switch (classify(uri)) {
          case Decision.loadHere:
            return NavigationDecision.navigate;
          case Decision.returnToApp:
            finishPayment(uri);
            return NavigationDecision.prevent;
          case Decision.handOff:
            unawaited(_handOff(uri));
            return NavigationDecision.prevent;
        }
      },
    ),
  )
  ..loadRequest(paymentUrl);

Five Flutter-specific traps:

  • Do not await the controller setters. They are queued internally. Awaiting them means the widget builds before the navigation delegate is attached, and the very first hand-off slips past unhandled.
  • Hand off with LaunchMode.externalApplication. The default in-app mode puts the URL straight back where it could not load. Wrap the call too: launchUrl throws rather than returning false in some cases, and a caller mid-payment needs a message, not an exception.
  • A prevented navigation produces no onPageFinished. If you drive a progress indicator from onPageStarted/onPageFinished, clear it yourself when you decline, or it spins through the whole trip to the bank and back.
  • The <queries> block is still yours to add. url_launcher is subject to Android 11 package visibility like anything else; see the Android section above. On iOS add CFBundleURLSchemes for your return scheme and nothing else: launchUrl needs no LSApplicationQueriesSchemes entries, canLaunchUrl does.
  • Check android.permission.INTERNET in your release manifest. Flutter's templates declare it in the debug and profile manifests only. A release build without it has no network at all, the WebView included, and the symptom looks like a broken payment page rather than a missing permission.

flutter_inappwebview is a good alternative when you need more control over popups and cookies. The classification logic is identical, on shouldOverrideUrlLoading, which must be enabled explicitly.

React Native

With react-native-webview:

<WebView
  source={{ uri: paymentUrl }}
  // The default whitelist blocks non-http(s) URLs before you can see them.
  originWhitelist={['*']}
  javaScriptEnabled
  domStorageEnabled
  thirdPartyCookiesEnabled
  sharedCookiesEnabled
  setSupportMultipleWindows={false}
  onShouldStartLoadWithRequest={(request) => {
    const decision = classify(request.url);
    if (decision === 'loadHere') return true;
    if (decision === 'returnToApp') finishPayment(request.url);
    else Linking.openURL(request.url).catch(showNoBankAppMessage);
    return false;
  }}
  onNavigationStateChange={(state) => {
    // Backstop: not every Android navigation reaches the hook above.
    if (classify(state.url) === 'returnToApp') finishPayment(state.url);
  }}
/>

Notes:

  • originWhitelist defaults to http/https only. Leave it at the default and custom-scheme navigations never reach your handler.
  • onShouldStartLoadWithRequest is not called for every Android navigation, form posts in particular. Keep onNavigationStateChange as a backstop for spotting the return URL, and make finishPayment idempotent, since both can fire for the same navigation.
  • setSupportMultipleWindows={false} makes target="_blank" load in place instead of vanishing. If you need real popups, set it true and handle onOpenWindow.
  • Linking.openURL rejects when nothing can handle the URL. Catch it and tell the customer their banking app could not be opened. Linking.canOpenURL needs the Android <queries> block to answer honestly.
  • Receiving the return uses Linking.addEventListener('url', …) for the warm road and Linking.getInitialURL() for the cold one. Both, or cold starts land on your home screen.

Configuration checklist

PlatformWhat to addWhy
Android<queries> with a VIEW intentWithout it the hand-off resolves to nothing
Androidintent-filter for the return URL, launchMode="singleTask", onNewIntentThe return leg, warm and cold
Androidassetlinks.json on the return host, with the release signing fingerprintOnly for a verified App Link. A debug-only fingerprint fails on release builds
AndroiddomStorageEnabled, third-party cookies, WebChromeClientThe payment page needs all three
iOSCFBundleURLSchemes for a custom schemeThe return leg, and the option with no capability to enable
iOSAssociated Domains entitlement + apple-app-site-associationOnly for a Universal Link return. Must be on the App ID and in the signing profile
iOSWKUIDelegate for popups, default WKWebsiteDataStorePayment steps that open a window; session cookies across the app switch
Bothreturn_url carrying your checkout_idThe cold return has nothing else
NeitherA list of banking-app URL schemes or package namesNot needed, not maintainable. Hand off to the OS instead
Embedded flowsThe same navigation handling as a checkout you drive yourselfThe WebView is yours even when the flow inside it is not

Troubleshooting

SymptomCause
ERR_UNKNOWN_URL_SCHEME, or a blank screen after picking a bankNon-http(s) navigations are not being handed to the OS
A hosted flow you only embedded dies at the bank stepThe embed brought the flow, not the hand-off. The WebView is still yours to configure
Nothing happens at all when the customer picks a bank (iOS)The navigation was allowed instead of cancelled-and-opened, or createWebViewWith returned nil
The bank app opens, and the customer never comes backreturn_url is not registered as a deep link for your app
The customer comes back to your home screen, not the orderThe cold return is not handled. Read the launch URL, not only the live callback
The page renders but buttons do nothingJavaScript or DOM storage disabled
The spinner never stopsA declined navigation emits no page-finished event
Works in a browser, fails in the app, same accountThe user agent was overridden to a desktop string
The result screen says "not found"An id from the returned query was merged over your route's checkout_id
One bank app switches, another silently loads a web pageExpected. That bank is reached by a verified app link, not a scheme
The hand-off works for months, then fails for one bankA canOpenURL pre-flight against a scheme list that has gone stale
One payment method app-switches, the rest hit a dead screenThe classifier allow-lists known schemes and defaults to "load it"
The return works in-app, but "not found" after the bankA custom scheme's first path segment was parsed as the host
The return works in development, opens a browser on a distributed buildiOS: the Associated Domains entitlement is missing from the signing profile. Android: assetlinks.json pins the wrong signing fingerprint

Turn on remote debugging in acceptance. A WebView has no console until you do: WebView.setWebContentsDebuggingEnabled(true) on Android (then chrome://inspect), webView.isInspectable = true on iOS 16.4+ (then Safari's Develop menu). Most of the table above is one look at the network tab.

Test on a real device. Emulators have no banking apps installed, so the hand-off path is exactly the path you cannot exercise there. Payment methods that stay on the web will pass on a simulator while the flow your customers use is broken.

Test against a probe page, not against the payment page. Reaching a real bank hand-off means confirming a real order, which is not something you can do on every build. Host a page with four links: an https page, a bank-style custom scheme, an intent:// URL with a browser_fallback_url, and your own return_url. Point your WebView at it and every branch of the classifier runs in seconds, including the one where nothing can open the URL.

Kill the app while the customer is in the bank. The cold return is the leg that breaks in production and passes in testing. Force-stop from the OS while the banking app has focus, then complete the payment: the return must still land on your result screen. On Android, "Don't keep activities" in developer options makes this the default rather than the exception.

What not to do

  • Do not put payment_url in an iframe. The provider's page refuses to be framed, and so do the banks after it.
  • Do not fetch, proxy or rewrite it server-side. It is a URL for a browser, bound to a session; anything else you do with it breaks the payment.
  • Do not spoof the user agent.
  • Do not strip the query string off the return navigation before your result screen sees it.
  • Do not re-open a payment_url after expires_at. Start a new checkout.
  • Do not scrape the payment page to detect success. Poll GET /checkouts/{checkout_id}, the only source of truth for whether the customer paid.

Did this page help you?