WebView & UPI setup
Everything the host app has to configure so the hosted embed behaves like a native screen — payments, popups, camera and all.
The hosted embed is a normal web app, but it does three things a plain WebView won't survive by default: it keeps a session in localStorage, it opens a popup window for recurring-plan mandates, and it hands the user off to a UPI app to pay. Each needs an explicit opt-in below. Skip one and the failure is usually silent — a button that does nothing rather than an error you can debug.
Android
javaScriptEnabled = true— required. The embed is a SPA.domStorageEnabled = true— required. The session token lives inlocalStorage; without it every screen re-prompts for OTP.setSupportMultipleWindows(true)plus aWebChromeClient.onCreateWindowoverride — required for the recurring-plan (SIP) mandate payment popup. Needs target SDK 24+. The flag alone does nothing; you must return the new WebView from the override.- Override
shouldOverrideUrlLoadingto forwardupi://andintent://URLs to the OS withIntent.parseUri(url, Intent.URI_INTENT_SCHEME). Otherwise the WebView tries to load them as pages and the payment dies. - Implement
onShowFileChooserand request theCAMERApermission — KYC selfie and document capture happen inside the WebView.
val webView = WebView(context)
webView.settings.apply {
javaScriptEnabled = true // required
domStorageEnabled = true // required — session lives in localStorage
setSupportMultipleWindows(true) // required — mandate popup (target SDK 24+)
javaScriptCanOpenWindowsAutomatically = true
}
// 1. Mandate popup: hand the child window back to the WebView.
webView.webChromeClient = object : WebChromeClient() {
override fun onCreateWindow(
view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message?
): Boolean {
val popup = WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = WebViewClient()
}
(resultMsg?.obj as? WebView.WebViewTransport)?.webView = popup
resultMsg?.sendToTarget()
return true
}
// 2. KYC document / selfie upload.
override fun onShowFileChooser(
view: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
params: FileChooserParams?
): Boolean {
pendingFileCallback = filePathCallback
startActivityForResult(params!!.createIntent(), REQ_FILE_CHOOSER)
return true
}
}
// 3. Forward UPI / intent deep links to the OS.
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(v: WebView?, r: WebResourceRequest?): Boolean {
val url = r?.url?.toString() ?: return false
if (url.startsWith("http://") || url.startsWith("https://")) return false
return try {
val intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
context.startActivity(intent)
true
} catch (e: Exception) {
// No UPI app installed — let the SPA fall back to its QR flow.
true
}
}
}
webView.loadUrl(embedUrl)iOS
- A single shared
WKProcessPoolper instance — it persists cookies andlocalStorageacross WebViews. Create it once and reuse it; a fresh pool per screen logs the user out. - Implement
webView(_:decidePolicyFor:decisionHandler:)and forward every non-http(s)scheme toUIApplication.shared.open. allowsInlineMediaPlayback = trueandmediaTypesRequiringUserActionForPlayback = []— the KYC camera preview is inline media.- Implement
webView(_:createWebViewWith:for:windowFeatures:)for the mandate popup. The default WKWebView drops target-blank navigations entirely.
// Create ONE pool and reuse it — this is what keeps the session alive.
let sharedProcessPool = WKProcessPool()
let config = WKWebViewConfiguration()
config.processPool = sharedProcessPool
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
let webView = WKWebView(frame: .zero, configuration: config)
webView.uiDelegate = self
webView.navigationDelegate = self
webView.load(URLRequest(url: URL(string: embedUrl)!))
// 1. Forward non-http(s) schemes (upi://, phonepe://, …) to the OS.
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let url = navigationAction.request.url,
let scheme = url.scheme?.lowercased(),
!["http", "https", "about", "file"].contains(scheme) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
// 2. Mandate popup: load target-blank navigations in the same WebView.
func webView(_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures) -> WKWebView? {
if navigationAction.targetFrame == nil {
webView.load(navigationAction.request)
}
return nil
}UPI deep links
Depending on which apps the user has installed, the SPA may try to open any of these schemes:
upi://,intent://— the generic UPI handoff and the Android chooser.tez://,gpay://,paytmmp://,phonepe://,bhim://,credpay://,amazonpay://,mobikwik://— app-specific handoffs.
shouldOverrideUrlLoading fires for that inner frame rather than the top-level one. Let it through — don't filter on isForMainFrame, or recurring plans break while one-off buys keep working, which is a miserable bug to chase.On iOS every scheme you intend to open must be declared in LSApplicationQueriesSchemes in Info.plist, or canOpenURL returns false and the handoff fails silently — no prompt, no error, nothing in the console.
<key>LSApplicationQueriesSchemes</key>
<array>
<string>upi</string>
<string>tez</string>
<string>gpay</string>
<string>paytmmp</string>
<string>phonepe</string>
<string>bhim</string>
<string>credpay</string>
<string>amazonpay</string>
<string>mobikwik</string>
</array>Push inside the embed
WebViews can't use service-worker push, so there is no browser notification to subscribe to. For embed-originated users OroPocket automatically routes transactional alerts — buy success, SIP installment, withdrawal status — to email and WhatsApp instead.
Support matrix
| Component | Minimum |
|---|---|
| Android System WebView | Chromium 100+ (default on Android 7.1+ with current Play Services) |
| Android target SDK | API 24 (7.0) minimum — required for setSupportMultipleWindows / onCreateWindow |
| iOS WKWebView | 15.0+ (iOS ≤14 has intermittent localStorage wipes) |
| Desktop debug | Chrome 110+, Safari 16+, Firefox 110+, Edge 110+ |
Smoke checklist
Run this on a real device before you ship. Each step exercises a different setting above.
- Cold open — the OTP screen should appear and the code arrive within ~2 seconds on cellular, not Wi-Fi.
- Verify the OTP — 1234 in sandbox. Proves DOM storage is on.
- Buy ₹100 of gold via a UPI intent app — proves deep-link forwarding works and the app returns you to the embed.
- Buy ₹100 of gold via the QR / iframe fallback — the path taken when no UPI app is installed.
- Sell ₹100 of gold — proceeds land in the user's INR balance.
- Set up a recurring plan — the mandate popup opens. This is what multi-window support is for; if you skipped it, this is the step that fails.
- Upload a KYC document from the gallery — proves
onShowFileChooseris wired up. - Background the app for 2 minutes, then resume — the session should survive, not bounce back to OTP.
- Sign out and re-init — issue a fresh
user_codeand confirm a clean second run.
user_code and handling webhooks — go back to Hosted embed.