> ## Documentation Index
> Fetch the complete documentation index at: https://docs.privy.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Open cards in a mobile WebView

> Open the cards React experience in a WebView on iOS and Android

Card prebuilt components ship in the Privy React SDK. iOS and Android apps open a hosted copy of that experience in a WebView.

Host the page from the [React integration](/financial-flows/cards/pre-built-components/react-integration.md) on a domain in the app's [allowed origins](https://dashboard.privy.io/apps?setting=domains\&page=settings). Use the same Privy app ID as the mobile app.

The WebView has its own Privy session so the user signs in on the hosted page.

## How it works

1. The user opens cards in the native app.
2. The app loads the hosted React page in a WebView.
3. The user signs in on that page when the WebView has no Privy session.
4. The page runs card signup and displays the card summary.

## Render the card views

For the best experience, render the card views full screen in the WebView.

> [!NOTE]
>
> Host the page on HTTPS. Add that origin in the [Privy
> Dashboard](https://dashboard.privy.io/apps?setting=domains\&page=settings) before the WebView loads
> it.

## Handling window popups

Card signup calls `window.open` for provider terms and identity verification. Return that window to the page, and release it on close so the page can read `closed`.

**iOS**

```swift theme={"system"}
func webView(
    _ webView: WKWebView,
    createWebViewWith configuration: WKWebViewConfiguration,
    for navigationAction: WKNavigationAction,
    windowFeatures: WKWindowFeatures
) -> WKWebView? {
    guard navigationAction.targetFrame == nil else { return nil }

    let popup = WKWebView(frame: view.bounds, configuration: configuration)
    popup.uiDelegate = self
    view.addSubview(popup)
    return popup
}

func webViewDidClose(_ webView: WKWebView) {
    webView.removeFromSuperview()
}
```

**Android**

```kotlin theme={"system"}
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(true)

override fun onCreateWindow(
    view: WebView,
    isDialog: Boolean,
    isUserGesture: Boolean,
    resultMsg: Message
): Boolean {
    val popup = WebView(view.context)
    val transport = resultMsg.obj as WebView.WebViewTransport
    transport.webView = popup
    resultMsg.sendToTarget()
    return true
}

override fun onCloseWindow(window: WebView) {
    window.destroy()
}
```

## Open the hosted page

The app loads the hosted React page in the WebView. On Android, enable JavaScript, DOM storage, and third-party cookies, and match the parent height so the cards layout keeps its size.

**iOS**

```swift theme={"system"}
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true

let webView = WKWebView(frame: view.bounds, configuration: config)
webView.uiDelegate = self
view.addSubview(webView)

if let url = URL(string: "https://your-domain.com/cards") {
    webView.load(URLRequest(url: url))
}
```

**Android**

```kotlin theme={"system"}
val webView = WebView(this).apply {
    layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
    settings.javaScriptEnabled = true
    settings.domStorageEnabled = true
    CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
}

webView.loadUrl("https://your-domain.com/cards")
```

## Handling webview permissions

Identity verification uses the camera and microphone. Declare that access, then grant the capture request to the WebView.

**iOS**

Add these keys to `Info.plist`.

```xml theme={"system"}
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to verify the cardholder's identity.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app uses the microphone to verify the cardholder's identity.</string>
```

```swift theme={"system"}
func webView(
    _ webView: WKWebView,
    requestMediaCapturePermissionFor origin: WKSecurityOrigin,
    initiatedByFrame frame: WKFrameInfo,
    type: WKMediaCaptureType,
    decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
    decisionHandler(.grant)
}
```

**Android**

Add these permissions to `AndroidManifest.xml`.

```xml theme={"system"}
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
```

```kotlin theme={"system"}
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
    if (granted.values.all { it }) webView.loadUrl(cardPageUrl)
}.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))

override fun onPermissionRequest(request: PermissionRequest) {
    request.grant(request.resources)
}
```

## Complete example

**iOS**

```swift theme={"system"}
import UIKit
import WebKit

class CardViewController: UIViewController, WKUIDelegate {
    private var webView: WKWebView!
    private var popupWebView: WKWebView?
    private let cardPageUrl = "https://your-domain.com/cards"
    private let popupCloseButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("Close", for: .normal)
        button.isHidden = true
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        let config = WKWebViewConfiguration()
        config.allowsInlineMediaPlayback = true

        webView = WKWebView(frame: view.bounds, configuration: config)
        webView.uiDelegate = self
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(webView)

        popupCloseButton.addTarget(self, action: #selector(closePopup), for: .touchUpInside)
        popupCloseButton.frame = CGRect(x: 16, y: 16, width: 72, height: 32)
        view.addSubview(popupCloseButton)

        if let url = URL(string: cardPageUrl) {
            webView.load(URLRequest(url: url))
        }
    }

    func webView(
        _ webView: WKWebView,
        createWebViewWith configuration: WKWebViewConfiguration,
        for navigationAction: WKNavigationAction,
        windowFeatures: WKWindowFeatures
    ) -> WKWebView? {
        guard navigationAction.targetFrame == nil else { return nil }

        popupWebView?.removeFromSuperview()
        // Use this configuration so JavaScript receives the new window.
        let popup = WKWebView(frame: view.bounds, configuration: configuration)
        popup.uiDelegate = self
        popup.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(popup)
        popupWebView = popup
        popupCloseButton.isHidden = false
        view.bringSubviewToFront(popupCloseButton)
        return popup
    }

    func webViewDidClose(_ webView: WKWebView) {
        guard webView == popupWebView else { return }
        webView.removeFromSuperview()
        popupWebView = nil
        popupCloseButton.isHidden = true
    }

    @objc private func closePopup() {
        popupWebView?.removeFromSuperview()
        popupWebView = nil
        popupCloseButton.isHidden = true
    }

    func webView(
        _ webView: WKWebView,
        requestMediaCapturePermissionFor origin: WKSecurityOrigin,
        initiatedByFrame frame: WKFrameInfo,
        type: WKMediaCaptureType,
        decisionHandler: @escaping (WKPermissionDecision) -> Void
    ) {
        decisionHandler(.grant)
    }
}
```

**Android**

```kotlin theme={"system"}
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Message
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.webkit.CookieManager
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat

class CardActivity : AppCompatActivity() {
    private val cardPageUrl = "https://your-domain.com/cards"
    private lateinit var root: FrameLayout
    private var webView: WebView? = null
    private var popupWebView: WebView? = null
    private var popupLayer: FrameLayout? = null

    private val requestCapture = registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) {
        webView?.loadUrl(cardPageUrl)
    }

    private val backCallback = object : OnBackPressedCallback(true) {
        override fun handleOnBackPressed() {
            when {
                popupWebView != null -> dismissPopup(popupWebView)
                webView?.canGoBack() == true -> webView?.goBack()
                else -> {
                    isEnabled = false
                    onBackPressedDispatcher.onBackPressed()
                }
            }
        }
    }

    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        onBackPressedDispatcher.addCallback(this, backCallback)
        root = FrameLayout(this)
        setContentView(root)

        val wv = newWebView(onCreatePopup = { resultMsg ->
            val popup = newWebView(onCreatePopup = null, onClosePopup = { dismissPopup(popupWebView) })
            showPopup(popup)
            val transport = resultMsg.obj as WebView.WebViewTransport
            transport.webView = popup
            resultMsg.sendToTarget()
        })

        webView = wv
        root.addView(wv, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))

        val needed = arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
        val missing = needed.any {
            ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
        }

        if (missing) {
            requestCapture.launch(needed)
        } else {
            wv.loadUrl(cardPageUrl)
        }
    }

    override fun onResume() {
        super.onResume()
        webView?.onResume()
        popupWebView?.onResume()
    }

    override fun onPause() {
        webView?.onPause()
        popupWebView?.onPause()
        super.onPause()
    }

    @SuppressLint("SetJavaScriptEnabled")
    private fun newWebView(
        onCreatePopup: ((Message) -> Unit)?,
        onClosePopup: (() -> Unit)? = null
    ): WebView {
        return WebView(this).apply {
            layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
            settings.javaScriptEnabled = true
            settings.domStorageEnabled = true
            settings.javaScriptCanOpenWindowsAutomatically = true
            settings.setSupportMultipleWindows(true)
            settings.mediaPlaybackRequiresUserGesture = false
            CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
            webViewClient = WebViewClient()
            webChromeClient = object : WebChromeClient() {
                override fun onPermissionRequest(request: PermissionRequest) {
                    request.grant(request.resources)
                }

                override fun onCreateWindow(
                    view: WebView,
                    isDialog: Boolean,
                    isUserGesture: Boolean,
                    resultMsg: Message
                ): Boolean {
                    val create = onCreatePopup ?: return false
                    create(resultMsg)
                    return true
                }

                override fun onCloseWindow(window: WebView) {
                    onClosePopup?.invoke()
                }
            }
        }
    }

    private fun showPopup(popup: WebView) {
        dismissPopup(popupWebView)
        popup.onResume()
        val layer = FrameLayout(this)
        layer.addView(popup, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
        root.addView(layer, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
        popupLayer = layer
        popupWebView = popup
    }

    private fun dismissPopup(popup: WebView?) {
        val layer = popupLayer ?: return
        root.removeView(layer)
        popupLayer = null
        popupWebView = null
        popup?.destroy()
    }
}
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.