Mobile apps and WebViews
This guide is for anyone whose customers open a Hubpay payment page inside a mobile app — your own iOS or Android app, a React Native app, or an app built with a platform such as Salesforce Mobile Publisher.
First choose one integration pattern. A direct WebView and an iframe inside a merchant wrapper page have different event and origin boundaries.
For a native in-app experience, create the payment request with executionMode: "EMBEDDED" and load the returned paymentUrl directly in the WebView. Hubpay removes hosted-page chrome and sends lifecycle events to the app's native bridge. Use a browser sheet with HOSTED_PAGE when browser-managed compatibility matters more than an embedded presentation.
| Pattern | Execution mode | Result handling |
|---|---|---|
The WebView loads the Hubpay paymentUrl directly | EMBEDDED | Use a verified native bridge. A source-aware native iOS wrapper may instead intercept the configured success/cancel return URLs; Android and generic React Native WebViews must leave HTTPS returns in the WebView |
| The WebView loads your HTTPS page, which mounts Hubpay in an iframe | EMBEDDED | Your page receives browser events and relays them to the app |
The app opens Chrome Custom Tab or SFSafariViewController | HOSTED_PAGE | Use redirects and server-side confirmation |
Do not append embedded=true yourself. It is already present on the URL returned for an EMBEDDED payment request. Load that complete URL so its access parameters are preserved. Native iOS and Android apps must install the verified native message bridge before loading so Hubpay can detect the app container from the first render. React Native can use the lightweight container signal shown below when the app does not consume events. The origin browser parameter is not needed in a direct WebView.
Standard card and bank-transfer journeys do not need custom URL-scheme handling. Crypto wallet apps do. Apple Pay and Google Pay also depend on the WebView and iframe capabilities described below, so verify every payment method you enable on physical devices.
Do I need this page?
Whether any setup is needed depends on where the payment page opens on the payer's phone:
| Where the payment page opens | Crypto wallet-app hand-off |
|---|---|
| The phone's browser app — the payer tapped a payment link and Chrome or Safari opened | ✅ Work — nothing to do |
| A browser sheet your app opens — a Chrome Custom Tab (Android) or SFSafariViewController (iOS) | ✅ Work — nothing to do |
| A screen inside your app that displays the page — a WebView (including apps built with Salesforce Mobile Publisher) | ⚠️ Broken until you follow this page |
If you are unsure which of these applies, ask your development team whether the mobile app opens the payment page in a WebView. If it does — or if this cannot be confirmed — follow the steps on this page.
The distinction matters because the first two are the phone's full browser, which knows how to open wallet apps. A WebView is a simplified browser component embedded in your app, and by default it does not open other apps.
This is a mobile-only concern. In a desktop browser, the payment page can use an installed wallet extension or show a QR code that the payer scans with a phone.
If your payers will not pay with crypto from a wallet app (MetaMask, Trust Wallet, Phantom and similar), the custom-scheme setup on this page is not needed. Still test card, bank-transfer and 3DS journeys in the actual app container. Apple Pay and Google Pay require the additional WebView capabilities described below.
Why crypto needs one extra step
When a payer chooses a wallet app on the payment page, the page opens that wallet using a different kind of link. Instead of starting with https:// like a normal web address, it starts with the wallet's own name — for example metamask:// or phantom://.
The phone's browser knows what to do with these links: it opens the wallet app. A WebView does not. By default it blocks the link and replaces the payment page with an error that reads net::ERR_UNKNOWN_URL_SCHEME, leaving the payer unable to continue.
The fix is always the same idea, whatever your platform: when the payment page opens one of Hubpay's wallet schemes, hand that link to the phone instead of blocking it. The phone then opens the wallet app, and the payer approves and pays there. Allowlist the schemes listed below; do not forward every unknown scheme from web content to the operating system.
Instead of configuring the WebView, you can open the payment page in a browser sheet — a Chrome Custom Tab on Android or SFSafariViewController on iOS. The sheet appears within your app but is the phone's full browser, so wallet payments work with no configuration.
Handle the return to your app
When no verified lifecycle bridge is connected, Hubpay follows the successRedirectUrl or cancelRedirectUrl configured on the payment request. An HTTPS return page may therefore remain inside a direct WebView. Do not close the native payment screen merely because a navigation targets the main frame: a child payment frame can request a main-frame navigation too.
The iOS example can verify both the source and target frame before matching the exact success or cancel URL supplied by your backend. Scheme, host, port and path must match; every configured query parameter and fragment must still be present with the configured value. Additional query parameters appended by Hubpay are allowed. Android's WebResourceRequest.isForMainFrame identifies the destination document, not the frame that initiated the navigation, so the basic Android example deliberately leaves HTTPS returns in the WebView and blocks custom schemes. Use the origin-restricted lifecycle bridge for host navigation, or a Chrome Custom Tab when external wallet-app links are required. After a success event or return, confirm the final payment through your backend before fulfilling the order.
Salesforce (Mobile Publisher)
If your customer-facing app is built with Salesforce Mobile Publisher for Experience Cloud, no code is required — this is a configuration change in Salesforce.
-
In Salesforce Setup, type Mobile Publisher in the Quick Find box and open your app's project.
-
Open the Android Setup and iOS Setup sections.
-
In each one, find the field called Allowlist Custom URL Schemes.
-
Add the following list (comma-separated):
metamask, trust, okx, bitkeep, tpdapp, phantom, tronlinkoutside -
Save, build the branded app, and test on physical Android and iOS devices before releasing.
Salesforce's own references for these settings: Manage How URLs Open from Your App and Deep Linking.
Two details from Salesforce's documentation are worth highlighting:
- The Allowlist Custom URL Schemes field and the URL Management section are separate settings. URL Management controls how normal web links open, and it does not apply to links inside the Hubpay payment page — the allowlist is the setting that matters here.
- Without the allowlist, wallet payments in your app fail with
ERR_UNKNOWN_URL_SCHEME— the exact problem described above. - Salesforce does not support outbound custom URL schemes in Publisher Playground. It can validate the surrounding page, but the crypto wallet hand-off must be tested in a real branded build.
For other Salesforce surfaces (for example, links opened inside the main Salesforce mobile app used by your staff), there is no allowlist setting. Open the payment page in the phone's browser instead.
Android (WebView)
If your Android app shows the payment page in a WebView, add the following conservative URL handling to your WebViewClient:
This callback requires Android 7 (API 24) or newer — the versions the wallet apps themselves support.
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val scheme = request.url.scheme?.lowercase()
if (scheme == "http" || scheme == "https") return false
return true // Never turn an unverified frame navigation into a native action.
}
isForMainFrame says which document the request will replace; it does not identify the frame that initiated it. Android's JavaScript bridge security guidance also warns that WebView.getUrl() cannot authenticate that source frame. Do not call startActivity, close the checkout, or update payment state from this callback. Configure HTTPS success and cancel URLs for the basic WebView and allow them to load, use the verified hubpayCheckout lifecycle listener below to drive native navigation, or use a Chrome Custom Tab when the journey needs an external wallet app.
For Google Pay in a native Android WebView, use AndroidX WebKit 1.14.0 or later, check WebViewFeature.PAYMENT_REQUEST, enable it with WebSettingsCompat.setPaymentRequestEnabled(webView.settings, true), and add the payment intent queries required by Android's WebView documentation. Payment Request is disabled by default. If the device WebView does not support the feature, use a Chrome Custom Tab.
To receive embedded lifecycle events, install an origin-restricted AndroidX WebKit message listener named hubpayCheckout. Support depends on the installed WebView provider, not only the Android API level, so check WebViewFeature.WEB_MESSAGE_LISTENER before loading checkout. If the feature is unavailable, open the complete paymentUrl in a Chrome Custom Tab instead. Replace the origin below with the origin of the returned paymentUrl for the environment you are using:
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
openPaymentInChromeCustomTab(paymentUrl)
return
}
val hubpayOrigin = Uri.parse("https://pay.hubpay.ae")
WebViewCompat.addWebMessageListener(
webView,
"hubpayCheckout",
setOf(hubpayOrigin.toString()),
) { _, message, sourceOrigin, isMainFrame, _ ->
val expectedOrigin =
sourceOrigin.scheme == hubpayOrigin.scheme &&
sourceOrigin.host == hubpayOrigin.host &&
sourceOrigin.port == hubpayOrigin.port
if (!isMainFrame || !expectedOrigin) {
return@addWebMessageListener
}
handleHubpayEvent(message.data) // JSON: hubpay:ready, hubpay:step, etc.
}
Do not use addJavascriptInterface as a compatibility fallback. The AndroidX listener provides an origin allowlist and main-frame information that a general JavaScript interface does not.
iOS (WKWebView)
If your iOS app shows the payment page in a WKWebView, add the following to your WKNavigationDelegate. Web links stay in the WebView, known wallet links go to the system and unknown schemes are blocked.
private let hubpayWalletSchemes: Set<String> = [
"metamask", "trust", "okx", "bitkeep", "tpdapp", "phantom", "tronlinkoutside",
]
private let hubpayOrigin = URL(string: paymentUrl)!
private let merchantReturnTargets = [successRedirectURL, cancelRedirectURL].compactMap { $0 }
private func isSameOrigin(_ url: URL?, target: URL) -> Bool {
url?.scheme?.lowercased() == target.scheme?.lowercased() &&
url?.host?.lowercased() == target.host?.lowercased() &&
url?.port == target.port
}
private func hasRequiredQuery(_ url: URL, target: URL) -> Bool {
let candidateItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
let requiredItems = URLComponents(url: target, resolvingAgainstBaseURL: false)?.queryItems ?? []
return Set(requiredItems.map(\.name)).allSatisfy { name in
candidateItems.filter { $0.name == name }.map(\.value) ==
requiredItems.filter { $0.name == name }.map(\.value)
}
}
private func isConfiguredMerchantReturn(_ url: URL) -> Bool {
merchantReturnTargets.contains { target in
url.scheme?.lowercased() == target.scheme?.lowercased() &&
url.host?.lowercased() == target.host?.lowercased() &&
url.port == target.port &&
url.path == target.path &&
hasRequiredQuery(url, target: target) &&
(target.fragment == nil || url.fragment == target.fragment)
}
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url,
let scheme = url.scheme?.lowercased() else {
decisionHandler(.cancel)
return
}
if navigationAction.sourceFrame.isMainFrame,
navigationAction.targetFrame?.isMainFrame == true,
isSameOrigin(webView.url, target: hubpayOrigin),
isConfiguredMerchantReturn(url) {
handleMerchantReturn(url) // close/navigate; confirm success server-side
decisionHandler(.cancel)
return
}
if navigationAction.sourceFrame.isMainFrame,
navigationAction.targetFrame?.isMainFrame == true,
isSameOrigin(webView.url, target: hubpayOrigin),
hubpayWalletSchemes.contains(scheme) {
UIApplication.shared.open(url)
decisionHandler(.cancel)
return
}
decisionHandler(scheme == "http" || scheme == "https" ? .allow : .cancel)
}
Both frame checks are intentional: sourceFrame identifies the frame that requested the navigation, while targetFrame identifies the frame that would display it. Requiring both prevents a child payment frame from asking the main frame to launch an app or close checkout.
To receive embedded lifecycle events, add a WKScriptMessageHandler named hubpayCheckout to the WebView configuration before loading the payment URL. In userContentController(_:didReceive:), require message.frameInfo.isMainFrame, verify message.frameInfo.securityOrigin matches the returned payment URL's origin, then parse message.body as the JSON event string. Do not install a general-purpose bridge for arbitrary pages.
React Native (react-native-webview)
Create the request with executionMode: "EMBEDDED", then load the returned paymentUrl unchanged. The basic integration sets a no-op onMessage handler so react-native-webview exposes its JavaScript bridge and Hubpay can distinguish the app container from an ordinary browser tab. Hubpay does not send lifecycle events through this generic bridge: it uses compact presentation but keeps its own Back control and merchant redirects.
The generic React Native callback does not expose WebKit's source frame. Its isTopFrame value describes the navigation destination, so a child payment frame can still request a top-level navigation. Do not launch wallet apps, close checkout or update payment state from onShouldStartLoadWithRequest on either platform. Use a source-aware native WKNavigationDelegate wrapper on iOS. On Android, use the origin-restricted lifecycle wrapper for host navigation and a Chrome Custom Tab when the journey needs an external wallet app.
import { WebView } from "react-native-webview";
<WebView
source={{ uri: paymentUrl }}
paymentRequestEnabled
onMessage={() => {
// Container signal only. Do not change application or payment state here.
}}
onShouldStartLoadWithRequest={(request) => {
const scheme = request.url.split(":", 1)[0].toLowerCase();
if (scheme === "http" || scheme === "https") return true;
return false; // Never turn an unverified frame navigation into a native action.
}}
/>;
Keep this basic onMessage handler as a no-op. Its presence is only a container signal. Do not change application state from event.nativeEvent.data alone. The generic bridge can be visible to child frames without trustworthy frame metadata, so a third-party payment frame could imitate a hubpay:* event. HTTPS success and cancel redirects remain in this basic WebView. Use a source-aware native wrapper when the host must navigate itself; use a browser sheet or Custom Tab for external wallet apps. In every case, confirm the result through the server-side webhook or API.
If the app needs host-owned Back and lifecycle events, use a native bridge that authenticates both the source origin and the main frame: the named AndroidX WebKit listener or iOS WKScriptMessageHandler described above. A custom React Native wrapper can expose the named hubpayCheckout bridge to the Hubpay main document and pass its verified native result to application JavaScript. On iOS, that wrapper can also apply the source-aware WKNavigationDelegate checks above. The Android message listener authenticates lifecycle messages but does not make shouldOverrideUrlLoading source-aware, so use a Custom Tab for external wallet-app journeys. Do not set nav=host or suppress redirects until the verified channel is installed.
On Android, the paymentRequestEnabled prop in the example enables Google Pay support. It requires react-native-webview version 13.15.0 or later and is disabled by default.
On iOS, enableApplePay is also disabled by default. Enabling it changes which script-injection, history and cookie features are available in react-native-webview; in particular, injectJavaScript is unavailable. Leave nav unset so Hubpay keeps its own Back control, use redirect URLs as the app-navigation fallback, and test the complete Apple Pay journey on a physical device before release. See the react-native-webview API reference before enabling either browser wallet capability.
Embedded checkout inside a native WebView
The direct WebView described above is the normal native embedded pattern. It has one browser layer and does not need origin. With a verified native bridge it receives lifecycle events directly; without one it retains Hubpay navigation and merchant redirects.
Use a nested wrapper only when the WebView must load an HTTPS page you control and that page must display merchant content around a child Hubpay iframe. The payment request still uses executionMode: "EMBEDDED".
The secure browser-event channel works in this arrangement, but wallet-app navigation, pop-ups, Apple Pay and Google Pay remain controlled by the outer WebView and every iframe ancestor. Native navigation callbacks do not behave identically for child frames on every platform. Prefer a direct EMBEDDED WebView when the wrapper is not essential.
The wrapper page's HTTPS origin is the origin Hubpay needs. It is not the app's bundle identifier or custom scheme. Hubpay events stop at the wrapper page, so its onEvent callback must relay each event through a named iOS script-message handler, an origin-restricted Android WebView message listener, or a React Native wrapper around one of those verified native channels.
If there is another iframe above the wrapper page, every ancestor must allow the browser's payment capability. If you do not control those ancestor frames, use a direct WebView or a HOSTED_PAGE browser sheet for Apple Pay and Google Pay.
Never use a file:, data: or inline HTML wrapper for this pattern. Its opaque origin cannot be used as a secure browser-message destination. The same applies to a wrapper sandboxed without allow-same-origin. Serve the wrapper from HTTPS and ensure it keeps its normal web origin.
The wallet links we use
If your platform asks you to list the exact link types (as Salesforce does), these are the ones the payment page currently uses:
| Wallet | Link starts with |
|---|---|
| MetaMask | metamask:// |
| Trust Wallet | trust:// |
| OKX | okx:// |
| Bitget | bitkeep:// |
| TokenPocket | tpdapp:// |
| Phantom | phantom:// |
| TronLink | tronlinkoutside:// |
Allowlist these schemes exactly. If Hubpay adds a wallet, update the allowlist after testing that wallet rather than forwarding every unknown scheme from web content.
How to test it
- Create a sandbox payment request using the execution mode for your chosen pattern and open it inside your app.
- Choose crypto, then a wallet that is installed on the test phone (for example, MetaMask).
- The wallet app should open, showing the payment page inside the wallet's own browser.
- Approve and pay in the wallet. Confirm that the app returns to the intended page and that your backend receives or retrieves the final status.
- Repeat on physical Android and iOS devices for every enabled payment method, including Apple Pay or Google Pay where offered.
If an error page mentioning ERR_UNKNOWN_URL_SCHEME appears instead, the setup above is not yet in place.
Common questions
Does this change how I confirm payments? No. Redirect URLs, webhooks and API lookups all behave exactly as described in Confirming payments. Payment status lives on Hubpay's servers, so it doesn't matter whether the payer finishes in your app's WebView or in their wallet's browser.
What if the payer doesn't have the wallet app installed? The payment page detects this and shows guidance, including the option to pay from an exchange account instead. Your app doesn't need to handle it.
Do card and bank transfer payments need custom URL-scheme handling? No. They remain in the page. Apple Pay and Google Pay are browser wallet methods, however, and require the WebView and iframe payment capabilities described above.
We embed the checkout in an iframe on the web — does this apply? Desktop and mobile web browsers handle wallet links natively; this page is only about WebViews inside native apps. For the web iframe integration, see Embedded checkout.