# Connect in a React Native WebView

Embed BillerAPI Connect in a React Native app with `react-native-webview` — no native SDK. You mint a hosted link token on your server, open its `hosted_url` in a `<WebView>`, and detect completion by intercepting the redirect back to your registered `https` callback URL.

## How it works

Three moving parts: your server mints the token, the WebView renders the hosted flow, and a redirect back to your registered `https` `redirect_uri` tells your app the session finished.

1. **Server mints a hosted link token** — POST /v1/link-tokens with hosted:{} and a pre-registered https redirect_uri. The response carries a hosted_url — return ONLY that to the app.
2. **The app opens hosted_url in a `<WebView>`** — The hosted Connect page runs as a full-page navigation inside react-native-webview. The user authenticates their biller exactly as they would in a mobile browser.
3. **A redirect to your https callback signals completion** — On finish the hosted page navigates to your redirect_uri with ?link_status=completed (or =exited). You intercept that navigation in onShouldStartLoadWithRequest, return false so the WebView never loads it, and close the WebView.

> **Note — Why Hosted Connect, not the Elements SDK**
> The [Elements SDK](/docs/guides/elements-sdk) renders Connect inside a browser iframe and talks to your page over `iframe ↔ parent postMessage`. A React Native WebView is **not** a browser DOM — there is no parent window for the iframe to message. Hosted Connect is instead a plain full-page navigation the WebView renders natively, and completion is signalled by a real page redirect, which the WebView surfaces to you as a navigation event. That makes Hosted Connect the correct integration for RN — the Elements SDK is for web pages.

## Prerequisites

- The `react-native-webview` package — `npx expo install react-native-webview` (or `npm install react-native-webview` for bare React Native).
- A server endpoint that mints link tokens with your `API key`. The secret must **never** ship in app code.
- An **https** callback URL you control (e.g. `https://yourapp.com/billerapi/callback`), pre-registered as a `redirect_uri` on your client (Step 1). The WebView intercepts navigations to this URL — it never has to resolve to a real page, but an [iOS Universal Link / Android App Link](https://reactnative.dev/docs/linking) at that path is a nice belt-and-suspenders fallback if the WebView is dismissed early.

## Step 1 — Register your redirect URI

Add your app's **https** callback URL to the client's `redirect_uris` allowlist **once**, then reuse it on every mint. This is a one-time setup step, not part of the mint. Update the allowlist from [your client's Settings](/settings) in the dashboard, or with an authenticated `PUT /v1/iam/clients/:id` call.

**curl**

```bash
# Uses your PORTAL SESSION (a signed-in dashboard user), not the
# API key pair used for the mint below.
# The client update endpoint is guarded by your dashboard login.
curl -X PUT https://api.billerapi.com/v1/iam/clients/$BILLERAPI_CLIENT_ID \
  -H "Authorization: Bearer <portal-session-jwt>" \
  -H "Content-Type: application/json" \
  -d '{ "redirect_uris": ["https://yourapp.com/billerapi/callback"] }'
```

> **Warning — Pre-registration is required — this is the #1 integration gotcha**
> The `redirect_uri` you pass to the mint **must** already appear in this allowlist, and it **must be https** (`http` is only accepted for `localhost`). Custom deep-link schemes like `myapp://` are **not** accepted — the allowlist and the hosted page both reject any non-http(s) scheme, so register an https URL. Minting with an unregistered redirect returns `400 REDIRECT_URI_NOT_REGISTERED`.

## Step 2 — Mint a hosted token on your server

When the user taps "Connect", your backend mints a hosted link token with `hosted: {}` and the `redirect_uri` you registered in Step 1. Pass `consents: ["bills:read"]` and a stable `client_user_id` for the end user.

**Node**

```javascript
// server only — your client secret never leaves the backend
app.post('/mint-connect-url', async (req, res) => {
  const r = await fetch('https://api.billerapi.com/v1/link-tokens', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer $BILLERAPI_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      client_user_id: req.user.id,
      consents: ['bills:read'],
      hosted: {},                                         // hosted flow — accepts {} or true
      redirect_uri: 'https://yourapp.com/billerapi/callback', // must be pre-registered (https)
    }),
  });
  const data = await r.json();
  // return ONLY the hosted_url to the app — never link_token
  res.json({ hosted_url: data.hosted_url });
});
```

**curl**

```bash
curl -X POST https://api.billerapi.com/v1/link-tokens \
  -H "Authorization: Bearer $BILLERAPI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "client_user_id": "user_42",
    "consents": ["bills:read"],
    "hosted": {},
    "redirect_uri": "https://yourapp.com/billerapi/callback"
  }'
```

**Response (201)**

```json
{
  "success": true,
  "link_token": "lt_01HX5...",
  "link_token_id": "lt_01HX5...",
  "expires_at": "2026-07-06T18:30:00.000Z",
  "hosted_url": "https://www.billerapi.com/connect?linkToken=lt_01HX5...&hosted=true"
}
```

`hosted_url` is `${CONNECT_HOSTED_BASE_URL}?linkToken=<token>&hosted=true` (production base `https://www.billerapi.com/connect`). If you omit `redirect_uri`, the hosted page finishes on a self-contained "All set" screen with no redirect — for a WebView integration you almost always want the redirect, so register and pass one.

> **Warning — Return only the hosted_url**
> Send the mobile app `hosted_url` and nothing else. The raw `link_token` and any biller credentials must stay server-side — the app only needs a URL to load.

## Step 3 — Open hosted_url in a `<WebView>`

Fetch the `hosted_url` from your server, render it in a `<WebView>`, and inspect each navigation with `onShouldStartLoadWithRequest`. When the WebView tries to navigate to your callback URL, return `false` so it never actually loads that page — read `link_status` from the query string and dismiss instead.

**React Native (TypeScript)**

```react
import React, { useEffect, useState } from 'react';
import {
  WebView,
  type WebViewNavigation,
  type ShouldStartLoadRequest,
} from 'react-native-webview';

// The exact https redirect_uri you registered + minted with.
const REDIRECT_URI = 'https://yourapp.com/billerapi/callback';

type ConnectResult = 'completed' | 'exited';

// Parse without the URL / URLSearchParams API — React Native's built-in URL
// does NOT implement .searchParams (it throws), so read the query with a regex.
function readLinkStatus(url: string): ConnectResult {
  const match = /[?&]link_status=([^&]+)/.exec(url);
  return match?.[1] === 'completed' ? 'completed' : 'exited';
}

export function ConnectWebView({
  onComplete,
  onError,
}: {
  onComplete: (status: ConnectResult) => void;
  onError: (message: string) => void;
}) {
  const [hostedUrl, setHostedUrl] = useState<string | null>(null);

  // 1. Ask YOUR server to mint the token and hand back only the hosted_url.
  useEffect(() => {
    fetch('https://your-backend.example.com/mint-connect-url', { method: 'POST' })
      .then((r) => r.json())
      .then((d: { hosted_url: string }) => setHostedUrl(d.hosted_url))
      .catch(() => onError('Could not start Connect'));
  }, [onError]);

  if (!hostedUrl) return null;

  // 2. Intercept the redirect back to our callback => the session finished.
  //    Returning false stops the WebView from loading the callback page.
  const shouldLoad = (req: ShouldStartLoadRequest): boolean => {
    if (req.url.startsWith(REDIRECT_URI)) {
      onComplete(readLinkStatus(req.url));
      return false; // handled — do not load
    }
    return true;
  };

  return (
    <WebView
      source={{ uri: hostedUrl }}
      onShouldStartLoadWithRequest={shouldLoad}
      onError={() => onError('WebView failed to load')}
      originWhitelist={['https://*']}
      javaScriptEnabled
      domStorageEnabled
    />
  );
}
```

> **Note — Intercept with onShouldStartLoadWithRequest, not the URL API**
> Because your callback is an `https` URL, it already matches `originWhitelist={['https://*']}` and stays inside the WebView — so returning `false` from `onShouldStartLoadWithRequest` is what stops the callback page from ever loading. Parse the query with a regex, not `new URL(...).searchParams`: React Native's built-in `URL` does not implement `searchParams` and throws (unless you add `react-native-url-polyfill/auto`).

## Step 4 — Confirm completion (server-side is authoritative)

The redirect interception is a great UX signal, but a WebView can be dismissed or lose connectivity before the redirect fires. Treat the `link.session_finished` webhook as the authoritative record, and use a status poll as a race-free fallback.

### link.session_finished webhook (authoritative)

Fires with `status: "completed"` once the server-side exchange is done (`link_id` is populated), or `status: "exited"` with an `exit_reason` of `user_closed` or `error`. It does **not** fire on token expiry — that surfaces as `link.disconnected` with `reason: "expired"`.

**link.session_finished**

```json
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "link.session_finished",
  "api_version": "2026-04-29",
  "created": 1714387260,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "id": "lt_01HX5...",
      "object": "link_token",
      "link_token_id": "lt_01HX5...",
      "status": "completed",
      "link_id": "link_01HX5...",
      "biller_id": "sb_utility",
      "user_id": "user_42"
    }
  },
  "request": { "id": null, "idempotency_key": null }
}
```

> **Warning — No public token on the payload**
> The webhook payload never carries the public token or any credential — the server-side exchange has already run by the time `link.session_finished` fires, and `link_id` is the durable handle you store against the user. Wire up the [Webhooks guide](/docs/guides/webhooks) to receive it.

### Status poll (fallback for the redirect/webhook race)

If you need an immediate in-app confirmation, poll the token status endpoint. The opaque `lt_` token in the path **is** the credential, so this call takes **no** auth header and is safe to make from the app. Back off between attempts (1s, 2s, 4s, capped at 10s).

**Status poll**

```bash
# no auth header — the lt_ token in the path is the credential
curl https://api.billerapi.com/v1/link-tokens/lt_01HX5.../status

# => { "status": "completed", "completed_at": "...", "redirect_uri": "https://yourapp.com/billerapi/callback" }
# public_token is stripped from this response
```

## Step 5 — Handle exit and errors

When the user dismisses your WebView before finishing, tell BillerAPI so the session is closed cleanly. `POST /v1/link-tokens/{lt_token}/exit` is best-effort (it emits the exited webhook) and, like the status poll, needs **no** auth header.

**Exit**

```bash
# call this when the user dismisses the WebView
curl -X POST https://api.billerapi.com/v1/link-tokens/lt_01HX5.../exit \
  -H "Content-Type: application/json" \
  -d '{ "exit_reason": "user_closed" }'
# => 201
```

> **Note — Explicit exit vs. expiry**
> An explicit `/exit` (or the user choosing "close" in the hosted page) surfaces as a `link.session_finished` event with `status: "exited"`. A token the user simply abandons until it expires surfaces instead as `link.disconnected` with `reason: "expired"` — handle both if you track session outcomes.

## Security checklist

| Do | Why |
| --- | --- |
| Mint tokens server-side only | Your `API key` must never ship in app code or be recoverable from the bundle. |
| Return only `hosted_url` to the app | The raw `link_token` and any biller credentials stay on your backend. |
| Pre-register every redirect URI | The allowlist blocks open-redirect abuse; unregistered URIs are rejected at mint. |
| Confirm outcomes with the webhook | Redirect interception is a UX hint; `link.session_finished` is the authoritative record before you fulfill anything. |
| Scope `originWhitelist` tightly | Allow only `https://*` so the WebView cannot be steered to a custom scheme or off-web target. Your https callback matches this and is intercepted in `onShouldStartLoadWithRequest`. |

## Sandbox testing

> **Warning — The simulated sandbox host does not support the hosted flow**
> `sandbox.api.billerapi.com` is a fully-simulated tier: it has **no** `POST /v1/link-tokens` hosted mint, its token response carries no `hosted_url`, and its status poll never returns a `redirect_uri`. So the redirect interception this guide is built on **cannot** be exercised on the sandbox host — a hosted token minted there would only ever reach the self-contained "All set" screen. To end-to-end test the WebView redirect, mint a real hosted token on the production gateway (`https://api.billerapi.com/v1/link-tokens`) with a `bak_test_` sandbox-tier `API key`, and drive it with [magic account numbers](/docs/sandbox/magic-numbers).

Mint a testable hosted token with a `bak_test_` key against the **production gateway** (not the simulated sandbox host). The response carries a real `hosted_url` and the redirect fires exactly as in production; magic account numbers drive each outcome.

**Sandbox-tier mint**

```bash
# Production gateway with a bak_test_ (sandbox-tier) key — the
# simulated sandbox.api.billerapi.com host has no hosted mint.
curl -X POST https://api.billerapi.com/v1/link-tokens \
  -H "Authorization: Bearer bak_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "client_user_id": "user_42",
    "consents": ["bills:read"],
    "hosted": {},
    "redirect_uri": "https://yourapp.com/billerapi/callback"
  }'
```

See [Magic Account Numbers](/docs/sandbox/magic-numbers) for the account number that triggers each sandbox scenario (success, invalid credentials, MFA, and more).

## Related

- [Elements SDK](/docs/guides/elements-sdk) — The browser (web) Connect embed
- [Link Demo Playground](/docs/guides/connect-demo) — Run the hosted flow against sandbox
- [Webhooks Guide](/docs/guides/webhooks) — Receive link.session_finished
- [Link Sessions API](/docs/api/link-sessions) — Link token endpoints reference
- [Magic Account Numbers](/docs/sandbox/magic-numbers) — Every sandbox scenario trigger
