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.scrollView.contentInsetAdjustmentBehavior = .never
webView.allowsBackForwardNavigationGestures = false
webView.uiDelegate = self
// EWA Implementation
webView.navigationDelegate = 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.safeAreaLayoutGuide.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 (Example).
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,
onDownloadRequested: (url: String, contentDisposition: String?, mimeType: String?) -> Unit,
onPageFinished: (WebView) -> 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 = { loadedWebView ->
isLoading = false
onPageFinished(loadedWebView)
},
),
webChromeClient = ExternalLinkWebChromeClient(
onExternalLink = onExternalLink,
onDownloadRequested = onDownloadRequested,
),
)
DisposableEffect(webView, onDownloadRequested) {
webView.setDownloadListener { downloadUrl, _, contentDisposition, mimeType, _ ->
onDownloadRequested(downloadUrl, contentDisposition, mimeType)
}
onDispose {
webView.setDownloadListener(null)
}
}
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
}
}
}
}
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)
/**
* Receives the blob data for download. This notifies the app to save the
* file in memory and prepare it for the share intent. (Android)
*/
@JavascriptInterface
fun onBlobDownload(
dataUrl: String,
fileName: String,
mimeType: String,
)
companion object {
const val NAME = "BranchAndroidBridge"
}
}Android EmbeddedWebviewScreen Example
Example use of EmbeddedWebviewScreen inside Activity or other class that extends EmbeddedWebViewJavascriptListener:
EmbeddedWebViewScreen(
listener = this,
url = url,
onExternalLink = ::openInSystemBrowser,
onDownloadRequested = ::downloadFromWebView,
onPageFinished = ::installBlobDownloadBridge,
)React Native Browser Window
Use BranchEmbeddedWebView (Example), a single component that serves both iOS and Android. Branch's web apps detect their native host by checking for a JavaScript bridge object; the component injects a BranchAndroidBridge shim before page content loads, so both React Native platforms communicate through the same bridge contract with no platform-specific code.
Dependencies: react-native-webview (v16+). The EWA sections below additionally use react-native-inappbrowser-reborn, react-native-blob-util, and react-native-share in the sample handlers — the component itself exposes them as props, so you can substitute your own implementations.
import React, { useCallback, useRef } from 'react';
import {
ActivityIndicator,
Linking,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native';
import WebView, { WebViewMessageEvent } from 'react-native-webview';
const PLAID_LINK_COMPLETE_EVENT = 'branchPlaidLinkComplete';
/**
* JavaScript injected into the page before content loads. Branch's web apps
* feature-detect their native host; this shim installs
* `window.BranchAndroidBridge` and forwards each call to React Native
* through `window.ReactNativeWebView.postMessage`, so a single React Native
* code path serves both iOS and Android.
*
* It must be injected via `injectedJavaScriptBeforeContentLoaded` — the web
* app captures the bridge reference when it first renders.
*/
const BRIDGE_SCRIPT = `
(function () {
if (window.BranchAndroidBridge) {
return;
}
var post = function (message) {
window.ReactNativeWebView.postMessage(JSON.stringify(message));
};
window.BranchAndroidBridge = {
openHostedLink: function (hostedLinkUrl) {
post({ type: 'openHostedLink', url: hostedLinkUrl });
},
downloadBase64: function (filename, mimeType, base64) {
post({
type: 'downloadBase64',
filename: filename,
mimeType: mimeType,
base64: base64
});
}
};
})();
true;
`;
// EWA Implementation
export type PlaidHostedLinkCompletion =
| { status: 'completed'; callbackUrl: string }
| { status: 'canceled' }
| { status: 'error'; message: string };
// EWA Implementation
export interface EmbeddedWebViewDownload {
filename: string;
mimeType: string;
/** Raw file contents, base64-encoded. */
base64: string;
}
type BridgeMessage =
| { type: 'openHostedLink'; url: string }
| {
type: 'downloadBase64';
filename: string;
mimeType: string;
base64: string;
};
export interface BranchEmbeddedWebViewProps {
/** Entry point URL for the flow (Unified Onboarding or Branch Direct). */
url: string;
/**
* EWA implementation. Called when the web page requests a Plaid Hosted
* Link launch. Present `hostedLinkUrl` in an in-app auth session
* (ASWebAuthenticationSession / Chrome Custom Tabs) and call
* `notifyCompletion` with the result so the page can resume.
*/
onOpenHostedLink?: (
hostedLinkUrl: string,
notifyCompletion: (completion: PlaidHostedLinkCompletion) => void,
) => void;
/**
* EWA implementation. Called when the web page hands over a file to
* download (e.g. a PDF agreement). Persist the contents and offer them to
* the user, typically via the platform share sheet.
*/
onDownloadFile?: (download: EmbeddedWebViewDownload) => void;
/**
* iOS only. Called when a navigation response can't be rendered inline
* (e.g. a direct link with `Content-Disposition: attachment`). Fetch the
* URL and offer the file to the user. On Android the webview's built-in
* `DownloadManager` integration handles these downloads automatically.
*/
onDownloadUrl?: (url: string) => void;
style?: StyleProp<ViewStyle>;
}
export function BranchEmbeddedWebView({
url,
onOpenHostedLink,
onDownloadFile,
onDownloadUrl,
style,
}: BranchEmbeddedWebViewProps) {
const webViewRef = useRef<WebView>(null);
const notifyPlaidCompletion = useCallback(
(completion: PlaidHostedLinkCompletion) => {
const script = `window.dispatchEvent(new CustomEvent('${PLAID_LINK_COMPLETE_EVENT}', { detail: ${JSON.stringify(
completion,
)} })); true;`;
webViewRef.current?.injectJavaScript(script);
},
[],
);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let message: BridgeMessage;
try {
message = JSON.parse(event.nativeEvent.data);
} catch {
return;
}
switch (message.type) {
case 'openHostedLink':
if (message.url) {
onOpenHostedLink?.(message.url, notifyPlaidCompletion);
}
break;
case 'downloadBase64':
if (message.filename && message.base64) {
onDownloadFile?.({
filename: message.filename,
mimeType: message.mimeType,
base64: message.base64,
});
}
break;
}
},
[onOpenHostedLink, onDownloadFile, notifyPlaidCompletion],
);
return (
<WebView
ref={webViewRef}
source={{ uri: url }}
// The flow pages embed `<iframe srcdoc>` content, which navigates as
// `about:srcdoc`. Whitelist the `about:` scheme so these load inline
// instead of being treated as external links. `target=_blank` links
// are still routed out via onOpenWindow below.
originWhitelist={['http://*', 'https://*', 'about:*']}
style={style}
injectedJavaScriptBeforeContentLoaded={BRIDGE_SCRIPT}
onMessage={handleMessage}
// Any navigation that attempts to open a new tab/window is pushed to
// the external browser instead.
setSupportMultipleWindows
javaScriptCanOpenWindowsAutomatically
onOpenWindow={event => {
Linking.openURL(event.nativeEvent.targetUrl).catch(() => {
// No handler for the URL; silently ignore.
});
}}
onFileDownload={event => {
onDownloadUrl?.(event.nativeEvent.downloadUrl);
}}
javaScriptEnabled
domStorageEnabled
allowsBackForwardNavigationGestures={false}
contentInsetAdjustmentBehavior="never"
startInLoadingState
renderLoading={() => (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" />
</View>
)}
/>
);
}
const styles = StyleSheet.create({
loadingContainer: {
...StyleSheet.absoluteFill,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#FFFFFF',
},
});React Native BranchEmbeddedWebView Example
<BranchEmbeddedWebView
url={url}
// EWA Implementation
onOpenHostedLink={openPlaidHostedLink}
// EWA Implementation
onDownloadFile={shareBase64Download}
onDownloadUrl={downloadUrlToShare}
/>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" />React Native Camera Access Permission
Declare the same permissions as the native implementations. react-native-webview forwards the page's camera request to the OS on both platforms and handles the Android runtime permission request automatically — no permission code is needed.
Add to ios/<AppName>/Info.plist:
<key>NSCameraUsageDescription</key>
<string>This app uses your camera to verify your identity.</string>Add to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />Handle External Links
Content like terms of service and support articles automatically open 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
This code includes routing for Android File Downloading.
internal class EmbeddedWebViewClient(val onFinishLoading: (WebView) -> 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(view)
}
}
/**
* 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,
private val onDownloadRequested: (url: String, contentDisposition: String?, mimeType: 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 {
val url = request.url?.toString()
if (url != null && url.toUri().scheme.equals(BLOB_SCHEME, ignoreCase = true)) {
onDownloadRequested(url, null, null)
} else {
url?.let(onExternalLink)
}
view.post {
view.destroy()
}
return true
}
}
}
transport.webView = tempWebView
resultMsg.sendToTarget()
return true
}
private companion object {
private const val BLOB_SCHEME = "blob"
}
}React Native External Link Handling
Handled inside BranchEmbeddedWebView (see above): setSupportMultipleWindows routes any new-tab/new-window navigation target=_blank, window.open) to onOpenWindow, which opens the URL in the device's default browser via Linking.openURL.
Note the originWhitelist in the component includes about:* in addition to the default http/https: the flow pages embed <iframe srcdoc> content, which the webview reports as an about:srcdoc navigation. Without the whitelist entry, react-native-webview treats it as an external link and tries to open it in the system browser.
Handle File Downloads
EWA Implementation
Workers can download documents related to their account, such as a PDF of their On-Demand Pay Agreement. Handle links to these resources with code to structure the retrieved data on the worker's device.
iOS File Downloading
extension EmbeddedWebViewController: WKNavigationDelegate {
func webView(
_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping @MainActor (WKNavigationResponsePolicy) -> Void
) {
if navigationResponse.canShowMIMEType {
decisionHandler(.allow)
} else {
decisionHandler(.download)
}
}
func webView(
_ webView: WKWebView,
navigationResponse: WKNavigationResponse,
didBecome download: WKDownload
) {
download.delegate = self
}
func webView(
_ webView: WKWebView,
navigationAction: WKNavigationAction,
didBecome download: WKDownload
) {
download.delegate = self
}
}
extension EmbeddedWebViewController: WKDownloadDelegate {
func download(
_ download: WKDownload,
decideDestinationUsing response: URLResponse,
suggestedFilename: String
) async -> URL? {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent(suggestedFilename)
}
func downloadDidFinish(_ download: WKDownload) {
guard let url = download.progress.fileURL else { return }
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
self.present(activityVC, animated: true)
}
}Android File Downloading
Ensure that Android External Link Handling is implemented to route the file download.
// In the Activity or other class that extends `EmbeddedWebViewJavascriptListener`
private fun downloadFromWebView(
url: String,
contentDisposition: String?,
mimeType: String?,
) {
if (!url.toUri().scheme.equals(BLOB_SCHEME, ignoreCase = true)) {
openInSystemBrowser(url)
return
}
requestBlobDownload(
blobUrl = url,
contentDisposition = contentDisposition,
mimeType = mimeType,
)
}
private fun requestBlobDownload(
blobUrl: String,
contentDisposition: String?,
mimeType: String?,
) {
val defaultName = URLUtil.guessFileName(
blobUrl,
contentDisposition,
mimeType ?: DEFAULT_DOWNLOAD_MIME_TYPE,
)
val script = """
(function() {
const blobUrl = ${JSONObject.quote(blobUrl)};
const requestedName = ${JSONObject.quote(defaultName)};
const requestedMimeType = ${JSONObject.quote(mimeType.orEmpty())};
const handler = window.${BLOB_DOWNLOAD_HANDLER_FUNCTION};
if (typeof handler === 'function') {
handler(blobUrl, requestedName, requestedMimeType);
}
})();
""".trimIndent()
webView?.post {
webView?.evaluateJavascript(script, null)
}
}
private fun installBlobDownloadBridge(webView: WebView) {
webView.evaluateJavascript(BLOB_DOWNLOAD_BRIDGE_SCRIPT, null)
}
private fun createBlobDownloadFile(
dataUrl: String,
fileName: String,
mimeType: String,
): DownloadedFile? {
val commaIndex = dataUrl.indexOf(',')
if (!dataUrl.startsWith(DATA_URL_PREFIX) || commaIndex < 0) {
return null
}
val metadata = dataUrl.substring(DATA_URL_PREFIX.length, commaIndex)
if (!metadata.contains(BASE64_ENCODING_MARKER)) {
return null
}
val decodedBytes = Base64.decode(dataUrl.substring(commaIndex + 1), Base64.DEFAULT)
val resolvedMimeType = metadata.substringBefore(';')
.takeIf {
it.isNotBlank()
}
?: mimeType.takeIf {
it.isNotBlank()
}
?: DEFAULT_DOWNLOAD_MIME_TYPE
val resolvedFileName = fileName
.takeIf {
it.isNotBlank()
}
?: URLUtil.guessFileName(
"download",
null,
resolvedMimeType,
)
val downloadsDir = File(cacheDir, EMBEDDED_WEBVIEW_DOWNLOAD_DIRECTORY)
if (!downloadsDir.exists() && !downloadsDir.mkdirs()) {
return null
}
val targetFile = File(downloadsDir, resolvedFileName)
if (targetFile.exists() && !targetFile.delete()) {
return null
}
targetFile.outputStream().use { outputStream ->
outputStream.write(decodedBytes)
outputStream.flush()
}
return DownloadedFile(
file = targetFile,
mimeType = resolvedMimeType,
)
}
private fun launchShareDownloadIntent(downloadedFile: DownloadedFile) {
val contentUri = FileProvider.getUriForFile(
this,
"${applicationContext.packageName}.fileprovider",
downloadedFile.file,
)
val intent = Intent(Intent.ACTION_SEND).apply {
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
setDataAndType(contentUri, contentResolver.getType(contentUri) ?: downloadedFile.mimeType)
putExtra(Intent.EXTRA_STREAM, contentUri)
}
try {
startActivity(Intent.createChooser(intent, getString(R.string.embedded_web_view_share_download)))
} catch (_: ActivityNotFoundException) {
showDownloadError()
}
}
@JavascriptInterface
override fun onBlobDownload(
dataUrl: String,
fileName: String,
mimeType: String,
) {
try {
val downloadedFile = createBlobDownloadFile(
dataUrl = dataUrl,
fileName = fileName,
mimeType = mimeType,
)
if (downloadedFile == null) {
showDownloadError()
return
}
launchShareDownloadIntent(downloadedFile)
} catch (_: IllegalArgumentException) {
showDownloadError()
} catch (_: IOException) {
showDownloadError()
}
}
private data class DownloadedFile(
val file: File,
val mimeType: String,
)
companion object {
private const val DATA_URL_PREFIX = "data:"
private const val BASE64_ENCODING_MARKER = ";base64"
private const val BLOB_SCHEME = "blob"
private const val DEFAULT_DOWNLOAD_MIME_TYPE = "application/octet-stream"
private const val EMBEDDED_WEBVIEW_DOWNLOAD_DIRECTORY = "embeddedwebviewdownloads"
private const val BLOB_DOWNLOAD_HANDLER_FUNCTION = "__branchHandleBlobDownload"
private const val BLOB_DOWNLOAD_BRIDGE_SCRIPT = """
(function() {
if (window.__branchBlobBridgeInstalled) {
return;
}
window.__branchBlobBridgeInstalled = true;
const bridge = window.BranchAndroidBridge;
const objectUrlToBlob = new Map();
const originalCreateObjectURL = URL.createObjectURL.bind(URL);
const originalRevokeObjectURL = URL.revokeObjectURL.bind(URL);
const originalAnchorClick = HTMLAnchorElement.prototype.click;
function emitBlob(blob, fileName, requestedMimeType) {
if (!blob || !bridge || typeof bridge.onBlobDownload !== 'function') {
return;
}
const reader = new FileReader();
reader.onloadend = function() {
const payload = typeof reader.result === 'string' ? reader.result : '';
bridge.onBlobDownload(
payload,
fileName || '',
blob.type || requestedMimeType || ''
);
};
reader.readAsDataURL(blob);
}
URL.createObjectURL = function(object) {
const objectUrl = originalCreateObjectURL(object);
if (object instanceof Blob) {
objectUrlToBlob.set(objectUrl, object);
}
return objectUrl;
};
URL.revokeObjectURL = function(objectUrl) {
window.setTimeout(function() {
objectUrlToBlob.delete(objectUrl);
originalRevokeObjectURL(objectUrl);
}, 10000);
};
window.__branchHandleBlobDownload = function(blobUrl, fileName, requestedMimeType) {
const mappedBlob = objectUrlToBlob.get(blobUrl);
if (mappedBlob) {
emitBlob(mappedBlob, fileName, requestedMimeType);
return;
}
fetch(blobUrl)
.then(function(response) { return response.blob(); })
.then(function(blob) { emitBlob(blob, fileName, requestedMimeType); })
.catch(function() {});
};
HTMLAnchorElement.prototype.click = function() {
try {
const href = this.href || '';
if (href.indexOf('blob:') === 0) {
window.__branchHandleBlobDownload(
href,
this.download || '',
''
);
}
} catch (error) {}
return originalAnchorClick.apply(this, arguments);
};
})();
"""
}React Native File Downloading
Downloads reach a React Native host through two paths, and the sample handles both:
- Bridge downloads — the web app hands the file over as base64 bytes via the bridge's
downloadBase64call (this is how documents like the On-Demand Pay Agreement are delivered). Surfaced byBranchEmbeddedWebViewas theonDownloadFileprop; identical on both platforms. - Direct HTTP downloads — a response the webview can't render inline
Content-Disposition: attachment).- On iOS this is surfaced as the
onDownloadUrlprop (the counterpart of the native iOSWKDownloadhandling). - On Android no code is required: the webview's built-in
DownloadManagerintegration saves the file to the public Downloads folder with a system notification.
- On iOS this is surfaced as the
React Native Bridge Downloading
Bridge downloads persist the bytes to the app's cache directory and offer them via the platform share sheet (uses react-native-blob-util and react-native-share):
import ReactNativeBlobUtil from 'react-native-blob-util';
import Share from 'react-native-share';
const DOWNLOAD_DIRECTORY = 'embeddedwebviewdownloads';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
export async function shareBase64Download({
filename,
mimeType,
base64,
}: EmbeddedWebViewDownload): Promise<void> {
const safeName = sanitizeFilename(filename);
const directory = `${ReactNativeBlobUtil.fs.dirs.CacheDir}/${DOWNLOAD_DIRECTORY}`;
const path = `${directory}/${safeName}`;
try {
if (!(await ReactNativeBlobUtil.fs.isDir(directory))) {
await ReactNativeBlobUtil.fs.mkdir(directory);
}
if (await ReactNativeBlobUtil.fs.exists(path)) {
await ReactNativeBlobUtil.fs.unlink(path);
}
await ReactNativeBlobUtil.fs.writeFile(path, base64, 'base64');
await Share.open({
url: `file://${path}`,
type: mimeType || DEFAULT_MIME_TYPE,
failOnCancel: false,
});
} catch (error) {
console.warn(`Failed to share download ${safeName}`, error);
}
}
function sanitizeFilename(name: string): string {
const sanitized = name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 200);
return sanitized.length > 0 ? sanitized : 'download';
}React Native Direct HTTP Downloading
Direct HTTP downloads on iOS fetch the URL to the cache directory and share it:
import ReactNativeBlobUtil from 'react-native-blob-util';
import Share from 'react-native-share';
const DOWNLOAD_DIRECTORY = 'embeddedwebviewdownloads';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
export async function downloadUrlToShare(url: string): Promise<void> {
const filename = filenameFromUrl(url);
const path = `${ReactNativeBlobUtil.fs.dirs.CacheDir}/${DOWNLOAD_DIRECTORY}/${filename}`;
try {
const response = await ReactNativeBlobUtil.config({
path,
overwrite: true,
}).fetch('GET', url);
const mimeType =
response.respInfo.headers['Content-Type'] ??
response.respInfo.headers['content-type'] ??
DEFAULT_MIME_TYPE;
await Share.open({
url: `file://${response.path()}`,
type: mimeType,
failOnCancel: false,
});
} catch (error) {
console.warn(`Failed to download ${url}`, error);
}
}
function filenameFromUrl(url: string): string {
const lastSegment = url.split('?')[0].split('#')[0].split('/').pop() ?? '';
const sanitized = lastSegment.replace(/[\\/:*?"<>|]/g, '_').slice(0, 200);
return sanitized.length > 0 ? sanitized : 'download';
}Direct HTTP downloads on Android are handled by the webview automatically. On Android 9 and below the system DownloadManager requires the storage permission — add it to AndroidManifest.xml bounded to those versions:
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />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()
}React Native Cookie Persistence
No additional code is required. react-native-webview uses the platform's persistent cookie store by default on both platforms (WKWebsiteDataStore on iOS, CookieManager on Android), so Branch Direct sessions survive app restarts.
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>React Native Bank Account Linking
The web page requests a Plaid Hosted Link launch through the bridge; BranchEmbeddedWebView surfaces it via the onOpenHostedLink prop. Present the URL in an in-app auth session — ASWebAuthenticationSession on iOS, Chrome Custom Tabs on Android — and report the result back with notifyCompletion, which dispatches the branchPlaidLinkComplete CustomEvent the page listens for. The sample uses react-native-inappbrowser-reborn:
import { Linking } from 'react-native';
import { InAppBrowser } from 'react-native-inappbrowser-reborn';
// Must match the scheme of the completion redirect URL configured in
// Pay Admin.
const PLAID_CALLBACK_URL = 'branch://';
export async function openPlaidHostedLink(
hostedLinkUrl: string,
notifyCompletion: (completion: PlaidHostedLinkCompletion) => void,
): Promise<void> {
try {
if (!(await InAppBrowser.isAvailable())) {
// No auth session support (e.g. no Custom Tabs provider); fall back to
// the system browser.
await Linking.openURL(hostedLinkUrl);
return;
}
const result = await InAppBrowser.openAuth(
hostedLinkUrl,
PLAID_CALLBACK_URL,
{
// iOS
ephemeralWebSession: true,
// Android
showTitle: false,
enableUrlBarHiding: true,
enableDefaultShare: false,
},
);
if (result.type === 'success' && result.url) {
notifyCompletion({ status: 'completed', callbackUrl: result.url });
} else {
// The user dismissed the sheet/tab before Plaid redirected.
notifyCompletion({ status: 'canceled' });
}
} catch (error) {
notifyCompletion({
status: 'error',
message: error instanceof Error ? error.message : String(error),
});
}
}Add the following intent filter to your MainActivity in AndroidManifest.xml so the Plaid redirect re-enters the app (the scheme must match the completion redirect URL configured in Pay Admin):
<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="branch" />
</intent-filter>No iOS configuration is required — ASWebAuthenticationSession claims the callback scheme for the duration of the session.
Provide an Entry Point URL
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 |
Onboarding URL Format
Construct the Branch Unified Onboarding URL for the intended environment using the Organization's UUID and optional query parameters to streamline the process. This URL can be directly provided to Workers.
- Production URL: https://onboarding.branchapp.com/?org_id={uuid}
- Staging URL: https://onboarding-stg.branchapp.com/?org_id={uuid}
- Sandbox URL: https://onboarding-sandbox.branchapp.com/?org_id={uuid}
Required Query Params:
org_id
- Definition: Providing the Organization's UUID configures the onboarding process using your Organization's settings and links new Workers to your Organization's roster
- UUID has format:
XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
Optional Query Params:
worker_id
- Definition: Providing the Worker ID as a query param will allow the user to bypass the worker search step (entering phone and email)
- Use Case: Onboarding links are emailed to individual workers, or the worker is accessing their onboarding link through a partner app where they are authenticated
Updated about 17 hours ago
