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

# Travel Rule Widget installation and setup

> Install the Uphold Travel Rule Widget SDK — or integrate directly against the Widget's message protocol — in web and native applications through a WebView.

By the end of this guide the Travel Rule Widget will be running in your app, mounted to a container, and emitting events you can react to.

## Before you start

You'll need:

* Access to [Widgets API](/rest-apis/widgets-api/travel-rule/create-session) to create widget sessions. Manage your access in [Enterprise Portal](https://portal.enterprise.uphold.com/).
* A **backend** that can call the Widgets API to create sessions on behalf of your users.
* A **frontend** — a web app, or a native app with a WebView — to embed the Widget.
* A quote or transaction with the **travel-rule** requirement — the Widget resolves either a pending request for information on an on-hold deposit (`deposit-form` flow) or a travel-rule requirement surfaced on a withdrawal quote (`withdrawal-form` flow). See [Travel Rule deposit via Widget](/developer-guides/travel-rule/deposit/via-widget) and [Travel Rule withdrawal via Widget](/developer-guides/travel-rule/withdrawal/via-widget) for how each requirement arises.

The Widget runs from one of two hosts depending on environment:

| Environment | Widget host |
| - | - |
| Sandbox | `https://travel-rule-widget.enterprise.sandbox.uphold.com` |
| Production | `https://travel-rule-widget.enterprise.uphold.com` |

## Choose an integration approach

We recommend integrating **without the SDK** for native apps — it's simpler to set up. For web-only integrations, the Web SDK is a solid default.

There are two ways to embed the Widget:

* **[Web SDK](#setup-with-web-sdk)** — Install `@uphold/enterprise-travel-rule-widget-web-sdk` for a better developer experience — typed events and a more streamlined integration on web. For native apps, the SDK must be bundled into the WebView's HTML page.
* **[Without the SDK](#setup-without-the-sdk)** — Listen for the Widget's messages over the iframe or WebView and respond to them directly. This is also the simpler option for native apps: there's no SDK to bundle into the WebView's HTML page.

Both approaches use the same backend step — creating a session via the Widgets API — and emit the same four lifecycle outcomes (`ready`, `complete`, `cancel`, `error`).

## Shared setup

These two steps are identical whichever approach you choose above — do them once, then jump to the matching section below.

### 1. Create a session on your backend

The Travel Rule Widget runs against a session — a short-lived, server-side authorization scoped to one flow and one user. Create it server-side using your OAuth credentials.

<Note>To create a session, you must have the `Travel Rule Widget` scope.</Note>

<Warning>Never create session directly from the client. Your client secret must not leave your backend.</Warning>

Call [`Create session`](/rest-apis/widgets-api/travel-rule/create-session) with the flow that matches what you're resolving and, optionally, the Widget `options`:

* `deposit-form` — resolves a pending request for information on an on-hold deposit. Pass `data.requestForInformationId`.
* `withdrawal-form` — resolves a travel-rule requirement on a withdrawal quote. Pass `data.quoteId`.

The example below creates a `deposit-form` session:

```bash theme={null}
curl -X POST https://api.sandbox.uphold.com/widgets/travel-rule/sessions \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-On-Behalf-Of: user $USER_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "flow": "deposit-form",
    "data": {
      "requestForInformationId": "3f6d0c1e-a1bf-4b25-9802-2a3ee492d3c8"
    },
    "options": {
      "layout": "fluid",
      "theme": {
        "appearance": "dark",
        "primary": "#4F46E5",
        "primaryForeground": "#FFFFFF",
        "foreground": "#E5E7EB",
        "background": "#111827",
        "fontFamily": { "sans": "Inter, sans-serif" },
        "components": {
          "button": { "borderRadius": "9999px" },
          "input": { "borderRadius": "8px" },
          "card": { "borderRadius": "12px" }
        }
      }
    }
  }'
```

The `options` object accepts the same options as the SDK, except `debug` — see [Configuration reference](#configuration-reference).

<Note>If you set `theme` or `layout`, also see [Theme and layout setup](#theme-and-layout-setup).</Note>

The response wraps the session object:

```json theme={null}
{
  "session": {
    "url": "https://travel-rule-widget.enterprise.sandbox.uphold.com/?sessionToken=..."
  }
}
```

Send `response.session` to your frontend, for example in your page response or through your own API endpoint.

Keep `session.url` unmodified, including its query string. The only change allowed is adding `theme_appearance` when integrating without the SDK (see [Theme and layout setup](#without-the-sdk)). Then:

* With the Web SDK, pass `response.session` as the `session` argument to the `TravelRuleWidget` constructor — see [Setup with Web SDK](#setup-with-web-sdk).
* Without the SDK, load `session.url` directly into your iframe or WebView — see [Setup without the SDK](#setup-without-the-sdk).

### 2. Allow the Widget domain in your CSP

If your web app embeds the Widget in an iframe and enforces a Content Security Policy, allow the Widget host for your environment(s) under `frame-src`.

```html theme={null}
<meta
  http-equiv="Content-Security-Policy"
  content="frame-src 'self' https://travel-rule-widget.enterprise.sandbox.uphold.com https://travel-rule-widget.enterprise.uphold.com;"
>
```

<Note>If your app does not use CSP, skip this step.</Note>

## Setup with Web SDK

### Install the SDK

Install the SDK in the frontend that will host the Widget — your web app, or the JS bundle loaded by your native WebView.

```bash theme={null}
npm install @uphold/enterprise-travel-rule-widget-web-sdk
```

### Initialize and mount the Widget

On the frontend, instantiate `TravelRuleWidget` with the session from [Create a session on your backend](#1-create-a-session-on-your-backend), then mount it into a container element. If you set `theme` or `layout` in Create session, also pass the same values to the constructor, to avoid a brief change in appearance (see [Theme and layout setup](#theme-and-layout-setup)).

```javascript theme={null}
import { TravelRuleWidget } from '@uphold/enterprise-travel-rule-widget-web-sdk';

// session is `response.session` from your backend — see Shared setup
const widget = new TravelRuleWidget(session, {
  debug: true, // Verbose logging during development
  // If you set `theme` or `layout` in Create session, pass the same values here
  layout: 'fluid',
  theme: { appearance: 'dark' }
});

widget.mountIframe(document.getElementById('travel-rule-container'));
```

<Note>The container must have explicit CSS width and height — the iframe fills its bounds. Minimum recommended size is **400px × 600px**.</Note>

For type inference on the `complete` event, pass the flow as a generic: `new TravelRuleWidget<'deposit-form'>(session)` (or `'withdrawal-form'`). See the [SDK reference](./sdk-reference#constructor) for full constructor details.

### Handle Widget events

The Widget emits four events during its lifecycle. Wire up handlers **before** calling `mountIframe`.

| Event | Fires when | What to do |
| - | - | - |
| `ready` | The Widget has finished loading | Hide your loading state |
| `complete` | The user finished the compliance form | The Widget already resolved the RFI — for `withdrawal-form` sessions, create the transaction; for `deposit-form` sessions, just wait for the transaction to leave `on-hold`. Then call `widget.unmount()` |
| `cancel` | The user dismissed the Widget | Call `widget.unmount()`, return them to your flow |
| `error` | The Widget hit an unrecoverable error | Read `event.detail.error`, call `widget.unmount()`, retry |

<Warning>The Widget does not unmount itself. You must call `widget.unmount()` from `complete`, `cancel`, and `error` handlers.</Warning>

```javascript [expandable] theme={null}
widget.on('ready', () => {
  console.log('Travel Rule Widget is ready');
});

widget.on('complete', async (event) => {
  console.log('Travel Rule form completed');

  // The Widget already resolved the RFI. For withdrawal-form sessions, create the transaction now.
  // For deposit-form sessions, just wait for the transaction to leave on-hold.
  await onTravelRuleComplete();

  widget.unmount();
});

widget.on('cancel', () => {
  console.log('Travel Rule form cancelled');
  widget.unmount();
});

widget.on('error', (event) => {
  console.error('Travel Rule error:', event.detail.error);
  widget.unmount();
});
```

<Note>The Widget resolves the underlying request for information itself by calling [Update request for information](/rest-apis/core-api/requests-for-information/update-request-for-information) internally — you don't need to forward `event.detail.value` anywhere. For `withdrawal-form` sessions, once `complete` fires, call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote id. For `deposit-form` sessions, the transaction leaves `on-hold` on its own once the RFI clears. See [Travel Rule deposit via Widget](/developer-guides/travel-rule/deposit/via-widget#handle-the-complete-event) and [Travel Rule withdrawal via Widget](/developer-guides/travel-rule/withdrawal/via-widget#handle-complete-event) for full examples. The `error.code` property (e.g. `entity_not_found`, `validation_failed`) lets you distinguish an expired session from a form validation issue — see [Events](./sdk-reference#events) in the SDK reference for the full error shape.</Note>

### Native apps with the Web SDK

<Accordion title="Bundle the SDK into a WebView (not recommended — see Setup without the SDK instead)">
  Native mobile apps can also use the SDK to embed the Widget through a WebView, building on the [Install the SDK](#install-the-sdk) and [Initialize and mount the Widget](#initialize-and-mount-the-widget) steps above. To do so, it requires you to bundle the SDK into the WebView's HTML page, and forward events to native code through a bridge. We recommend integrating [without the SDK](#setup-without-the-sdk) for native apps as it has a simpler setup, but if you choose to use the SDK, follow the steps below.

  Create a JS bundle that includes the SDK. This bundle will be loaded in the WebView. You can use a tool like [Webpack](https://webpack.js.org/) or [Rollup](https://rollupjs.org/) to bundle the SDK and your custom code into a single JS file.

  <Note>Loading the Web SDK from a CDN is not supported.</Note>

  Then create an HTML page that includes the JS bundle and mounts the Widget. This page will be loaded in the WebView. Here is an example you can use — the `sendToNativeApp` helper at the bottom forwards events to whichever bridge is available (iOS, Android, or React Native).

  ```html [expandable] theme={null}
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>Travel Rule Widget</title>
      <style>
        body { margin: 0; padding: 0; }
        #travel-rule-container { width: 100%; height: 100vh; }
      </style>
    </head>
    <body>
      <div id="travel-rule-container"></div>

      <!-- Include your JS bundle which contains the SDK -->
      <script src="your-bundle-with-sdk.js"></script>
      <script>
        window.addEventListener('load', async () => {
          const session = await createTravelRuleWidgetSession();
          const widget = new TravelRuleWidget(session);

          widget.on('complete', (event) => {
            sendToNativeApp('complete', event.detail.value);
            widget.unmount();
          });

          widget.on('cancel', () => {
            sendToNativeApp('cancel');
            widget.unmount();
          });

          widget.on('error', (event) => {
            sendToNativeApp('error', event.detail.error);
            widget.unmount();
          });

          widget.mountIframe(document.getElementById('travel-rule-container'));
        });

        function sendToNativeApp(type, data = null) {
          const message = { type, data };

          if (window.webkit?.messageHandlers?.travelRuleWidgetMessage) {
            // iOS - send all events through single handler
            window.webkit.messageHandlers.travelRuleWidgetMessage.postMessage(message);
          } else if (window.TravelRuleBridge) {
            // Android - send events through JavaScript interface
            window.TravelRuleBridge.onMessage(JSON.stringify(message));
          } else if (window.ReactNativeWebView) {
            // React Native - send events through postMessage
            window.ReactNativeWebView.postMessage(JSON.stringify(message));
          }
        }
      </script>
    </body>
  </html>
  ```

  ### Platform setup

  Here is a sample of how to create a WebView in your native app and load the HTML page that contains the SDK and mounts the Widget. The WebView should be configured to allow JavaScript execution and to forward messages to your native code.

  <Tabs>
    <Tab title="iOS (Swift)">
      ```swift [expandable] theme={null}
      import WebKit

      class TravelRuleViewController: UIViewController, WKScriptMessageHandler {
        @IBOutlet weak var webView: WKWebView!

        override func viewDidLoad() {
          super.viewDidLoad()

          // Register a single message handler for all Widget events
          let contentController = webView.configuration.userContentController
          contentController.add(self, name: "travelRuleWidgetMessage")

          if let url = Bundle.main.url(forResource: "travel-rule-widget", withExtension: "html") {
            webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
          }
        }

        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
          guard message.name == "travelRuleWidgetMessage",
                let messageDict = message.body as? [String: Any],
                let type = messageDict["type"] as? String else {
            return
          }

          let data = messageDict["data"]

          switch type {
            case "complete": handleTravelRuleComplete(data: data)
            case "cancel":   handleTravelRuleCancel()
            case "error":    handleTravelRuleError(error: data)
            default: print("Unknown Travel Rule Widget message type: \(type)")
          }
        }

        private func handleTravelRuleComplete(data: Any?) { /* send data to your backend to resolve the RFI or create the transaction */ }
        private func handleTravelRuleCancel() { /* return user to previous screen */ }
        private func handleTravelRuleError(error: Any?) { /* show error UI */ }

        deinit {
          webView?.configuration.userContentController.removeScriptMessageHandler(forName: "travelRuleWidgetMessage")
        }
      }
      ```
    </Tab>

    <Tab title="Android (Java/Kotlin)">
      ```java [expandable] theme={null}
      import android.webkit.WebView;
      import android.webkit.WebSettings;
      import android.webkit.JavascriptInterface;
      import android.util.Log;
      import org.json.JSONObject;

      WebView webView = findViewById(R.id.webview);
      WebSettings webSettings = webView.getSettings();
      webSettings.setJavaScriptEnabled(true);

      webView.addJavascriptInterface(new TravelRuleBridge(), "TravelRuleBridge");
      webView.loadUrl("file:///android_asset/travel-rule-widget.html");

      public class TravelRuleBridge {
        @JavascriptInterface
        public void onMessage(String messageJson) {
          try {
            JSONObject message = new JSONObject(messageJson);
            String type = message.getString("type");
            Object data = message.opt("data");

            runOnUiThread(() -> {
              switch (type) {
                case "complete": handleTravelRuleComplete(data != null ? data.toString() : null); break;
                case "cancel":   handleTravelRuleCancel(); break;
                case "error":    handleTravelRuleError(data != null ? data.toString() : null); break;
                default: Log.w("TravelRule", "Unknown Travel Rule Widget message type: " + type);
              }
            });
          } catch (Exception e) {
            Log.e("TravelRule", "Error parsing Travel Rule Widget message", e);
          }
        }

        private void handleTravelRuleComplete(String data) { /* send data to your backend to resolve the RFI or create the transaction */ }
        private void handleTravelRuleCancel() { /* return user to previous screen */ }
        private void handleTravelRuleError(String error) { /* show error UI */ }
      }
      ```

      <Note>If you load local assets, ensure the WebView allows file access according to your security requirements (e.g. `setAllowFileAccess(true)` when needed).</Note>
    </Tab>

    <Tab title="React Native">
      ```javascript [expandable] theme={null}
      import { WebView } from 'react-native-webview';
      import { Alert } from 'react-native';

      const TravelRuleScreen = () => {
        const handleMessage = (event) => {
          try {
            const message = JSON.parse(event.nativeEvent.data);

            switch (message.type) {
              case 'complete': handleTravelRuleComplete(message.data); break;
              case 'cancel':   handleTravelRuleCancel(); break;
              case 'error':    handleTravelRuleError(message.data); break;
              default: console.log('Unknown message type:', message.type);
            }
          } catch (error) {
            console.error('Error parsing WebView message:', error);
          }
        };

        const handleTravelRuleComplete = (data) => { /* send data to your backend to resolve the RFI or create the transaction */ };
        const handleTravelRuleCancel = () => { /* return user to previous screen */ };
        const handleTravelRuleError = (error) => { /* show error UI */ };

        return (
          <WebView
            source={{ uri: 'file:///path/to/travel-rule-widget.html' }}
            javaScriptEnabled={true}
            domStorageEnabled={true}
            onMessage={handleMessage}
          />
        );
      };
      ```

      <Note>For iOS, `file:` URLs may be restricted. Consider using `source={{ html: '<html>...</html>' }}` or loading a bundled asset and adjusting the URI per platform.</Note>
    </Tab>
  </Tabs>
</Accordion>

## Setup without the SDK

Instead of installing the SDK, you can load the Widget's session `url` directly — as an iframe you create yourself on web, or as your WebView's top-level page on native — and speak its underlying message protocol directly. This is useful for hosts that can't ship a JS bundle to their WebView, or want a fully native shell around the Widget.

<Note>The [Shared setup](#shared-setup) steps still apply here — you just don't install anything. Only how you load the Widget and exchange messages changes.</Note>

### The message protocol

These are the message types the Widget speaks. The transport carrying them differs per platform — see the tabs below.

**From the Widget to your host:**

| Type | Payload | Fires when | What to do |
| - | - | - | - |
| `load` | — | The Widget's script has booted | Optionally, reply with an `init` message to pass options from your frontend (see below) |
| `ready` | — | The Widget is ready for interaction | Hide your loading state |
| `complete` | `value` | The user finished the compliance form | Read `value`, send it to your backend to resolve the RFI or create the transaction, then tear down the iframe/WebView |
| `cancel` | — | The user dismissed the Widget | Tear down the iframe/WebView, return them to your flow |
| `error` | `error` | The Widget hit an unrecoverable error | Read `error`, tear down the iframe/WebView, retry |
| `force_repaint` | — | Works around a WKWebView redraw issue. iOS WKWebView hosts only (including React Native on iOS) | Briefly toggle the WebView's opacity to force a repaint |

**From your host to the Widget:**

| Type | Payload | Send when |
| - | - | - |
| `init` | `{ options }` | Optional. Replying to the Widget's `load` message |

The Widget loads its session on its own, using the options set when creating the session, so the `init` reply is optional. The `init` payload carries an `options` object with the same shape as the SDK's [`TravelRuleWidgetOptions`](./sdk-reference#options), except `debug` — omit it or pass `{}` to use defaults. For now, include the same `theme` and `layout` you set in Create session, to avoid a brief change in appearance (see [Theme and layout setup](#theme-and-layout-setup)).

If you send `init`, its options are merged with the ones set when creating the session, and **Create session** values take precedence for any value set in both.

<Note>Sending the full session object in `init` (`{ ...session, options }`) still works but is deprecated. The Widget ignores it if it has already loaded its session, and logs a console warning when it's used. Send `{ options }` only.</Note>

### Platform implementation

<Tabs>
  <Tab title="Web">
    Mount the session `url` in an iframe you create yourself, and exchange messages over the standard `window.postMessage` API. Make sure the iframe's container has explicit CSS width and height (minimum recommended size is **400px × 600px**).

    ```html [expandable] theme={null}
    <div id="travel-rule-container"></div>

    <script>
      // session is `response.session` from your backend
      async function startTravelRuleWidget(session) {
        const container = document.getElementById('travel-rule-container');
        const sessionOrigin = new URL(session.url).origin;

        const iframe = document.createElement('iframe');
        iframe.src = session.url;
        iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src';");
        iframe.style.width = '100%';
        iframe.style.height = '100%';
        iframe.style.border = 'none';

        function teardown() {
          window.removeEventListener('message', onMessage);
          iframe.remove();
        }

        function onMessage(event) {
          if (event.origin !== sessionOrigin) return;

          switch (event.data?.type) {
            case 'load':
              // Options are the TravelRuleWidgetOptions shape from the SDK reference. Omit or pass {} to use defaults.
              iframe.contentWindow.postMessage({ options: {}, type: 'init' }, sessionOrigin);
              break;
            case 'ready':
              console.log('Travel Rule Widget is ready');
              break;
            case 'complete':
              console.log('Travel Rule data:', event.data.value);
              teardown();
              break;
            case 'cancel':
              console.log('Travel Rule form cancelled');
              teardown();
              break;
            case 'error':
              console.error('Travel Rule error:', event.data.error);
              teardown();
              break;
          }
        }

        window.addEventListener('message', onMessage);
        container.appendChild(iframe);
      }
    </script>
    ```
  </Tab>

  <Tab title="iOS (Swift)">
    Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the `uphdTravelRuleWidget` message handler; to reply, call `window.TravelRuleWidgetBridge.sendMessageToWidget(...)` via `evaluateJavaScript`, passing an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `TravelRuleViewController` below ties this together into a complete example, forwarding the Widget's messages to native callbacks.

    ```swift [expandable] theme={null}
    import WebKit

    final class TravelRuleViewController: UIViewController, WKScriptMessageHandler {

      var onComplete: ((Any?) -> Void)?
      var onCancel: (() -> Void)?
      var onError: ((Any?) -> Void)?

      private let webView: WKWebView = {
        let webView = WKWebView(frame: .zero)
        webView.translatesAutoresizingMaskIntoConstraints = false
        return webView
      }()

      override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(webView)
        NSLayoutConstraint.activate([
          webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
          webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
          webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
          webView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])

        // Registers the exact handler name the Widget looks for
        webView.configuration.userContentController.add(self, name: "uphdTravelRuleWidget")

        Task {
          do {
            let session = try await createTravelRuleWidgetSession() // calls your backend

            guard let urlString = session["url"] as? String, let url = URL(string: urlString) else {
              onError?(["message": "Travel Rule Widget session response is missing a valid 'url'"])
              return
            }

            webView.load(URLRequest(url: url))
          } catch {
            onError?(["message": "\(error)"])
          }
        }
      }

      // Replies to the Widget's "load" message with an "init" command carrying an options
      // object — same shape as the SDK's TravelRuleWidgetOptions. Empty here; see Configuration
      // reference to customize it.
      private func sendInitMessageToWidget() {
        let initMessage: [String: Any] = ["type": "init", "options": [:]]

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

        webView.evaluateJavaScript("window.TravelRuleWidgetBridge.sendMessageToWidget(\(json));")
      }

      // Nudges the page's opacity to force a repaint — needed to work around a WKWebView
      // redraw bug when the Widget uses the View Transitions API.
      private func forceRepaintWidget() {
        webView.evaluateJavaScript("""
        document.documentElement.style.opacity = '0.99';
        setTimeout(() => { document.documentElement.style.opacity = '1'; }, 0);
        """)
      }

      func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        // The Widget JSON.stringifies its messages before posting them, so the body
        // arrives as a string rather than an already-decoded dictionary.
        guard message.name == "uphdTravelRuleWidget",
              let jsonString = message.body as? String,
              let data = jsonString.data(using: .utf8),
              let messageDict = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let type = messageDict["type"] as? String else {
          return
        }

        switch type {
          case "load":          sendInitMessageToWidget()
          case "force_repaint": forceRepaintWidget()
          case "ready":          print("Travel Rule Widget is ready")
          case "complete":       onComplete?(messageDict["value"])
          case "cancel":         onCancel?()
          case "error":          onError?(messageDict["error"])
          default: print("Unknown Travel Rule Widget message type: \(type)")
        }
      }

      deinit {
        webView.configuration.userContentController.removeScriptMessageHandler(forName: "uphdTravelRuleWidget")
      }
    }
    ```
  </Tab>

  <Tab title="Android (Kotlin)">
    Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the `uphdTravelRuleWidget` object registered below; to reply, call `replyProxy.postMessage(...)`, passing an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `TravelRuleActivity` below ties this together into a complete example, forwarding the Widget's messages to native callbacks.

    <Warning>There is no bundled test app reference for this path yet. Validate the message names and payload shapes below against the version of the Widget you're integrating.</Warning>

    ```kotlin [expandable] theme={null}
    import android.webkit.WebView
    import androidx.webkit.JavaScriptReplyProxy
    import androidx.webkit.WebViewCompat
    import androidx.webkit.WebViewFeature
    import org.json.JSONObject

    class TravelRuleActivity : AppCompatActivity() {

      private lateinit var webView: WebView

      override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_travel_rule)

        webView = findViewById(R.id.webview)
        webView.settings.javaScriptEnabled = true

        if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
          WebViewCompat.addWebMessageListener(
            webView,
            "uphdTravelRuleWidget",
            setOf(
              "https://travel-rule-widget.enterprise.sandbox.uphold.com",
              "https://travel-rule-widget.enterprise.uphold.com"
            )
          ) { _, message, _, _, replyProxy ->
            val body = JSONObject(message.data ?: return@addWebMessageListener)

            when (body.getString("type")) {
              "load" ->     sendInitMessageToWidget(replyProxy)
              "ready" ->    Log.d("TravelRule", "Travel Rule Widget is ready")
              "complete" -> handleTravelRuleComplete(body.opt("value"))
              "cancel" ->   handleTravelRuleCancel()
              "error" ->    handleTravelRuleError(body.opt("error"))
              else -> Log.w("TravelRule", "Unknown Travel Rule Widget message type: ${body.getString("type")}")
            }
          }
        } else {
          // Fall back to Setup Web SDK on WebViews that don't support WEB_MESSAGE_LISTENER.
        }

        lifecycleScope.launch {
          try {
            val session = createTravelRuleWidgetSession() // calls your backend
            webView.loadUrl(session.getString("url"))
          } catch (e: Exception) {
            handleTravelRuleError(e.message)
          }
        }
      }

      // Replies to the Widget's "load" message with an "init" command carrying an options
      // object — same shape as the SDK's TravelRuleWidgetOptions. Empty here; see Configuration
      // reference to customize it.
      private fun sendInitMessageToWidget(replyProxy: JavaScriptReplyProxy) {
        val initMessage = JSONObject()
          .put("type", "init")
          .put("options", JSONObject())

        replyProxy.postMessage(initMessage.toString())
      }

      private fun handleTravelRuleComplete(value: Any?) { /* send value to your backend to resolve the RFI or create the transaction */ }
      private fun handleTravelRuleCancel() { /* return user to previous screen */ }
      private fun handleTravelRuleError(error: Any?) { /* show error UI */ }
    }
    ```

    <Note>`force_repaint` is a WKWebView-specific workaround and isn't expected on Android — the listener above doesn't need to handle it.</Note>
  </Tab>

  <Tab title="React Native">
    Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the WebView's `onMessage` prop, once it detects `window.ReactNativeWebView` (injected automatically by `react-native-webview`); to reply, call `window.TravelRuleWidgetBridge.sendMessageToWidget(...)` via `injectJavaScript`, passing an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `TravelRuleScreen` below ties this together into a complete example, forwarding the Widget's messages to native callbacks.

    ```jsx [expandable] theme={null}
    import React, { useRef } from 'react';
    import { WebView } from 'react-native-webview';

    const TravelRuleScreen = ({ session }) => {
      const webViewRef = useRef(null);

      // Replies to the Widget's "load" message with an "init" command carrying an options
      // object.
      const sendInitMessageToWidget = () => {
        const initMessage = JSON.stringify({ options: {}, type: 'init' });

        webViewRef.current?.injectJavaScript(`window.TravelRuleWidgetBridge.sendMessageToWidget(${initMessage}); true;`);
      };

      const handleMessage = (event) => {
        const message = JSON.parse(event.nativeEvent.data);

        switch (message.type) {
          case 'load':     sendInitMessageToWidget(); break;
          case 'ready':     console.log('Travel Rule Widget is ready'); break;
          case 'complete':  handleTravelRuleComplete(message.value); break;
          case 'cancel':    handleTravelRuleCancel(); break;
          case 'error':     handleTravelRuleError(message.error); break;
          default: console.log('Unknown Travel Rule Widget message type:', message.type);
        }
      };

      const handleTravelRuleComplete = (value) => { /* send value to your backend to resolve the RFI or create the transaction */ };
      const handleTravelRuleCancel = () => { /* return user to previous screen */ };
      const handleTravelRuleError = (error) => { /* show error UI */ };

      return (
        <WebView
          ref={webViewRef}
          source={{ uri: session.url }}
          javaScriptEnabled
          onMessage={handleMessage}
        />
      );
    };
    ```

    <Note>`force_repaint` is a WKWebView-specific workaround. If you see repaint glitches on iOS, forward it the same way as `load`, toggling the WebView's `opacity` briefly via `injectJavaScript`. Check the iOS example for details.</Note>
  </Tab>
</Tabs>

## Theme and layout setup

Set `theme` and `layout` in the [Create session](/rest-apis/widgets-api/travel-rule/create-session) `options` to customize the Widget's appearance. See [WidgetThemeOption](/widgets/travel-rule/sdk-reference#widgetthemeoption) and [WidgetLayout](/widgets/travel-rule/sdk-reference#widgetlayout) for all options.

The Widget applies the options set in Create session once it has loaded the session. Until then, it renders its defaults: the browser or OS color scheme and the boxed layout. If you set `theme` or `layout`, the Widget may briefly show those defaults before switching.

Until a future release removes the need for it, pass the same `theme` and `layout` from your frontend as well. Use the same values in both places, since Create session values replace the frontend ones once the session loads.

### With the Web SDK

Pass the same `theme` and `layout` in the constructor `options`:

```javascript theme={null}
const widget = new TravelRuleWidget(session, {
  layout: 'fluid',
  theme: { appearance: 'dark' }
});
```

### Without the SDK

1. Before loading `session.url`, append a `theme_appearance` query parameter with the value you want (`dark` or `light`). The Widget renders before it receives any options, so this makes its first render use the right light or dark mode instead of the browser or OS color scheme:

   ```javascript theme={null}
   const url = new URL(session.url);
   url.searchParams.set('theme_appearance', 'dark'); // or 'light'
   ```

   This works the same whether you load the result into a web iframe or as your native WebView's top-level page.

   <Note>Add `theme_appearance` through `URL`/`URLSearchParams`, as above, to preserve the `sessionToken` query parameter the Widget needs to load its session. Don't rebuild the query string by hand.</Note>

2. When the Widget sends its `load` message, reply with an `init` message carrying the same `theme` and `layout`:

   ```javascript theme={null}
   iframe.contentWindow.postMessage(
     { options: { layout: 'fluid', theme: { appearance: 'dark' } }, type: 'init' },
     sessionOrigin
   );
   ```

## Test in Sandbox

With your Sandbox credentials and the Sandbox Widget host configured, run through this checklist — it applies whichever approach you integrated with:

* The Widget mounts and `ready` fires.
* Completing the compliance form fires `complete` with the expected `value` for your flow — `event.detail.value` with the Web SDK, or the `value` payload of the `complete` message without it.
* Closing or dismissing the Widget fires `cancel`.
* If your app uses a CSP (see [Shared setup](#shared-setup)), the browser console shows no violations (look for "Refused to frame").

Once Sandbox is green, swap your OAuth credentials to Production. The Widget host is selected automatically by the session `url` returned from your backend — no client-side environment switching is needed.

## Configuration reference

The most common Widget options. See the [SDK reference](./sdk-reference#options) for the full schema and all event types.

If you're integrating without the SDK, these map directly to the `options` object you send in the [Create session](/rest-apis/widgets-api/travel-rule/create-session) request, or in your `init` reply, except `debug`, which only applies to the SDK.

| Option | Purpose |
| - | - |
| `theme` | Customize the Widget's appearance: force `light` or `dark` mode, set brand colors, typography, and per-component border radii. See [WidgetThemeOption](/widgets/travel-rule/sdk-reference#widgetthemeoption) for all options. |
| `layout` | Control how the Widget is laid out on larger viewports — a centered, framed `'boxed'` card (default) or a `'fluid'` fill of its container. See [WidgetLayout](/widgets/travel-rule/sdk-reference#widgetlayout) for details. |
| `debug` | **SDK only.** Verbose SDK console logging. Use during development. Has no effect in the Create session request or the `init` message. |

## Troubleshooting

**Widget not displaying**

Confirm the Widget host for your environment is in the `frame-src` directive of your CSP (see [Shared setup](#shared-setup)). Open DevTools → Console and look for `Refused to frame` violations.

**Container is empty after mount**

The iframe fills its container — the container must have explicit CSS width and height. Minimum recommended size is 400px × 600px.

**Widget briefly shows the wrong theme or layout**

The Widget renders its defaults (the browser or OS color scheme and the boxed layout) until it loads the session, then switches to the `theme` and `layout` set in Create session. Verify that:

* You pass the same `theme` and `layout` from your frontend, with the same values as in Create session.
* Without the SDK, you append `theme_appearance` to `session.url` before loading it, and reply to `load` with `init` as soon as you receive it.

See [Theme and layout setup](#theme-and-layout-setup).

**Events not firing in native apps (with the Web SDK)**

Verify that:

* JavaScript is enabled in the WebView.
* The message bridge is registered **before** the HTML page loads.
* Event handler names match the platform-specific bridge contract used in `sendToNativeApp`.

**Events not firing (without the SDK)**

Verify that:

* Your message-handler / listener name is exactly `uphdTravelRuleWidget` — the name the Widget checks for on both iOS and Android — and is registered **before** the WebView loads the session `url`.
* On web, you're filtering incoming `message` events by origin (`event.origin === sessionOrigin`) — messages from other origins should be ignored, not treated as Widget events.

**Widget stays in a loading state and sends `error`**

The Widget couldn't load its session. Usually, the `sessionToken` in `session.url` is expired, invalid, or already used, or `session.url` was modified or rebuilt without its query string. Create a new session and load its `session.url` unmodified.

**Widget never unmounts.** The Web SDK does not auto-unmount, and neither does the Widget itself when integrating without it. Call `widget.unmount()` (Web SDK) or tear down your iframe/WebView (without the SDK) from each terminal message (`complete`, `cancel`, `error`).

## Next steps

* Review the complete [SDK Reference](./sdk-reference) for all available methods and events.
* See [Handle quote requirements](/developer-guides/crypto-transfers/withdrawal/via-rest-api#handle-quote-requirements) and [Handle on-hold transactions](/developer-guides/crypto-transfers/deposit/via-rest-api#handle-on-hold-transactions) for practical examples of using the Travel Rule Widget in transactions.
