billerapi-js
v1.0.1
Published
BillerAPI Elements — unified iframe SDK for Connect, Add Payment Method, and Pay flows
Maintainers
Readme
billerapi-js
The unified, Plaid-style SDK for embedding BillerAPI Elements — secure, iframe-hosted flows that keep credentials, card numbers, and CVV entirely out of your JavaScript context.
Three flows, one SDK:
| Flow | Factory | Hosted page | onSuccess first arg |
|---|---|---|---|
| Connect (account linking) | .connect() | /connect | publicToken |
| Add a payment method | .addPaymentMethod() | /elements/add-payment-method | paymentMethodId |
| Pay a bill | .pay() | /elements/pay | paymentAttemptId |
Successor to
@billerapi/connect-sdk, which is now deprecated — new integrations should use this package. TheBillerAPIandBillButleraliases are exported for back-compat, so existing@billerapi/connect-sdkconsumers keep working:new BillButler(...).create(opts)maps to.connect(opts).
Installation
Elements is distributed as a UMD <script> bundle from BillerAPI's S3 CDN — no
npm install required (npm/public-CDN publishing is deferred). Drop it in and use
the window.BillerApiElements global:
<script
src="https://bb-prod-cdn-s3-elements.s3.us-east-1.amazonaws.com/elements/1.0.0/index.umd.js"
integrity="sha384-<paste the hash printed by `bb cdn publish-elements` — also shown in the Elements SDK guide>"
crossorigin="anonymous"
></script>Always use the immutable versioned path (elements/1.0.0/…) with the matching
integrity="sha384-…" SRI hash — this is the only form safe to embed next to
card/PAN/CVV fields, because SRI lets the browser reject a swapped bundle. The
real hash is generated at publish time by scripts/cdn/cdn-publish.ts and
printed by bb cdn publish-elements; the Elements SDK
guide lists the current version
and hash.
For sandbox/testing only, the moving v1 alias
(elements/v1/index.umd.js) tracks the latest 1.x without SRI — never use it in
production.
Quick start
import { BillerApiElements } from 'billerapi-js';
const elements = new BillerApiElements({
clientId: 'your-client-id',
environment: 'sandbox', // or 'production'; auto-detected from hostname when omitted
});1. Connect (account linking)
Mint a link_token server-side (POST /v1/link/token/create), then:
elements
.connect({
linkToken: 'link-token-from-your-backend',
onSuccess: (publicToken, metadata) => {
if (metadata.completion_mode === 'update') {
// Update/repair completion (link token minted with `update`): the Link
// already exists, `publicToken` is an EMPTY string — do NOT exchange it.
// Just treat this as "the user finished re-authenticating".
return;
}
// First-time connect: exchange publicToken server-side for an access token.
console.log('linked', publicToken, metadata.institution_name);
},
onExit: (error) => {
if (error) console.error(error.code, error.message);
},
})
.open();2. Add a payment method
Mint a pay_token server-side (POST /v1/pay/token/create), then:
elements
.addPaymentMethod({
payToken: 'pay-token-from-your-backend',
onSuccess: (paymentMethodId, metadata) => {
console.log('saved card', paymentMethodId, metadata.last_4, metadata.card_brand);
},
})
.open();3. Pay a bill
elements
.pay({
payToken: 'pay-token-from-your-backend',
billId: 'bill_123', // optional — the pay_token may already be bill-scoped
onSuccess: (paymentAttemptId, metadata) => {
console.log('payment started', paymentAttemptId, metadata.bill_id);
},
})
.open();Theming
The developer-facing theme is camelCase; the SDK translates it to the snake_case wire shape exactly once before posting it to the hosted page.
elements.connect({
linkToken,
onSuccess,
theme: {
primaryColor: '#1D4ED8', // 6-digit hex
mode: 'system', // 'light' | 'dark' | 'system'
borderRadius: 'lg', // 'sm' | 'md' | 'lg'
clientLogoUrl: 'https://cdn.example/logo.png',
},
});
// Back-compat: a bare mode string is also accepted.
elements.connect({ linkToken, onSuccess, theme: 'dark' });API
new BillerApiElements(config)
interface ElementsConfig {
clientId: string; // required, /^[a-zA-Z0-9_-]+$/
environment?: 'sandbox' | 'production'; // auto-detected from hostname; SSR -> 'sandbox'
baseUrl?: string; // overrides per-flow path derivation base
apiUrl?: string; // overrides env API URL
connectUrl?: string; // explicit connect URL (connect flow only, back-compat)
}Methods: .connect(opts), .addPaymentMethod(opts), .pay(opts) (each returns
an ElementHandler), plus .getConfig() and .destroy().
ElementHandler
interface ElementHandler {
open(): Promise<void>; // idempotent; a missing required token routes to onExit
close(): void; // idempotent
isOpen(): boolean;
}Only one modal can be in flight: calling any factory closes the previous handler.
Options
Every flow accepts onSuccess (required), onExit, onLoad, onEvent, and
theme. connect requires linkToken; addPaymentMethod and pay require
payToken (pay also accepts an optional billId).
Connect events (onEvent)
The connect flow emits a stable, namespaced event vocabulary through
onEvent — the conversion-funnel backbone. Names are connect/<verb>; every
event carries snake_case metadata (it crosses the postMessage wire). Types ship
as ConnectEventName, ConnectViewName, ConnectEventMetadataByName, and the
ConnectEventHandler handler type. onEvent stays widened, so a plain
(eventName: string, metadata) => void handler still type-checks (ad-hoc events
such as biller_onboarding_requested continue to flow through unchanged).
Every event includes: link_session_id (the link token id, used as the v1
session key), timestamp (ISO-8601), and — once known — biller_id,
biller_name, view_name, and mode.
mode (ConnectFlowMode) is 'connect' for a first-time connect or 'update'
for a repair / re-authentication of an existing Link (its credentials went
stale, the session expired, MFA is required again, or consent lapsed). In update
mode the biller is already known, the consent step is skipped, and the account
step re-confirms the existing selection. connect/open with mode: 'update'
marks an update flow starting. Segment your repair funnel off mode — no new
event names are introduced.
| Event | When | Extra metadata |
|-------|------|----------------|
| connect/open | Page initialized after INIT | — |
| connect/view | Every pane/step transition | view_name |
| connect/search | Settled (debounced) biller search | query |
| connect/biller_selected | A biller was chosen | biller_id, biller_name |
| connect/credentials_submitted | Credentials submitted for login | — |
| connect/mfa_required | An MFA challenge is required | mfa_type (when known) |
| connect/otp_submitted | An OTP / MFA code was submitted | — |
| connect/input_required | Flow left a processing state, now needs input | view_name |
| connect/authenticated | Biller-side login verified (fires once, often well before completion) | — |
| connect/backgrounded | User chose to continue the connect in the background (followed by a clean EXIT, no error) | view_name (pane the choice was made from) |
| connect/error | An error surfaced | error_code, will_retry (when true, the backend keeps retrying a backgrounded transient failure — treat as pending, not terminal) |
| connect/exit | The user bailed | status (pane they left) |
| connect/handoff | Public token handed to the parent | — |
view_name / status values: biller_selection, consent, region,
credentials, mfa, account_selection, success, failure, loading.
connect/error carries a stable, machine-readable error_code (typed as
ConnectErrorCode, never localized UI copy) — key your analytics/error handling
off these values, not the user-facing message:
| error_code | Meaning |
|--------------|---------|
| credentials_rejected | The biller rejected the submitted credentials |
| mfa_invalid | The submitted OTP / MFA code was wrong |
| mfa_expired | The OTP / MFA code (or challenge) timed out or expired |
| mfa_locked_out | Too many MFA attempts; the account is locked |
| account_discovery_failed | Login succeeded but the account list couldn't be read |
| connection_lost | The realtime stream dropped and couldn't reconnect |
| link_expired | The link token is no longer valid |
| internal_error | A generic client/network or unclassified backend fault |
Deprecated (pre-taxonomy) events
The hosted page still emits a few ad-hoc events that predate this taxonomy,
kept for back-compat. Prefer the connect/* events above; these will be
removed in a future major:
STEP_CHANGE— superseded byconnect/view.CLOSE_CONFIRM_SHOWN/CLOSE_CONFIRM_DISMISSED— internal close-confirm dialog telemetry; noconnect/*replacement (useconnect/exit).
elements.connect({
linkToken,
onSuccess: (publicToken, metadata) => exchange(publicToken),
onEvent: (eventName, metadata) => {
if (eventName === 'connect/view') analytics.track('connect_view', metadata);
if (eventName === 'connect/error') analytics.track('connect_error', metadata);
},
});Async connect contract
onSuccess confirms the connection — the public token it hands you is
exchangeable immediately (POST /link/token/exchange). Bills arrive
asynchronously: the first retrieval run starts right after the exchange.
Don't poll for bills off the exchange response; listen for webhooks:
bill.created— fires once per bill as retrieval runs land bills.connection.ready— fires exactly once per link when its FIRST retrieval run completes successfully. Flip "Syncing your bills…" UX on this, not on a timer.
Backgrounded flows. If the user chooses "continue in background" on the
hosted connect page instead of waiting out account discovery, onSuccess
never fires. BillerAPI completes the flow server-side (all discovered
accounts auto-selected, public token exchanged internally — it is never
exposed on this path) and completion arrives via the link_token.completed
webhook instead, carrying the created link_id. Treat that link_id
exactly like one returned by your own exchange call; the same bill.created
and connection.ready events follow.
Handle both completion paths:
| Path | Completion signal | Your exchange call |
|------|-------------------|--------------------|
| Interactive | onSuccess(publicToken) | Required (POST /link/token/exchange) |
| Backgrounded | link_token.completed webhook | Not needed (done server-side) |
Payload shapes and full #1555 envelope examples live in the hosted
Webhooks reference (/docs/api/webhooks) and docs/webhooks/biller-api-events.md.
Security
- Iframe isolation — all sensitive UI (including card number / CVV entry) lives in the BillerAPI-hosted page, never in your context.
- Origin-locked postMessage — the SDK locks the expected origin at
open(), rejects any inbound message from another origin, and never posts to'*'. - Hardened protocol v1 — every message carries
protocol_version: 1. The SDK sends the canonicalBILLERAPI_*prefix and accepts the legacyBILLERAPI_*prefix inbound for back-compat. - PAN / CVV never cross postMessage and never reach any first-party backend other than the gateway tokenization endpoint.
Browser support
Chrome 60+, Firefox 55+, Safari 12+, Edge 79+.
License
MIT
