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.
| Flow | URL |
|---|---|
| Onboarding | https://onboarding.branchapp.com/ |
| Branch Direct / EWA | https://direct.branchapp.com/account |
