For Android
Comparison of Android WebView
| Capability | WebView | Custom Tabs |
|---|---|---|
| Embed inside your own UI | Yes | No (modal browser) |
Receive postMessage from Payment capture page in native code | Yes | No — use successUrl / cancelUrl redirects |
| Apple Pay | No | No |
| Google Pay | Yes | Yes |
| Initial setup effort | Moderate | Minimal |
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.
- Update the build dependency.
- Update
AndroidManifest.xmlwith the required Google Pay queries. - 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:
- A popup window for 3DS or issuer authentication using
window.open()ortarget="_blank". - 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+
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.
| Approach | Payment capture page URL | Initiation | Native code changes required |
|---|---|---|---|
| Option A: Payment capture page Submit button | Include submitbutton=true | User taps Submit inside Payment capture page | Not required |
| Option B: Native app button | Omit submitbutton | User taps the native app’s button | Yes — 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:
- A JavaScript bridge using
@JavascriptInterface, so JavaScript can call into Kotlin. - An injected
window.addEventListener("message", ...)listener that forwards PCPsuccessanderrormessages 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
postMessage bridgeCall 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://...)
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
shouldOverrideUrlLoadingand launch an externalIntent. - Ensure the same
WebViewClientis 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
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)
}Updated 6 days ago