Link Integration Guide
Connect your users to their biller accounts in 3 steps: create a link token, open Elements, and exchange the token for persistent access.
Create a Link Token
Your server creates a short-lived link token that identifies the user and the biller they want to connect. Send this token to your frontend to initialize Elements.
POST /v1/link-tokens
const response = await fetch('https://sandbox.api.billerapi.com/v1/link-tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $BILLERAPI_API_KEY',
},
body: JSON.stringify({
client_user_id: 'user_123',
biller_id: 'sb_utility',
consents: ['bills:read'],
}),
});
const { link_token } = await response.json();
// Send link_token to your frontendRequest body
{
"client_user_id": "string",
"biller_id": "string (optional)",
"consents": ["bills:read"]
}Response
{
"success": true,
"link_token": "string",
"expires_at": "string"
}Note
Open the hosted Elements flow
Pass the link token to Elements on your frontend. The SDK handles credential entry, account discovery, and consent — then returns a public token in the onSuccess callback.
Initialize and open Elements
import { BillerApiElements } from 'billerapi-js';
const elements = new BillerApiElements({
clientId: 'your_client_id',
environment: 'sandbox',
});
const handler = elements.connect({
linkToken: linkToken, // from your server
onSuccess: async (publicToken, metadata) => {
console.log('Account linked!', metadata.institution_name);
// Exchange the public token on your server
const res = await fetch('/api/exchange-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ publicToken }),
});
const { account_link_id, access_token } = await res.json();
// Store account_link_id for server reads. Keep access_token only if you
// use an endpoint that explicitly supports link-scoped authentication.
},
onExit: (error) => {
if (error) console.error('Error:', error.code);
},
});
handler.open();React component example
import React, { useState, useCallback, useRef } from 'react';
import { BillerApiElements } from 'billerapi-js';
const LinkButton = ({ userId, onSuccess }) => {
const [isLoading, setIsLoading] = useState(false);
const elementsRef = useRef(
new BillerApiElements({
clientId: process.env.NEXT_PUBLIC_BILLERAPI_CLIENT_ID,
environment: 'sandbox',
})
);
const handleLink = useCallback(async () => {
setIsLoading(true);
try {
// Get link token from your server
const response = await fetch('/api/create-link-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
const { linkToken } = await response.json();
// Open the hosted Elements flow
const handler = elementsRef.current.connect({
linkToken,
onSuccess: async (publicToken, metadata) => {
await fetch('/api/exchange-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ publicToken }),
});
onSuccess?.(metadata);
},
onExit: (error) => {
if (error) console.error(error.code);
setIsLoading(false);
},
});
handler.open();
} catch (error) {
console.error('Error creating link token:', error);
setIsLoading(false);
}
}, [userId, onSuccess]);
return (
<button
onClick={handleLink}
disabled={isLoading}
className="bg-primary text-primary-foreground px-6 py-3 rounded-lg"
>
{isLoading ? 'Connecting...' : 'Link Account'}
</button>
);
};
export default LinkButton;Sandbox test credentials
4242424242 with any username and password. This always succeeds. See the Getting Started guide for the full sandbox reference table.Exchange the Public Token
Send the public token from the onSuccess callback to your server, then exchange it for a long-lived access token. Store the access token securely — you'll use it for all subsequent API calls on this linked account.
POST /v1/link-tokens/:public_token/exchange
const response = await fetch(`https://sandbox.api.billerapi.com/v1/link-tokens/${encodeURIComponent(publicToken)}/exchange`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $BILLERAPI_API_KEY',
},
body: JSON.stringify({}),
});
const { access_token, biller_id, account_link_id } = await response.json();
// Store access_token securely. account_link_id is what you pass to
// GET /v1/bills?account_link_id=... and every other read endpoint.Request body
{
"public_token": "string"
}Response
{
"success": true,
"access_token": "string",
"biller_id": "string",
"account_link_id": "string",
"link_id": "string"
}Note
onSuccess callback.After the exchange: the async contract
onSuccess confirms the connection— the public token is exchangeable immediately. Bills arrive asynchronously: the first retrieval run starts right after the exchange and typically takes a minute or two. Do not poll for bills in the exchange response; listen for webhooks instead:
bill.created— fires once per bill as the first retrieval run (and every later scheduled run) lands bills.connection.ready— fires exactly once per link when its first retrieval run completes successfully. Use it to flip “Syncing your bills…” UX to a ready state without polling.
If the user backgrounds the flow (chooses “continue in background” on the hosted connect page instead of waiting for account discovery), your onSuccess callback never fires. BillerAPI finishes the flow server-side — all discovered accounts are auto-selected and the public token is exchanged internally — and completion arrives via the link_token.completed webhook instead, carrying the created link_id. Treat that link_id exactly like the one you would have received from POST /v1/link-tokens/:public_token/exchange; the same bill.created and connection.ready events follow.
Note
onSuccess + your own exchange call) and backgrounded (link_token.completed webhook, no exchange needed). Payload shapes and envelope examples are in the Webhooks reference.Embedding hosted Connect on your domain
The hosted Connect page ships with a strict frame-ancestors Content-Security-Policy: by default only BillerAPI first-party domains may frame it. To embed it in an iframe on your own site, register your site's origin first — otherwise the browser blocks the iframe before any script runs and the widget renders blank.
- Portal: Settings → Embed Domains — add your origin and save.
- API:
PUT /v1/iam/clients/:idwith{ "allowed_embed_origins": ["https://app.example.com"] }(a replacement list — include existing origins).
Origins must be exact: https:// only (plain http is allowed just for localhost), no wildcards, no paths, and the scheme and port must match the embedding page exactly. Changes take effect within about 30 seconds.
Note
linkToken so BillerAPI can look up your registered origins before the page is served. The Elements SDK does this for you; if the widget stays blank on your site, check the browser console — a frame-ancestors CSP error (and, after ~15s, an SDK hint) means the embedding origin is not registered yet.