Guide

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 in localStorage; without it every screen re-prompts for OTP.
  • setSupportMultipleWindows(true) plus a WebChromeClient.onCreateWindow override — 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 shouldOverrideUrlLoading to forward upi:// and intent:// URLs to the OS with Intent.parseUri(url, Intent.URI_INTENT_SCHEME). Otherwise the WebView tries to load them as pages and the payment dies.
  • Implement onShowFileChooser and request the CAMERA permission — 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 WKProcessPool per instance — it persists cookies and localStorage across 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 to UIApplication.shared.open.
  • allowsInlineMediaPlayback = true and mediaTypesRequiringUserActionForPlayback = [] — 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.
The mandate screen loads inside an inner iframe, so on Android 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.

Info.plist
<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.

There is nothing to configure. You don't need to register a push token, proxy notifications, or ask for the notification permission on our behalf.

Support matrix

ComponentMinimum
Android System WebViewChromium 100+ (default on Android 7.1+ with current Play Services)
Android target SDKAPI 24 (7.0) minimum — required for setSupportMultipleWindows / onCreateWindow
iOS WKWebView15.0+ (iOS ≤14 has intermittent localStorage wipes)
Desktop debugChrome 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.

  1. Cold open — the OTP screen should appear and the code arrive within ~2 seconds on cellular, not Wi-Fi.
  2. Verify the OTP1234 in sandbox. Proves DOM storage is on.
  3. Buy ₹100 of gold via a UPI intent app — proves deep-link forwarding works and the app returns you to the embed.
  4. Buy ₹100 of gold via the QR / iframe fallback — the path taken when no UPI app is installed.
  5. Sell ₹100 of gold — proceeds land in the user's INR balance.
  6. 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.
  7. Upload a KYC document from the gallery — proves onShowFileChooser is wired up.
  8. Background the app for 2 minutes, then resume — the session should survive, not bounce back to OTP.
  9. Sign out and re-init — issue a fresh user_code and confirm a clean second run.
Reproduce any of this without a device build from Playground → Embed. For the server-side half — issuing the user_code and handling webhooks — go back to Hosted embed.