Embedded Webview

Overview

Unified Onboarding is a Branch-hosted web experience that lives inside your mobile app. Rather than redirecting workers to a separate browser or external app, your app loads the Branch onboarding flow in an embedded webview so the experience feels seamless and native.

When your integration includes EWA (Branch Direct), workers complete onboarding once and then have ongoing access to earned wages and payout controls — all within the same embedded experience inside your app. These two flows always exist together and are built on the same webview implementation.

💰

Some components are only necessary to implement when including EWA.

These will be called out in the guide as EWA Implementation.

Embed the browser window

Your iOS or Android app needs to host a specific type of in-app browser window. Branch provides sample code for both platforms — your mobile engineers implement it once, and both the onboarding and EWA flows run inside it.

iOS Browser Window

Use WKWebView with standard configuration. Add the view controller (EmbeddedWebViewController) to your UITabBarController, UINavigationController, or other container as needed.

// EWA Implementation
import AuthenticationServices

import UIKit
import WebKit

class EmbeddedWebViewController: UIViewController {

    private lazy var webView: WKWebView = {
        let contentController = WKUserContentController()

        // EWA Implementation
        contentController.add(self, name: self.plaidLinkMessageName)

        let configuration = WKWebViewConfiguration()
        configuration.userContentController = contentController

        let webView = WKWebView(frame: .zero, configuration: configuration)
        webView.allowsBackForwardNavigationGestures = false
        webView.uiDelegate = self
        return webView
    }()

    private let initialURL: URL

    init(initialURL: URL) {
        self.initialURL = initialURL
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        self.view.addSubview(self.webView)
        self.webView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            self.webView.topAnchor.constraint(equalTo: self.view.topAnchor),
            self.webView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            self.webView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            self.webView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
        ])

        self.webView.load(URLRequest(url: self.initialURL))
    }
}

Android Browser Window

Use EmbeddedWebViewScreen (Jetpack Compose). Add it to an Activity or other container view as needed.

import android.annotation.SuppressLint
import android.os.Message
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.branchapp.appconfiguration.api.AppColors
import com.branchapp.designsystem.api.compose.composite.indicators.OverlayLoader
import com.branchapp.designsystem.api.compose.composite.webview.rememberWebViewWithLifecycle

@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
@Composable
internal fun EmbeddedWebViewScreen(
    listener: EmbeddedWebViewJavascriptListener,
    url: String,
    onExternalLink: (String) -> Unit,
) {
    var isLoading by remember {
        mutableStateOf(true)
    }
    val webView = rememberWebViewWithLifecycle(
        listener = listener,
        isFullScreen = true,
        settings = {
            javaScriptEnabled = true
            domStorageEnabled = true
            useWideViewPort = true
            loadWithOverviewMode = true
            setSupportMultipleWindows(true)
            javaScriptCanOpenWindowsAutomatically = true
        },
        webViewClient = EmbeddedWebViewClient(
            onFinishLoading = {
                isLoading = false
            },
        ),
        webChromeClient = ExternalLinkWebChromeClient(onExternalLink = onExternalLink),
    )
    LaunchedEffect(webView, url) {
        webView.loadUrl(url)
    }
    Box(
        modifier =
            Modifier
                .fillMaxSize()
                .background(AppColors.surface),
    ) {
        AndroidView(
            modifier = Modifier
                .fillMaxSize()
                .systemBarsPadding(),
            factory = {
                webView.apply {
                    (parent as? ViewGroup)?.removeView(this)
                    isVerticalScrollBarEnabled = true
                    isHorizontalScrollBarEnabled = false
                    overScrollMode = WebView.OVER_SCROLL_IF_CONTENT_SCROLLS
                }
            },
        )
        if (isLoading) {
            OverlayLoader()
        }
    }
}

@Composable
fun rememberWebViewWithLifecycle(
    listener: EmbeddedWebViewJavascriptListener? = null,
    webViewClient: WebViewClient? = null,
    webChromeClient: WebChromeClient? = null,
    settings: WebSettings.() -> Unit = {},
    requiresThirdPartyCookies: Boolean = false,
    shouldRemoveCookies: Boolean = false,
    isFullScreen: Boolean = false,
): WebView {
    val context = LocalContext.current
    val webView = remember {
        WebView(context).apply {
            if (isFullScreen) {
                layoutParams = ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT,
                )
            }
            this.settings.apply(settings)
            if (shouldRemoveCookies) {
                val cookieManager = CookieManager.getInstance()
                cookieManager.removeAllCookies(null)
                cookieManager.flush()
            }
            if (requiresThirdPartyCookies) {
                // AppRTC requires third party cookies to work
                val cookieManager = CookieManager.getInstance()
                cookieManager.setAcceptThirdPartyCookies(this, true)
            }
            webViewClient?.let {
                this.webViewClient = it
            }
            webChromeClient?.let {
                this.webChromeClient = it
            }
        }
    }
    // Makes WebView follow the lifecycle of this composable
    val lifecycleObserver = rememberWebViewLifecycleObserver(webView, listener)
    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(lifecycle) {
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }
    return webView
}

@Composable
private fun rememberWebViewLifecycleObserver(
    webView: WebView,
    listener: EmbeddedWebViewJavascriptListener?,
): LifecycleEventObserver = remember(webView) {
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> {
                listener?.let {
                    webView.addJavascriptInterface(listener, EmbeddedWebViewJavascriptListener.NAME)
                }
            }
            Lifecycle.Event.ON_RESUME -> {
                webView.onResume()
            }
            Lifecycle.Event.ON_PAUSE -> {
                webView.onPause()
            }
            Lifecycle.Event.ON_DESTROY -> {
                listener?.let {
                    webView.removeJavascriptInterface(EmbeddedWebViewJavascriptListener.NAME)
                }
            }
            else -> {
                // no op
            }
        }
    }
}

// EWA Implementation
interface EmbeddedWebViewJavascriptListener {
    var webView: WebView?
    /**
     * Receives the Plaid Hosted Link URL from the web page. The web page calls this
     * via `BranchAndroidBridge.openHostedLink(hostedLinkUrl)` (Android)
     */
    @JavascriptInterface
    fun openHostedLink(hostedLinkUrl: String)
    companion object {
        const val NAME = "BranchAndroidBridge"
    }
}

Request Camera Access Permission

During identity verification, some workers are prompted to photograph their ID. Your app must declare camera permission in its configuration file.

iOS Camera Access Permission

Add the following entry to your app's .plist file:

<key>NSCameraUsageDescription</key>
<string>This app uses your camera to verify your identity.</string>

Android Camera Access Permission

Add the following to your AndroidManifest.xml:

<uses-permission android:name="android.permission.CAMERA" />

Handle External Links

Content like terms of service and support articles automatically opens in the device's default browser so neither the onboarding nor EWA flow is interrupted.

iOS External Link Handling

extension EmbeddedWebViewController: WKUIDelegate {
    func webView(
        _ webView: WKWebView,
        createWebViewWith configuration: WKWebViewConfiguration,
        for navigationAction: WKNavigationAction,
        windowFeatures: WKWindowFeatures
    ) -> WKWebView? {
        // Any navigation that attempts to open new tab should push to external browser
        if navigationAction.targetFrame == nil, let url = navigationAction.request.url {
            UIApplication.shared.open(url)
        }

        return nil
    }
}

Android External Link Handling

internal class EmbeddedWebViewClient(val onFinishLoading: () -> Unit) : WebViewClient() {
    /**
     * Allow all top-frame navigations to load in the WebView as normal.
     * External (new-window / target=_blank) navigation is intercepted in
     * [ExternalLinkWebChromeClient.onCreateWindow] instead.
     */
    override fun shouldOverrideUrlLoading(
        view: WebView,
        request: WebResourceRequest,
    ): Boolean = false
    override fun onPageFinished(
        view: WebView,
        url: String,
    ) {
        super.onPageFinished(view, url)
        onFinishLoading()
    }
}

/**
 * Any navigation that requests a new tab/window (target=_blank, window.open, etc.)
 * is routed to the system browser rather than opening inline.
 */
internal class ExternalLinkWebChromeClient(private val onExternalLink: (String) -> Unit) : WebChromeClient() {
    override fun onCreateWindow(
        view: WebView,
        isDialog: Boolean,
        isUserGesture: Boolean,
        resultMsg: Message?,
    ): Boolean {
        val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
        val tempWebView = WebView(view.context).apply {
            webViewClient = object : WebViewClient() {
                override fun shouldOverrideUrlLoading(
                    view: WebView,
                    request: WebResourceRequest,
                ): Boolean {
                    request.url?.toString()?.let(onExternalLink)
                    view.post { view.destroy() }
                    return true
                }
            }
        }
        transport.webView = tempWebView
        resultMsg.sendToTarget()
        return true
    }
}

Enable Persistent Login using Cookies

💰

EWA Implementation

Workers return to Branch Direct repeatedly. Session cookies allow them to stay logged in across app restarts without re-authenticating. Your app must save and restore these cookies when the app is paused or closed.

iOS Cookie Persistence

WKWebView with standard configuration handles cookie persistence automatically. No additional code is required beyond the base implementation.

Android Cookie Persistence

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    CookieManager.getInstance().setAcceptCookie(true)
    // ...
}

override fun onPause() {
    CookieManager.getInstance().flush()
    super.onPause()
}

override fun onDestroy() {
    CookieManager.getInstance().flush()
    super.onDestroy()
}

Link a Bank Account using Plaid

💰

EWA Implementation

When a worker links an external bank account, a secure Plaid verification window opens on top of the embedded view. When the worker finishes or cancels, they are automatically returned to Branch Direct. Your app must be configured to launch the Plaid pop-up correctly and handle its completion.

iOS Bank Account Linking

Plaid is presented via ASWebAuthenticationSession, which opens modally above the webview and dismisses automatically on completion or cancellation.

// Add to EmbeddedWebViewController: UIViewController
private let plaidLinkCompleteEventName = "branchPlaidLinkComplete"
private var plaidHostedLinkSession: ASWebAuthenticationSession?
private let callbackURIScheme = "example" // Should match scheme of completion redirect URL configured in Pay Admin
private let plaidLinkMessageName = "branchPlaidLink"

private func startPlaidHostedLinkSession(plaidURL: URL) {
    self.plaidHostedLinkSession = ASWebAuthenticationSession(
        url: plaidURL,
        callbackURLScheme: self.callbackURIScheme
    ) { [weak self] url, error in
        self?.handlePlaidHostedLinkSession(callbackURL: url, error: error)
        self?.plaidHostedLinkSession = nil
    }
    self.plaidHostedLinkSession?.prefersEphemeralWebBrowserSession = true
    self.plaidHostedLinkSession?.presentationContextProvider = self
    self.plaidHostedLinkSession?.start()
}

private func handlePlaidHostedLinkSession(callbackURL: URL?, error: Error?) {
    let detail: [String: Any]

    if let error = error as? ASWebAuthenticationSessionError, error.code == .canceledLogin {
        detail = ["status": "canceled"]
    } else if let error {
        detail = ["status": "error", "message": error.localizedDescription]
    } else {
        detail = ["status": "completed", "callbackUrl": callbackURL?.absoluteString ?? ""]
    }

    guard let data = try? JSONSerialization.data(withJSONObject: detail),
            let json = String(data: data, encoding: .utf8) else { return }

    let js = "window.dispatchEvent(new CustomEvent('\(self.plaidLinkCompleteEventName)', { detail: \(json) }));"
    self.webView.evaluateJavaScript(js)
}

extension EmbeddedWebViewController: WKScriptMessageHandler {
    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage
    ) {
        guard message.name == self.plaidLinkMessageName,
              let urlString = message.body as? String,
              let url = URL(string: urlString) else { return }

        self.startPlaidHostedLinkSession(plaidURL: url)
    }
}

extension EmbeddedWebViewController: ASWebAuthenticationPresentationContextProviding {
    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        self.view.window!
    }
}

Android Bank Account Linking

Plaid is launched via CustomTabsIntent. The activity listens for the redirect URI on return and notifies the webview of the result.

// In the Activity or other class that extends `EmbeddedWebViewJavascriptListener`
private var awaitingPlaidReturn = false
private var lastPlaidCallbackUrl: String? = null

override var webView: WebView? = null

/**
 * Plaid redirects back to the app via the configured redirect URI when the
 * Hosted Link flow completes successfully. The Custom Tab dismisses and this
 * activity (singleTop) receives the intent here.
 */
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    val data = intent.data ?: return
    if (data.isPlaidHostedLinkRedirect()) {
        awaitingPlaidReturn = false
        lastPlaidCallbackUrl = data.toString()
        notifyCompletion(PlaidCompletion.Completed(callbackUrl = data.toString()))
    }
}

override fun onResume() {
    super.onResume()
    // If the user dismissed the Custom Tab via the close button (not the redirect),
    // onNewIntent is never called but onResume is. Treat this as a cancel.
    if (awaitingPlaidReturn) {
        awaitingPlaidReturn = false
        notifyCompletion(PlaidCompletion.Canceled)
    }
}

private fun openInSystemBrowser(url: String) {
    val intent = Intent(Intent.ACTION_VIEW, url.toUri())
        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    try {
        startActivity(intent)
    } catch (_: ActivityNotFoundException) {
        // No browser available; silently ignore.
    }
}

/**
 * Launches the Plaid Hosted Link URL in a Custom Tab presented modally above the
 * embedded WebView. Returns true if the tab launched successfully.
 */
private fun launchPlaidHostedLink(url: String): Boolean {
    val intent = CustomTabsIntent.Builder()
        .setShareState(CustomTabsIntent.SHARE_STATE_OFF)
        .setUrlBarHidingEnabled(true)
        .build()
    return try {
        intent.launchUrl(this, url.toUri())
        true
    } catch (_: ActivityNotFoundException) {
        // Fall back to the system browser if no Custom Tabs provider is available.
        openInSystemBrowser(url)
        false
    }
}

@JavascriptInterface
override fun openHostedLink(hostedLinkUrl: String) {
    val url = hostedLinkUrl.takeIf { it.isNotBlank() }
        ?: return

    webView?.post {
        if (launchPlaidHostedLink(url)) {
            awaitingPlaidReturn = true
        }
    }
}

/**
 * Notifies the WebView that the Plaid Hosted Link Custom Tab session has finished.
 * Dispatches a `branchPlaidLinkComplete` CustomEvent on `window` whose `detail`
 * payload contains:
 *   - `{ status: "completed", callbackUrl: "..." }`
 *   - `{ status: "canceled" }`
 *   - `{ status: "error", message: "..." }`
 */
fun notifyCompletion(completion: PlaidCompletion) {
    val view = webView ?: return
    val detail = JSONObject().apply {
        put("status", completion.status)
        when (completion) {
            is PlaidCompletion.Completed -> put("callbackUrl", completion.callbackUrl)
            is PlaidCompletion.Error -> put("message", completion.message)
            PlaidCompletion.Canceled -> Unit
        }
    }
    val script = "window.dispatchEvent(new CustomEvent('$PLAID_COMPLETE_EVENT', " +
        "{ detail: $detail }));"
    view.post {
        view.evaluateJavascript(script, null)
    }
}

private fun Uri.isPlaidHostedLinkRedirect(): Boolean = scheme.equals(PLAID_REDIRECT_SCHEME, ignoreCase = true) &&
    host.equals(PLAID_REDIRECT_HOST, ignoreCase = true)

sealed class PlaidCompletion(val status: String) {
    data class Completed(val callbackUrl: String) : PlaidCompletion("completed")
    data object Canceled : PlaidCompletion("canceled")
    data class Error(val message: String) : PlaidCompletion("error")
}

Add the following to your AndroidManifest.xml to handle the Plaid redirect URI:

<activity
    android:name=".embeddedwebview.EmbeddedWebViewActivity"
    android:theme="@style/Theme.TransparentBackgroundActivity"
    android:screenOrientation="portrait"
    android:windowSoftInputMode="adjustResize"
    android:launchMode="singleTop"
    android:exported="true">
    <intent-filter android:autoVerify="false">
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data
            android:scheme="branchapp"
            android:host="plaid-hosted-link-complete"/>
    </intent-filter>
</activity>

Provide an Entry Point URL

💰

EWA Implementation

The same embedded window handles both flows. Load the appropriate URL based on where the worker is in the journey.