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,
) {
    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
            }
        }
    }
}

interface EmbeddedWebViewJavascriptListener {
    var webView: WebView?

    /**
     * Receives the Plaid Hosted Link URL from the web page. The web page calls this
     * via `window.BranchAndroidBridge.openHostedLink(hostedLinkUrl)`.
     */
    @JavascriptInterface
    fun openHostedLink(hostedLinkUrl: String)

    /**
     * Receives a file download from the web page as base64 bytes. The web page calls
     * this via `window.BranchAndroidBridge.downloadBase64(filename, mimeType, base64)`.
     * The host is expected to decode and persist the file.
     */
    @JavascriptInterface
    fun downloadBase64(
        filename: String,
        mimeType: String,
        base64: 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,
)

React Native Browser Window

Use BranchEmbeddedWebView (Example), a single component that serves both iOS and Android, in bare React Native and Expo apps alike.

Branch's web apps detect a React Native host by checking for window.ReactNativeWebView, which react-native-webview injects automatically. When they find it, they post JSON messages through window.ReactNativeWebView.postMessage; your app doesn't inject any scripts.

MessageSent when
{ type: 'branchPlaidLinkOpen', url }The worker starts linking a bank account. url is the Plaid Hosted Link URL. See React Native Bank Account Linking.
{ type: 'branchDownloadFile', filename, mimeType, base64 }The worker downloads a file, such as an On-Demand Pay Agreement. See React Native Bridge Downloading.

BranchEmbeddedWebView routes these to its onOpenHostedLink and onDownloadFile props, which you implement (the EWA sections below include samples). The result of a Plaid session goes back to the page as a branchPlaidLinkComplete event, dispatched for you by the notifyCompletion callback.

Dependencies: react-native-webview 13 or later. In Expo apps, install it with npx expo install react-native-webview so the version matches your Expo SDK.

🔁

Upgrading from the BranchAndroidBridge shim? Earlier versions of this guide injected a window.BranchAndroidBridge shim on both platforms. Existing shim integrations keep working, because Branch's web apps still check for BranchAndroidBridge first. To migrate, remove the shim and its injectedJavaScriptBeforeContentLoaded prop, and handle the branchPlaidLinkOpen and branchDownloadFile messages instead of openHostedLink and downloadBase64.

import React, { useCallback, useRef } from 'react';
import {
  ActivityIndicator,
  Linking,
  StyleProp,
  StyleSheet,
  View,
  ViewStyle,
} from 'react-native';
import WebView, { WebViewMessageEvent } from 'react-native-webview';

import {
  BridgeMessage,
  EmbeddedWebViewDownload,
  PlaidHostedLinkCompletion,
} from './types';

const PLAID_LINK_COMPLETE_EVENT = 'branchPlaidLinkComplete';

/** Branch web app origins allowed to send bridge messages. */
export const TRUSTED_ORIGINS = [
  'https://direct.branchapp.com',
  'https://onboarding.branchapp.com',

  //  Uncomment for sandbox testing
  // 'https://onboarding-sandbox.branchapp.com',
  // 'https://direct-sandbox.branchapp.com',
];

/**
 * True when `pageUrl` (the page that posted a bridge message) is on one of `TRUSTED_ORIGINS`.
 */
export function isTrustedSender(pageUrl: string | undefined): boolean {
  return (
    !!pageUrl && TRUSTED_ORIGINS.some(origin => pageUrl.startsWith(`${origin}/`))
  );
}

export interface BranchEmbeddedWebViewProps {
  /** Entry point URL for the flow (Onboarding or Branch Direct). */
  url: string;

  /**
   * EWA Implementation. Called when the page requests a Plaid Hosted Link
   * launch. Present `hostedLinkUrl` in an in-app auth session and call
   * `notifyCompletion` with the result so the page can resume.
   */
  onOpenHostedLink?: (
    hostedLinkUrl: string,
    notifyCompletion: (completion: PlaidHostedLinkCompletion) => void,
  ) => void;

  /** EWA Implementation. Called when the page hands over a file to download. */
  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`). On Android
   * the webview's built-in DownloadManager integration handles these.
   */
  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) => {
      // Only Branch pages may use the bridge. The worker can navigate the
      // webview elsewhere (e.g. a link inside the flow), and those pages can
      // call window.ReactNativeWebView.postMessage too.
      const { url: pageUrl, data } = event.nativeEvent;
      if (!isTrustedSender(pageUrl)) {
        console.warn('Rejected message from unauthorized origin:', pageUrl);
        return;
      }

      let message: BridgeMessage;
      try {
        message = JSON.parse(data);
      } catch {
        return;
      }

      switch (message.type) {
        case 'branchPlaidLinkOpen':
          if (message.url) {
            onOpenHostedLink?.(message.url, notifyPlaidCompletion);
          }
          break;

        case 'branchDownloadFile':
          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`. Allow the `about:` scheme so it loads inline.
      originWhitelist={['http://*', 'https://*', 'about:*']}
      style={style}
      onMessage={handleMessage}
      // New-window navigations (target=_blank, window.open) open in the
      // device's default browser.
      setSupportMultipleWindows
      javaScriptCanOpenWindowsAutomatically
      onOpenWindow={event => {
        Linking.openURL(event.nativeEvent.targetUrl).catch(() => {});
      }}
      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',
  },
});
/**
 * Result of a Plaid Hosted Link session, reported back to the page as the
 * `detail` of a `branchPlaidLinkComplete` CustomEvent.
 */
export type PlaidHostedLinkCompletion =
  | { status: "completed"; callbackUrl: string }
  | { status: "canceled" }
  | { status: "error"; message: string };

/** A file handed to the host app by the page for download. */
export interface EmbeddedWebViewDownload {
  filename: string;
  mimeType: string;
  /** Raw file contents, base64-encoded. */
  base64: string;
}

/**
 * JSON messages the Branch web apps post to React Native via
 * `window.ReactNativeWebView.postMessage`.
 */
export type BridgeMessage =
  | { type: "branchPlaidLinkOpen"; url: string }
  | {
      type: "branchDownloadFile";
      filename: string;
      mimeType: string;
      base64: string;
    };

React Native BranchEmbeddedWebView Example

The same usage works in bare React Native and Expo; only the handler implementations differ (see the EWA sections below).

<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, so no permission code is needed.

In bare React Native, add the permission to ios/<AppName>/Info.plist and android/app/src/main/AndroidManifest.xml.

<key>NSCameraUsageDescription</key>
<string>This app uses your camera to verify your identity.</string>
<uses-permission android:name="android.permission.CAMERA" />

In Expo, add both permission entries to app.json.

{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSCameraUsageDescription": "This app uses your camera to verify your identity."
      }
    },
    "android": {
      "permissions": ["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,
) : 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
    }
}

React Native External Link Handling

Handled inside BranchEmbeddedWebView (see above), the same in bare React Native and Expo: 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`
@JavascriptInterface
override fun downloadBase64(
    filename: String,
    mimeType: String,
    base64: String,
) {
    require(filename.isNotBlank()) {
        "filename is required"
    }
    require(base64.isNotBlank()) {
        "base64 is required"
    }

    val safeName = sanitizeFilename(filename)
    val safeMime = mimeType.takeIf {
        it.isNotBlank()
    } ?: DEFAULT_DOWNLOAD_MIME_TYPE
    val bytes = try {
        Base64.decode(base64, Base64.DEFAULT)
    } catch (e: IllegalArgumentException) {
        throw IllegalArgumentException("invalid base64 payload", e)
    }

    val file = try {
        writeToDownloadCache(safeName, bytes)
    } catch (e: IOException) {
        showDownloadError()
        throw e
    }

    launchShareDownloadIntent(file, safeMime)
}

private fun writeToDownloadCache(
    filename: String,
    bytes: ByteArray,
): File {
    val dir = File(cacheDir, DOWNLOAD_CACHE_DIRECTORY)
    if (!dir.exists() && !dir.mkdirs()) {
        throw IOException("could not create $dir")
    }
    val file = File(dir, filename)
    file.outputStream().use {
        it.write(bytes)
    }
    return file
}

// @JavascriptInterface methods run on a background thread, so UI work is posted to the main looper.
private fun launchShareDownloadIntent(
    file: File,
    mimeType: String,
) {
    Handler(Looper.getMainLooper()).post {
        val uri = FileProvider.getUriForFile(
            this,
            "${applicationContext.packageName}.fileprovider",
            file,
        )
        val send = Intent(Intent.ACTION_SEND).apply {
            type = contentResolver.getType(uri) ?: mimeType
            putExtra(Intent.EXTRA_STREAM, uri)
            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        }
        val chooser = Intent.createChooser(
            send,
            getString(R.string.embedded_web_view_share_download),
        )
        try {
            startActivity(chooser)
        } catch (_: ActivityNotFoundException) {
            Toast.makeText(this, R.string.embedded_web_view_download_failed, Toast.LENGTH_SHORT).show()
        }
    }
}

private fun showDownloadError() {
    Handler(Looper.getMainLooper()).post {
        Toast.makeText(applicationContext, R.string.embedded_web_view_download_failed, Toast.LENGTH_SHORT).show()
    }
}

private fun sanitizeFilename(name: String): String = name.replace(Regex("[\\\\/:*?\"<>|\\x00]"), "_").take(200)

companion object {
    private const val DEFAULT_DOWNLOAD_MIME_TYPE = "application/octet-stream"
    private const val DOWNLOAD_CACHE_DIRECTORY = "embeddedwebviewdownloads"
}

React Native File Downloading

Downloads reach a React Native or Expo host through two paths, and the samples handle both:

  1. Bridge downloads: the web app hands the file over as base64 bytes in a branchDownloadFile message. This is how documents like the On-Demand Pay Agreement are delivered. BranchEmbeddedWebView surfaces it as the onDownloadFile prop; it's identical on both platforms.
  2. Direct HTTP downloads: a response the webview can't render inline (Content-Disposition: attachment).
    1. On iOS this is surfaced as the onDownloadUrl prop (the counterpart of the native iOS WKDownload handling).
    2. On Android no code is required: the webview's built-in DownloadManager integration saves the file to the public Downloads folder with a system notification.

When a host app takes over a bridge download, the web app doesn't show its own success message; your share sheet is the confirmation.

React Native Bridge Downloading

Bridge downloads persist the bytes to the app's cache directory and offer them via the platform share sheet. The React Native sample uses react-native-blob-util and react-native-share; the Expo sample uses expo-file-system and expo-sharing.

import ReactNativeBlobUtil from "react-native-blob-util";
import Share from "react-native-share";

import { EmbeddedWebViewDownload } from "./branch-embedded";

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";
}
import { Directory, File, Paths } from "expo-file-system";
import * as Sharing from "expo-sharing";

import { EmbeddedWebViewDownload } from "./branch-embedded";

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);

  try {
    const directory = new Directory(Paths.cache, DOWNLOAD_DIRECTORY);
    directory.create({ idempotent: true });

    const file = new File(directory, safeName);
    if (file.exists) {
      file.delete();
    }
    file.write(base64, { encoding: "base64" });

    await Sharing.shareAsync(file.uri, {
      mimeType: mimeType || DEFAULT_MIME_TYPE,
    });
  } 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";
}
import { Directory, File, Paths } from "expo-file-system";
import * as Sharing from "expo-sharing";

const DOWNLOAD_DIRECTORY = "embeddedwebviewdownloads";

export async function downloadUrlToShare(url: string): Promise<void> {
  const filename = filenameFromUrl(url);

  try {
    const directory = new Directory(Paths.cache, DOWNLOAD_DIRECTORY);
    directory.create({ idempotent: true });

    const file = await File.downloadFileAsync(
      url,
      new File(directory, filename),
      { idempotent: true },
    );

    // iOS infers the file type from its extension, so no UTI is passed.
    await Sharing.shareAsync(file.uri);
  } 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";
}

On Android 9 and below, the webview's DownloadManager integration needs storage permission:

<uses-permission
    android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />
{
  "expo": {
    "android": {
      "permissions": ["android.permission.WRITE_EXTERNAL_STORAGE"]
    }
  }
}

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, in bare React Native or Expo. 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.

Set callbackURIScheme and callbackURIHost from the completion redirect URL configured for your organization in Pay Admin (for example yourapp://plaid-hosted-link-complete). ASWebAuthenticationSession only matches the callback scheme, so it closes on any URL with that scheme; check the host of the returned URL before reporting completed, as below.

// Add to EmbeddedWebViewController: UIViewController
private let plaidLinkCompleteEventName = "branchPlaidLinkComplete"
private var plaidHostedLinkSession: ASWebAuthenticationSession?
// Completion redirect URL configured in Pay Admin: yourapp://plaid-hosted-link-complete
private let callbackURIScheme = "yourapp"
private let callbackURIHost = "plaid-hosted-link-complete"
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 if let callbackURL,
              callbackURL.scheme?.lowercased() == self.callbackURIScheme,
              callbackURL.host?.lowercased() == self.callbackURIHost {
        detail = ["status": "completed", "callbackUrl": callbackURL.absoluteString]
    } else {
        // Any URL with the callback scheme ends the session; only the
        // Pay Admin completion redirect URL counts as completed.
        detail = [
            "status": "error",
            "message": "Unexpected Plaid callback URL: \(callbackURL?.absoluteString ?? "nil")"
        ]
    }

    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. Set PLAID_REDIRECT_SCHEME and PLAID_REDIRECT_HOST from the completion redirect URL configured for your organization in Pay Admin (for example yourapp://plaid-hosted-link-complete), and use the same values in the intent filter below.

// 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")
}

companion object {
    // Completion redirect URL configured in Pay Admin: yourapp://plaid-hosted-link-complete
    private const val PLAID_REDIRECT_SCHEME = "yourapp"
    private const val PLAID_REDIRECT_HOST = "plaid-hosted-link-complete"
    private const val PLAID_COMPLETE_EVENT = "branchPlaidLinkComplete"
}

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="yourapp"
            android:host="plaid-hosted-link-complete"/>
    </intent-filter>
</activity>

React Native Bank Account Linking

The web page requests a Plaid Hosted Link launch by posting a branchPlaidLinkOpen message, and 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. That dispatches the branchPlaidLinkComplete CustomEvent the page listens for, with one of these detail payloads:

detailWhen
{ status: 'completed', callbackUrl }Plaid redirected to the completion redirect URL. This happens whether the worker linked an account or exited inside Plaid; Branch confirms the outcome on its servers.
{ status: 'canceled' }The worker closed the auth session before Plaid redirected.
{ status: 'error', message }The auth session failed to open or run.

The React Native sample uses react-native-inappbrowser-reborn; the Expo sample uses expo-web-browser.

⚠️

Your callback URL must match Pay Admin exactly. Branch sends the completion redirect URL configured for your organization in Pay Admin to Plaid. Set PLAID_CALLBACK_URL to that full URL (scheme and host), and register it in your app (below). If the scheme doesn't match, the auth session never closes.

On iOS, ASWebAuthenticationSession only takes a callback scheme, so it closes on any URL with that scheme. Check that the returned URL starts with PLAID_CALLBACK_URL before reporting completed, as the samples below do.

import { Linking } from "react-native";
import { InAppBrowser } from "react-native-inappbrowser-reborn";

import { PlaidHostedLinkCompletion } from "./branch-embedded";

// Must match the completion redirect URL configured in Pay Admin.
const PLAID_CALLBACK_URL = "yourapp://plaid-hosted-link-complete";

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; completion isn't reported in this mode.
      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) {
      if (result.url.startsWith(PLAID_CALLBACK_URL)) {
        notifyCompletion({ status: "completed", callbackUrl: result.url });
      } else {
        // On iOS any URL with the callback scheme closes the session, so a
        // redirect to the wrong URL ends up here.
        notifyCompletion({
          status: "error",
          message: `Unexpected Plaid callback URL: ${result.url}`,
        });
      }
    } else {
      // The worker dismissed the sheet/tab before Plaid redirected.
      notifyCompletion({ status: "canceled" });
    }
  } catch (error) {
    notifyCompletion({
      status: "error",
      message: error instanceof Error ? error.message : String(error),
    });
  }
}
import * as WebBrowser from "expo-web-browser";

import { PlaidHostedLinkCompletion } from "./branch-embedded";

// Must match the completion redirect URL configured in Pay Admin.
const PLAID_CALLBACK_URL = "yourapp://plaid-hosted-link-complete";

export async function openPlaidHostedLink(
  hostedLinkUrl: string,
  notifyCompletion: (completion: PlaidHostedLinkCompletion) => void,
): Promise<void> {
  try {
    const result = await WebBrowser.openAuthSessionAsync(
      hostedLinkUrl,
      PLAID_CALLBACK_URL,
      {
        // iOS
        preferEphemeralSession: true,
        // Android
        showTitle: false,
        enableBarCollapsing: true,
        enableDefaultShareMenuItem: false,
      },
    );

    if (result.type === "success") {
      if (result.url.startsWith(PLAID_CALLBACK_URL)) {
        notifyCompletion({ status: "completed", callbackUrl: result.url });
      } else {
        // On iOS any URL with the callback scheme closes the session, so a
        // redirect to the wrong URL ends up here.
        notifyCompletion({
          status: "error",
          message: `Unexpected Plaid callback URL: ${result.url}`,
        });
      }
    } else {
      // The worker dismissed the sheet/tab before Plaid redirected.
      notifyCompletion({ status: "canceled" });
    }
  } catch (error) {
    notifyCompletion({
      status: "error",
      message: error instanceof Error ? error.message : String(error),
    });
  }
}

Register the callback URL so the Plaid redirect re-enters the app. In bare React Native, add an intent filter to your MainActivity in AndroidManifest.xml that matches both the scheme and the host of PLAID_CALLBACK_URL, and keep android:launchMode="singleTask" so the redirect returns to the existing activity. In Expo, set scheme in app.json; Expo generates an intent filter for the whole scheme, and the sample checks the rest of the URL.

<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="yourapp" android:host="plaid-hosted-link-complete" />
</intent-filter>
{
  "expo": {
    "scheme": "yourapp"
  }
}

No iOS configuration is required: ASWebAuthenticationSession claims the callback scheme for the duration of the session.

📘

Using Expo Router? On Android the Plaid redirect arrives as a normal deep link, and Expo Router tries to navigate to it, which shows an "Unmatched route" screen. Return null for the redirect from redirectSystemPath in app/+native-intent.tsx so the auth session handles it instead. Apps without Expo Router don't need this.

// Must match PLAID_CALLBACK_URL.
const PLAID_CALLBACK_URL = "yourapp://plaid-hosted-link-complete";

export function redirectSystemPath({
  path,
  initial,
}: {
  path: string;
  initial: boolean;
}) {
  if (path.startsWith(PLAID_CALLBACK_URL)) {
    // On a cold start there's no screen to return to, so go home.
    return initial ? "/" : null;
  }
  return path;
}

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.

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.

  1. Production URL: https://onboarding.branchapp.com/?org_id={uuid}
  2. Staging URL: https://onboarding-stg.branchapp.com/?org_id={uuid}
  3. 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