> ## 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.

# Payment Widget installation and setup

> Install the Uphold Payment 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 Payment 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/payment/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.

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

| Environment | Widget host                                            |
| ----------- | ------------------------------------------------------ |
| Sandbox     | `https://payment-widget.enterprise.sandbox.uphold.com` |
| Production  | `https://payment-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 SDK is a solid default.

There are two ways to embed the Widget:

* **Web SDK** — Install `@uphold/enterprise-payment-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, and if your flow needs Apple Pay or Google Pay, that page must be served from a real HTTPS origin rather than bundled locally.
* **JavaScript** — 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, so payment methods like Apple Pay and Google Pay work without any extra setup.

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 Payment 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 `Payment 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/payment/create-session) with the desired `flow` (`select-for-deposit`, `select-for-withdrawal`, or `authorize`) and the user the session is for:

```bash theme={null}
curl -X POST https://api.sandbox.uphold.com/widgets/payment/sessions \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-On-Behalf-Of: user $USER_ID" \
  -H "Content-Type: application/json" \
  -d '{ "flow": "select-for-deposit" }'
```

The response wraps the session object:

```json theme={null}
{
  "session": {
    "flow": "select-for-deposit",
    "url": "https://payment-widget.enterprise.sandbox.uphold.com/...",
    "token": "..."
  }
}
```

Pass `response.session` to your frontend (e.g. as part of your page response or via your own API endpoint):

* With the SDK, it's the single `session` argument to the `PaymentWidget` constructor — see [Setup Web SDK](#setup-with-web-sdk).
* Without the SDK, `session.url` is what you load directly into your iframe or WebView, and the whole object is what you send back as `init` — see [Setup with JavaScript](#setup-with-javascript).

### 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://payment-widget.enterprise.sandbox.uphold.com https://payment-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-payment-widget-web-sdk
```

### Initialize and mount the Widget

On the frontend, instantiate `PaymentWidget` with the session from [Create a session on your backend](#1-create-a-session-on-your-backend), then mount it into a container element.

```javascript [expandable] theme={null}
import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk';

// session is `response.session` from your backend — see Shared setup
const widget = new PaymentWidget(session, {
  paymentMethods: [
    { type: 'card' },
    { type: 'bank' },
    { type: 'crypto', assets: { include: ['BTC', 'ETH', 'XRP'] } },
    { type: 'paypal' },
    { type: 'apple-pay'},
    { type: 'google-pay'}
  ],
  theme: { appearance: 'dark' }, // omit to follow system preference
  debug: true                    // verbose logging during development
});

widget.mountIframe(document.getElementById('payment-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 PaymentWidget<'select-for-deposit'>(session)`. 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 selection or authorization | Read `event.detail.value`, 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('Payment Widget is ready');
});

widget.on('complete', (event) => {
  console.log('Selection:', event.detail.value);
  widget.unmount();
});

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

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

The shape of `event.detail.value` varies by flow. See [Events](./sdk-reference#events) in the SDK reference for the full type definitions.

### Native apps with the SDK

<Accordion title="Bundle the Web SDK into a WebView (not recommended — see Setup with JavaScript 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-with-javascript) 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>Payment Widget</title>
      <style>
        body { margin: 0; padding: 0; }
        #payment-container { width: 100%; height: 100vh; }
      </style>
    </head>
    <body>
      <div id="payment-container"></div>

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

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

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

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

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

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

          if (window.webkit?.messageHandlers?.paymentWidgetMessage) {
            window.webkit.messageHandlers.paymentWidgetMessage.postMessage(message);
          } else if (window.PaymentBridge) {
            window.PaymentBridge.onMessage(JSON.stringify(message));
          } else if (window.ReactNativeWebView) {
            window.ReactNativeWebView.postMessage(JSON.stringify(message));
          }
        }
      </script>
    </body>
  </html>
  ```

  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.

  <Warning>The following example will load the HTML from the app's bundle but if you intend to use certain payment methods (e.g. Apple Pay or Google Pay), the HTML page must be served from a real HTTPS origin rather than bundled locally.</Warning>

  ### Platform setup

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

      class PaymentViewController: 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: "paymentWidgetMessage")

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

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

          let data = messageDict["data"]

          switch type {
            case "complete": handlePaymentComplete(data: data)
            case "cancel":   handlePaymentCancel()
            case "error":    handlePaymentError(error: data)
            default: print("Unknown Payment Widget message type: \(type)")
          }
        }

        private func handlePaymentComplete(data: Any?) { /* navigate to success */ }
        private func handlePaymentCancel() { /* return user to previous screen */ }
        private func handlePaymentError(error: Any?) { /* show error UI */ }

        deinit {
          webView?.configuration.userContentController.removeScriptMessageHandler(forName: "paymentWidgetMessage")
        }
      }
      ```
    </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 PaymentBridge(), "PaymentBridge");
      webView.loadUrl("file:///android_asset/payment-widget.html");

      public class PaymentBridge {
        @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": handlePaymentComplete(data != null ? data.toString() : null); break;
                case "cancel":   handlePaymentCancel(); break;
                case "error":    handlePaymentError(data != null ? data.toString() : null); break;
                default: Log.w("Payment", "Unknown Payment Widget message type: " + type);
              }
            });
          } catch (Exception e) {
            Log.e("Payment", "Error parsing Payment Widget message", e);
          }
        }

        private void handlePaymentComplete(String data) { /* navigate to success */ }
        private void handlePaymentCancel() { /* return user to previous screen */ }
        private void handlePaymentError(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 PaymentScreen = () => {
        const handleMessage = (event) => {
          try {
            const message = JSON.parse(event.nativeEvent.data);

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

        const handlePaymentComplete = (data) => { /* navigate to success */ };
        const handlePaymentCancel = () => { /* return user to previous screen */ };
        const handlePaymentError = (error) => { /* show error UI */ };

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

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

## Setup with JavaScript

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. This approach is recommended for native apps as there is no need for bundling the SDK into the WebView's HTML page and serving that page from a real HTTPS origin to avoid issues with Apple Pay/Google Pay.

<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 and needs its session | Reply with an `init` message (see below)                |
| `ready`         | —       | The Widget is ready for interaction                  | Hide your loading state                                 |
| `complete`      | `value` | The user finished selection or authorization         | Read `value`, 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 redraw issue in mobile Safari only    | Briefly toggle the WebView's opacity to force a repaint |

**From your host to the Widget:**

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

The `init` payload spreads the session object from [Create a session on your backend](#1-create-a-session-on-your-backend) (`url`, `token`, `flow`) alongside an `options` object with the same shape as the SDK's [`PaymentWidgetOptions`](./sdk-reference#options) — omit `options` or pass `{}` to use defaults.

### Specifying the theme appearance

By default, the Widget matches the browser or OS `prefers-color-scheme` until it receives your `init` reply. To control the initial appearance yourself — and avoid a flash if it won't match what you send in `init` — append a `theme_appearance` query parameter (`dark` or `light`) to `session.url` before loading it:

```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.

### 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 `allow` attribute includes clipboard permissions (and payment permissions, for Apple Pay and Google Pay), and that its container has explicit CSS width and height (minimum recommended size is **400px × 600px**).

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

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

        const iframe = document.createElement('iframe');
        iframe.src = session.url;
        // payment permission is required for Apple Pay and Google Pay flows
        iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src'; payment '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 PaymentWidgetOptions shape from the SDK reference. Omit or pass {} to use defaults.
              iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin);
              break;
            case 'ready':
              console.log('Payment Widget is ready');
              break;
            case 'complete':
              console.log('Selection:', event.data.value);
              teardown();
              break;
            case 'cancel':
              console.log('Payment cancelled');
              teardown();
              break;
            case 'error':
              console.error('Payment 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 `uphdPaymentWidget` message handler; to reply, call `window.PaymentWidgetBridge.sendMessageToWidget(...)` via `evaluateJavaScript`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `PaymentViewController` below ties this together into a complete example, forwarding the Widget's messages to native callbacks.

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

    final class PaymentViewController: UIViewController, WKScriptMessageHandler {

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

      // The session dict from your backend (url, token, flow, ...), kept around so it can be
      // echoed back to the Widget in the "init" reply to its "load" message.
      private var session: [String: Any]?

      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: "uphdPaymentWidget")

        Task {
          do {
            let session = try await createPaymentWidgetSession() // calls your backend
            self.session = session

            guard let urlString = session["url"] as? String, let url = URL(string: urlString) else {
              onError?(["message": "Payment 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 the session
      // (url, token, flow, ...) plus an options object — same shape as the SDK's
      // PaymentWidgetOptions. Empty here; see Configuration reference to customize it.
      private func sendInitMessageToWidget() {
        guard var initMessage = session,
              let data = try? JSONSerialization.data(withJSONObject: {
                initMessage["type"] = "init"
                initMessage["options"] = [:]
                return initMessage
              }()),
              let json = String(data: data, encoding: .utf8) else {
          return
        }

        webView.evaluateJavaScript("window.PaymentWidgetBridge.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 == "uphdPaymentWidget",
              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("Payment Widget is ready")
          case "complete":       onComplete?(messageDict["value"])
          case "cancel":         onCancel?()
          case "error":          onError?(messageDict["error"])
          default: print("Unknown Payment Widget message type: \(type)")
        }
      }

      deinit {
        webView.configuration.userContentController.removeScriptMessageHandler(forName: "uphdPaymentWidget")
      }
    }
    ```

    <Warning>
      Some flows (e.g. PayPal's authorization step) open a popup via `window.open()`. WKWebView never opens real OS windows for that — without a `WKUIDelegate` implementing `webView(_:createWebViewWith:for:windowFeatures:)` to host the popup's own `WKWebView`, `window.open()` silently fails and the page waits indefinitely. Similarly, app-switch redirects to non-http(s) schemes (e.g. `venmo://...`) need a `WKNavigationDelegate` that hands them off to `UIApplication.shared.open(url)` instead of letting the load fail. Both are one-time additions to a production integration; omitted above for clarity.
    </Warning>
  </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 `uphdPaymentWidget` object registered below; to reply, call `replyProxy.postMessage(...)`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `PaymentActivity` below ties this together into a complete example, forwarding the Widget's messages to native callbacks.

    ```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 PaymentActivity : AppCompatActivity() {

      private lateinit var webView: WebView
      private var session: JSONObject? = null

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

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

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

            when (body.getString("type")) {
              "load" ->     sendInitMessageToWidget(replyProxy)
              "ready" ->    Log.d("Payment", "Payment Widget is ready")
              "complete" -> handlePaymentComplete(body.opt("value"))
              "cancel" ->   handlePaymentCancel()
              "error" ->    handlePaymentError(body.opt("error"))
              else -> Log.w("Payment", "Unknown Payment 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 = createPaymentWidgetSession() // calls your backend
            this@PaymentActivity.session = session
            webView.loadUrl(session.getString("url"))
          } catch (e: Exception) {
            handlePaymentError(e.message)
          }
        }
      }

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

        replyProxy.postMessage(initMessage.toString())
      }

      private fun handlePaymentComplete(value: Any?) { /* navigate to success */ }
      private fun handlePaymentCancel() { /* return user to previous screen */ }
      private fun handlePaymentError(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.PaymentWidgetBridge.sendMessageToWidget(...)` via `injectJavaScript`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `PaymentScreen` 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 PaymentScreen = ({ session }) => {
      const webViewRef = useRef(null);

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

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

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

        switch (message.type) {
          case 'load':     sendInitMessageToWidget(); break;
          case 'ready':     console.log('Payment Widget is ready'); break;
          case 'complete':  handlePaymentComplete(message.value); break;
          case 'cancel':    handlePaymentCancel(); break;
          case 'error':     handlePaymentError(message.error); break;
          default: console.log('Unknown Payment Widget message type:', message.type);
        }
      };

      const handlePaymentComplete = (value) => { /* navigate to success */ };
      const handlePaymentCancel = () => { /* return user to previous screen */ };
      const handlePaymentError = (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>

## 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.
* Selecting a payment method fires `complete` with the expected value for your flow — `event.detail.value` with the 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 SDK options. See the [SDK reference](./sdk-reference#options) for the full schema and all event types.

<Note>If you're integrating without the SDK, these map directly to the `options` object you send in your `init` reply.</Note>

| Option                | Purpose                                                                                                                                                                                                                   |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `authorize`           | Configure the `authorize` flow — set `mode` to control how authorization is handled. Applies only to sessions created with the `authorize` flow.                                                                          |
| `paymentMethods`      | Filter which payment methods (and assets) appear. Omit to show all.                                                                                                                                                       |
| `theme`               | Customize the Widget's appearance: force `light` or `dark` mode, set brand colors, typography, and per-component border radii. See [WidgetThemeOption](/widgets/payment/sdk-reference#widgetthemeoption) for all options. |
| `debug`               | Verbose console logging. Use during development.                                                                                                                                                                          |
| `maxAccountsPerAsset` | Cap accounts created per asset for crypto deposit flows (max effective: `100`).                                                                                                                                           |

## 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.

**Events not firing in native apps (with the 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 `uphdPaymentWidget` — the name the Widget checks for on both iOS and Android — and is registered **before** the WebView loads the session `url`.
* You're replying to `load` with `init` — the Widget won't render anything until it receives it.
* 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 never unmounts.** The SDK does not auto-unmount, and neither does the Widget itself when integrating without it. Call `widget.unmount()` (SDK) or tear down your iframe/WebView (no SDK) from each terminal message (`complete`, `cancel`, `error`).

**Apple Pay option not shown to the user**

Confirm the device supports Apple Pay through the [Payment Request API](https://developer.apple.com/documentation/applepayontheweb#Apple-Pay-availability-by-region-and-platform) in case of a deposit or for the [Disbursement Request API](https://applepaydemo.apple.com/disbursement-request-api) in case of a withdrawal and that the user has the required capabilities enabled. If you mounted the iframe yourself instead of using `mountIframe()`, confirm its `allow` attribute includes `payment 'src'` — without it, the Widget can't detect Apple Pay support and hides the option. Also confirm your domain is registered with Apple Pay (see [Apple Pay](/developer-guides/apm-transfers/overview#apple-pay)); an unverified domain makes Apple Pay silently unavailable.

**Apple Pay sheet doesn't open when the button is clicked (authorize flow)**

This is usually the device, not the integration: Apple Pay requires the device to be able to authenticate the user at the moment of the click. For example, on a MacBook with the lid closed, Touch ID is unreachable and the sheet won't open; the same applies if the device has no Touch ID/Face ID or passcode configured. Ask the user to check their device's authentication method is available, then try again.

**Apple Pay sheet opens, then is immediately dismissed**

This is typically a merchant validation failure — check that your domain, and any ancestor frame domains, are registered with Apple Pay under Uphold's merchant ID (see [Apple Pay](/developer-guides/apm-transfers/overview#apple-pay)).

## Next steps

* Review the complete [SDK Reference](./sdk-reference) for all available methods and events.
* Read our Developer Guides for step-by-step instructions on implementing specific payment methods with the Widget:

<CardGroup cols={3}>
  <Card title="Bank" icon="bank">
    - [Bank deposits](/developer-guides/bank-transfers/deposit/via-payment-widget)
    - [Bank withdrawals](/developer-guides/bank-transfers/withdrawal/via-payment-widget)
  </Card>

  <Card title="Card" icon="credit-card">
    * [Card deposits](/developer-guides/card-transfers/deposit/via-payment-widget)
    * [Card withdrawals](/developer-guides/card-transfers/withdrawal/via-payment-widget)
  </Card>

  <Card title="Crypto" icon="bitcoin">
    * [Crypto deposits](/developer-guides/crypto-transfers/deposit/via-payment-widget)
    * [Crypto withdrawals](/developer-guides/crypto-transfers/withdrawal/via-payment-widget)
  </Card>

  <Card title="APM" icon="wallet">
    * [APM deposits](/developer-guides/apm-transfers/deposit/via-payment-widget)
    * [APM withdrawals](/developer-guides/apm-transfers/withdrawal/via-payment-widget)
  </Card>
</CardGroup>
