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.
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.
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.
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.
Why Hosted Connect, not the Elements SDK
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-webviewpackage —npx expo install react-native-webview(ornpm install react-native-webviewfor 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 aredirect_urion 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 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 in the dashboard, or with an authenticated PUT /iam/clients/:id call.
# 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/iam/clients/$BILLERAPI_CLIENT_ID \
-H "Authorization: Bearer <portal-session-jwt>" \
-H "Content-Type: application/json" \
-d '{ "redirect_uris": ["https://yourapp.com/billerapi/callback"] }'Pre-registration is required — this is the #1 integration gotcha
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"] and a stable client_user_id for the end user.
// 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'],
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 });
});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.
Return only the hosted_url
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.
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
/>
);
}Intercept with onShouldStartLoadWithRequest, not the URL API
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".
{
"id": "evt_01HX...",
"object": "event",
"type": "link.session_finished",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": {
"object": {
"id": "lt_01HX5...",
"object": "link_token",
"link_token_id": "lt_01HX5...",
"status": "completed",
"link_id": "link_01HX5...",
"biller_id": "test_electric_company",
"user_id": "user_42"
}
},
"request": { "id": null, "idempotency_key": null }
}No public token on the payload
link.session_finished fires, and link_id is the durable handle you store against the user. Wire up the Webhooks guide 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).
# no auth header — the lt_ token in the path is the credential
curl https://api.billerapi.com/v1/link/token/lt_01HX5.../status
# => { "status": "completed", "completed_at": "...", "redirect_uri": "https://yourapp.com/billerapi/callback" }
# public_token is stripped from this responseStep 5 — Handle exit and errors
When the user dismisses your WebView before finishing, tell BillerAPI so the session is closed cleanly. POST /v1/link/token/{lt_token}/exit is best-effort (it emits the exited webhook) and, like the status poll, needs no auth header.
# call this when the user dismisses the WebView
curl -X POST https://api.billerapi.com/v1/link/token/lt_01HX5.../exit \
-H "Content-Type: application/json" \
-d '{ "exit_reason": "user_closed" }'
# => 201Explicit exit vs. expiry
/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
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_ key or your sandbox-tier API key / API key, and drive it with magic account 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.
# 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"],
"hosted": {},
"redirect_uri": "https://yourapp.com/billerapi/callback"
}'See Magic Account Numbers for the account number that triggers each sandbox scenario (success, invalid credentials, MFA, and more).