Comparison of Android WebView

CapabilityWebViewCustom Tabs
Embed inside your own UIYesNo (modal browser)
Receive postMessage from Payment capture page in native codeYesNo — use successUrl / cancelUrl redirects
Apple PayNoNo
Google PayYesYes
Initial setup effortModerateMinimal

WebView

Payment method-specific setup

Google Pay

Make the following code changes in your Android application. Integrators should follow Google’s official documentation: Using Android WebView.

  1. Update the build dependency.
  2. Update AndroidManifest.xml with the required Google Pay queries.
  3. Enable JavaScript and the Payment Request API in the WebView that embeds the PCP URL.

Example WebView setup:

settings.javaScriptEnabled = true

if (WebViewFeature.isFeatureSupported(WebViewFeature.PAYMENT_REQUEST)) {
    WebSettingsCompat.setPaymentRequestEnabled(settings, true)
}

webView.loadUrl(pcpUrl)

Register your mobile application for production usage. For Google Pay production usage, register your application in the Google Pay & Wallet Console.

Follow Google’s documentation on publishing your integration and obtain Google Pay approval for the application.


GCash / 3DS setup

Some payment methods, such as cards and certain wallets or banks, may open:

  1. A popup window for 3DS or issuer authentication using window.open() or target="_blank".
  2. A deep link to an external app, such as gcash://..., to complete authentication in a wallet or banking app.

Enable popup windows

When a 3DS or wallet authentication page calls window.open(), Android invokes WebChromeClient.onCreateWindow.

You must create a child WebView, assign it to the WebViewTransport in the Message, and display it, commonly in a full-screen Dialog.

When the authentication page calls window.close(), onCloseWindow is triggered. At that point, dismiss the dialog and call destroy() on the popup WebView.

Apply the following WebView settings to both the main Payment capture page WebView and every popup WebView:

settings.apply {
    javaScriptEnabled = true
    domStorageEnabled = true

    // Allow window.open() and target="_blank" to create popups.
    setSupportMultipleWindows(true)

    // Allow popups to open without requiring an additional tap,
    // where supported by the browser/WebView.
    javaScriptCanOpenWindowsAutomatically = true
}

Attach the following popup window implementation to your main Payment capture page WebView:

import android.app.Dialog
import android.os.Message
import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.WebChromeClient
import android.webkit.WebView

// Keep track of open popups so they can be dismissed and destroyed cleanly.
private val openPopups = mutableMapOf<WebView, Dialog>()

fun setupPcpWebChromeClient(webView: WebView) {
    // Enable popup support on the main WebView.
    webView.settings.setSupportMultipleWindows(true)
    webView.settings.javaScriptCanOpenWindowsAutomatically = true

    webView.webChromeClient = object : WebChromeClient() {
        override fun onCreateWindow(
            view: WebView?,
            isDialog: Boolean,
            isUserGesture: Boolean,
            resultMsg: Message?,
        ): Boolean {
            val parent = view ?: return false
            return openPopupWindow(parent, resultMsg)
        }

        override fun onCloseWindow(window: WebView?) {
            // The authentication page called window.close().
            window?.let { dismissPopupWebView(it) }
        }
    }
}

private fun openPopupWindow(parent: WebView, resultMsg: Message?): Boolean {
    // Android provides the child WebView slot through Message.obj.
    val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false

    val popup = WebView(parent.context).apply {
        // Apply the same settings as the parent WebView.
        settings.javaScriptEnabled = true
        settings.domStorageEnabled = true
        settings.setSupportMultipleWindows(true)
        settings.javaScriptCanOpenWindowsAutomatically = true

        // The popup also needs the external URL handler.
        webViewClient = createExternalUrlWebViewClient()

        // Handle rare nested popups during multi-step authentication.
        webChromeClient = object : WebChromeClient() {
            override fun onCreateWindow(
                view: WebView?,
                isDialog: Boolean,
                isUserGesture: Boolean,
                msg: Message?,
            ): Boolean = openPopupWindow(view ?: this@apply, msg)

            override fun onCloseWindow(window: WebView?) {
                window?.let { dismissPopupWebView(it) }
            }
        }
    }

    // Show the popup WebView in a full-screen Dialog.
    val dialog = Dialog(parent.context).apply {
        setContentView(
            popup,
            ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT,
            ),
        )

        window?.setLayout(
            WindowManager.LayoutParams.MATCH_PARENT,
            WindowManager.LayoutParams.MATCH_PARENT,
        )

        // If the user presses back or dismisses the dialog, clean up the popup WebView.
        setOnDismissListener {
            dismissPopupWebView(popup)
        }
    }

    openPopups[popup] = dialog

    // Tell the WebView engine which instance is the popup, then show it.
    transport.webView = popup
    resultMsg.sendToTarget()

    dialog.show()

    // Return true to indicate that the popup was handled.
    return true
}

private fun dismissPopupWebView(webView: WebView) {
    val dialog = openPopups.remove(webView) ?: return

    dialog.setOnDismissListener(null)

    if (dialog.isShowing) {
        dialog.dismiss()
    }

    webView.stopLoading()
    webView.destroy()
}

Intercept non-HTTP(S) URLs and open external apps

When the user taps an option such as Open GCash app inside the authentication popup, the page may navigate to a custom scheme such as gcash://....

Android WebView cannot load these URLs directly. You must intercept them in WebViewClient.shouldOverrideUrlLoading() and launch the external app using an Intent.

At minimum, handle:

  • Custom schemes, such as gcash://, myapp://, and similar app-specific schemes.
  • intent:// URLs, which are common on Android.
  • browser_fallback_url, when the target app is not installed.
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

fun createExternalUrlWebViewClient(): WebViewClient = object : WebViewClient() {
    override fun shouldOverrideUrlLoading(
        view: WebView?,
        request: WebResourceRequest?,
    ): Boolean {
        val url = request?.url?.toString() ?: return false
        return handleExternalUrl(view, url)
    }
}

private fun handleExternalUrl(view: WebView?, url: String): Boolean {
    val scheme = Uri.parse(url).scheme?.lowercase()

    // Let WebView handle normal HTTP and HTTPS page loads.
    if (scheme == null || scheme == "http" || scheme == "https") {
        return false
    }

    val context = view?.context ?: return true

    return try {
        val intent = if (url.startsWith("intent://", ignoreCase = true)) {
            // Android-specific deep link format used by many wallet and banking apps.
            Intent.parseUri(url, Intent.URI_INTENT_SCHEME).apply {
                addCategory(Intent.CATEGORY_BROWSABLE)
                component = null
                selector = null
            }
        } else {
            // Direct custom scheme, for example:
            // gcash://com.mynt.gcash/app/...
            Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
                addCategory(Intent.CATEGORY_BROWSABLE)
            }
        }

        context.startActivity(intent)

        // The URL was handled. Do not let WebView load it,
        // otherwise it may show ERR_UNKNOWN_URL_SCHEME.
        true
    } catch (_: ActivityNotFoundException) {
        // If the target app is not installed, try the fallback URL from intent:// links.
        if (url.startsWith("intent://", ignoreCase = true)) {
            val fallbackUrl = runCatching {
                Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
                    .getStringExtra("browser_fallback_url")
            }.getOrNull()

            if (fallbackUrl != null) {
                view?.loadUrl(fallbackUrl)
                return true
            }
        }

        // Return true to suppress the WebView error page.
        true
    }
}

Apply createExternalUrlWebViewClient() to both:

  • The main Payment capture page WebView.
  • Every 3DS or authentication popup WebView.

If this handler is added only to the parent WebView, wallet buttons inside popup windows may still fail with ERR_UNKNOWN_URL_SCHEME.

Update AndroidManifest.xml for package visibility on Android 11+

Starting from Android 11, package visibility restrictions require apps to declare which external apps or URL schemes they need to query or open.

Keep your existing https and Google Pay queries, and add entries for the wallet or banking apps your customers use.

Example:

<queries>
    <!-- Existing HTTPS and Google Pay queries -->

    <!-- Wallet deep links from 3DS/authentication pages -->
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="gcash" />
    </intent>

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

    <package android:name="com.mynt.gcash" />
</queries>

Add a <data android:scheme="..." /> entry and/or a <package android:name="..." /> entry for each wallet or banking app you support in production.


Submitting Payment Method

This depends on how you implement the Submit button to send the information from Payment capture page.

ApproachPayment capture page URLInitiationNative code changes required
Option A: Payment capture page Submit buttonInclude submitbutton=trueUser taps Submit inside Payment capture pageNot required
Option B: Native app buttonOmit submitbuttonUser taps the native app’s buttonYes — inject create postMessage

Payment capture page Submit button

Call the Payment capture page with submitbutton=true and the payment method submission is handled automatically.

Native app button

Inject the create message

Call this after the user has entered their payment details in Payment capture page and taps your native Add payment method button:

import android.webkit.WebView
import org.json.JSONObject

/**
 * Dispatches the same message that a web parent page would send:
 *
 * postMessage({ actionType: "create" }, targetOrigin)
 *
 * This runs inside the WebView page through evaluateJavascript.
 * It is not a native postMessage API.
 */
fun WebView.postMessageEmbedCreateAction(targetOrigin: String) {
    val originLiteral = JSONObject.quote(targetOrigin)

    val script = """
        (function() {
            try {
                window.postMessage({ "actionType": "create" }, $originLiteral);
            } catch (e) {
                console.error("postMessage embed create", e);
            }
        })();
    """.trimIndent()

    evaluateJavascript(script, null)
}

Wire it up from a native button

// Match the PCP URL loaded in your environment.
val targetOrigin = "https://hosted-global.ezypay.com"

var webView: WebView? = null

Button(
    onClick = {
        webView?.postMessageEmbedCreateAction(targetOrigin)
    }
) {
    Text("Add payment method")
}

Receiving messages from Payment capture page

Payment capture page returns the result using window.postMessage. Android WebView does not provide a built-in postMessage listener.

You need two components:

  1. A JavaScript bridge using @JavascriptInterface, so JavaScript can call into Kotlin.
  2. An injected window.addEventListener("message", ...) listener that forwards PCP success and error messages to the bridge.

Re-inject the listener on every onPageFinished event. PCP may navigate during tokenisation, for example during 3DS authentication, and navigation clears injected scripts.

Set up the JavaScript postMessage bridge

Call this once when creating the WebView.

The JavaScript bridge name, PcpEmbedBridge, must match in both the bridge and the injected listener.

import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import org.json.JSONObject

private const val PCP_EMBED_BRIDGE_JS_NAME = "PcpEmbedBridge"

private const val PCP_POST_MESSAGE_LISTENER = """
(function() {
    if (window.__pcpEmbedPostMessageListener) return;

    window.__pcpEmbedPostMessageListener = true;

    window.addEventListener('message', function(event) {
        try {
            var data = event.data;

            if (typeof data === 'string') {
                try {
                    data = JSON.parse(data);
                } catch (e) {
                    return;
                }
            }

            if (!data || typeof data !== 'object') return;
            if (data.type !== 'success' && data.type !== 'error') return;

            PcpEmbedBridge.onEmbedMessage(
                JSON.stringify(data),
                event.origin || ''
            );
        } catch (e) {
            console.error('PcpEmbedBridge', e);
        }
    }, false);
})();
""".trimIndent()

class PcpEmbedMessageBridge(
    private val onResult: (type: String, json: String, origin: String) -> Unit,
) {
    private val mainHandler = Handler(Looper.getMainLooper())

    @JavascriptInterface
    fun onEmbedMessage(json: String, origin: String) {
        mainHandler.post {
            onResult(JSONObject(json).optString("type"), json, origin)
        }
    }
}

/**
 * Registers the bridge and re-injects the listener after each page load.
 */
fun WebView.setupPcpPostMessageReceiver(
    onResult: (type: String, json: String, origin: String) -> Unit,
) {
    addJavascriptInterface(
        PcpEmbedMessageBridge(onResult),
        PCP_EMBED_BRIDGE_JS_NAME,
    )

    webViewClient = object : WebViewClient() {
        override fun onPageFinished(view: WebView?, url: String?) {
            view?.evaluateJavascript(PCP_POST_MESSAGE_LISTENER, null)
        }
    }
}

Example usage when creating the WebView:

@SuppressLint("SetJavaScriptEnabled")
val webView = WebView(context).apply {
    settings.javaScriptEnabled = true
    settings.domStorageEnabled = true

    setupPcpPostMessageReceiver { type, json, origin ->
        handlePcpEmbedResult(type, json, origin)
    }

    loadUrl(pcpUrl)
}

If you use popup WebViews for 3DS authentication, call setupPcpPostMessageReceiver() on each popup WebView as well. Otherwise, you may miss the success or error message.

Handle success and error

fun handlePcpEmbedResult(type: String, json: String, origin: String) {
    // Optional but recommended:
    // Verify that the origin matches your PCP host,
    // for example: https://hosted-global.ezypay.com

    val data = JSONObject(json).optJSONObject("data") ?: return

    when (type) {
        "success" -> {
            val token = data.optString("paymentMethodToken")
            val displayName = data.optString("displayName")

            // Link the token to the customer, show a success page, etc.
        }

        "error" -> {
            val errorCode = data.optString("errorCode")
            val errorMessage = data.optString("errorMessage")

            // Show an error message, allow the user to retry, etc.
        }
    }
}

Troubleshooting

Google Pay Error

  • OR_BIBED_15
    Indicates that required setup steps for enabling Google Pay in WebView may be incomplete. Verify that device requirements and WebView configuration steps have been completed.

  • OR_BIBED_11
    Occurs only in the Google Pay production environment. Verify that the application is registered and approved in the Google Pay & Wallet Console.

  • Application not visible in Google Pay & Wallet Console
    Ensure that the logged-in Google account has access to the application in the Google Play Console.

Popup Error

  • Confirm setSupportMultipleWindows(true) and javaScriptCanOpenWindowsAutomatically = true.
  • Confirm WebChromeClient.onCreateWindow is implemented and returns true.
  • Confirm the popup WebView has javaScriptEnabled and domStorageEnabled.

net::ERR_UNKNOWN_URL_SCHEME (e.g. gcash://...)

  • The auth page is trying to open a wallet/banking app. WebView cannot load that URL directly.
  • Fix: implement shouldOverrideUrlLoading and launch an external Intent.
  • Ensure the same WebViewClient is used on the popup WebView, not only the parent.

Custom Tabs

Apply the following changes in the Android application (Kotlin example). successUrl and cancelUrl is mandatory to redirects users back to the app.

https://hosted-global-sandbox.ezypay.com/paymentmethod/embed?token=eyJhbGciOi...&countryCode=AU&successUrl=https%3A%2F%2Fexample.com%2Fsuccess&cancelUrl=https%3A%2F%2Fexample.com%2Fcancel

Update build dependency

build.gradle.kts

dependencies {
    implementation("androidx.browser:browser:<latest>")
}

libs.versions.toml

[versions]
browser = "1.8.0"

[libraries]
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }

Update AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Required because PCP is opened through HTTPS -->
    <uses-permission android:name="android.permission.INTERNET" />

    <!-- Recommended for browser intent handling -->
    <queries>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <category android:name="android.intent.category.BROWSABLE" />
            <data android:scheme="https" />
        </intent>
    </queries>

    <application android:theme="@style/Theme.YourApp">

        <activity android:name=".MainActivity" android:exported="true">

            <!-- Launcher entry point -->
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <!-- Required for deep link return (success/cancel URLs) -->
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />

                <!-- Must match successUrl/cancelUrl -->
                <data android:scheme="androidmobileapp" android:host="checkout" />
            </intent-filter>

        </activity>
    </application>

</manifest>

Launch Payment capture page URL with Custom Tabs

import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent

fun openPcpInCustomTab(context: Context, pcpUrl: String) {
    val uri = Uri.parse(pcpUrl)

    val customTabsIntent = CustomTabsIntent.Builder()
        .setShowTitle(true)
        .build()

    customTabsIntent.launchUrl(context, uri)
}


Did this page help you?