Unbound Connect JS Loader
Status: Draft Last Updated: 2026-06-15 Audience: customer
The JS loader lets a web app open the Unbound Connect flow in-page —
Unbound.connect({ token, onSuccess }), the Plaid Link interaction model —
without shipping any Unbound UI into your bundle. The loader is a tiny,
dependency-free script served from the LifeDB binary; it opens the hosted flow in
a popup and relays a small versioned event stream back to your page over
postMessage. Flow changes never require a loader update.
OAuth providers refuse to render inside iframes, so the flow runs in a popup
(a top-level window), not an embedded frame. When the popup is blocked (typically
mobile web), the loader falls back to a full-page redirect, completing through the
identical redirect_uri contract.
Embedding the loader
Vendor the loader with a single <script> tag. The ?v=1 pins the major version
(see Versioning).
<script src="https://<your-lifedb-host>/connect/v1/connect.js?v=1"></script>Then call Unbound.connect when the user clicks your “Connect your data” button.
The example below uses obviously synthetic values:
<button id="connect-btn">Connect your data</button><script> document.getElementById("connect-btn").addEventListener("click", async () => { // 1. Mint a connect token on YOUR backend (never in the browser) and hand it // to the page. The token is short-TTL and single-use. const token = await fetch("/api/unbound/connect-token", { method: "POST" }) .then((r) => r.json()) .then((d) => d.connect_token);
Unbound.connect({ token, onSuccess: ({ code, state }) => { // 2. POST the code to YOUR backend for exchange (see below). Never // exchange in the browser — the exchange needs your client secret. fetch("/api/unbound/exchange", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, state }), }); }, onExit: ({ reason }) => { // The user closed the popup or the flow ended without success. console.log("connect exited:", reason); // "closed" | "error" | flow reason }, onEvent: ({ name, payload }) => { // Optional telemetry: the full relayed event stream (see the table below). console.log("connect event:", name, payload); }, }); });</script>token is required; onSuccess, onExit, and onEvent are all optional (a
missing callback is a no-op).
Exchanging the code on your backend
onSuccess delivers { code, state } — exactly what redirect mode would deliver
to your redirect_uri for the same journey. The code is a one-time
authorization code; the loader never persists it and it never appears in a URL.
Exchange it on your backend using your client credentials:
POST https://<your-lifedb-host>/connect/token { "code": "<code>", "client_id": "<id>", "client_secret": "<secret>" }Verify that state matches the value you minted the session with before trusting
the result — this is your CSRF guard, identical to redirect mode.
Event vocabulary
The flow page emits a small, versioned event stream. Every event reaches
onEvent; the terminal ones also drive onSuccess / onExit. The vocabulary is
additive-only within v1 — an old loader relays an unknown future event through
onEvent untouched, so new events never break an existing embed.
| Event | When | Payload | Drives |
|---|---|---|---|
open | The flow page has loaded in the popup. | {} | onEvent |
source_connected | A source’s grant landed during the journey (e.g. the Mac sync grant). | { source } | onEvent |
success | The journey completed; the authorization code is ready. | { code, state } | onEvent + onSuccess |
exit | The flow ended without success (a flow-side exit reason). | { reason } | onEvent + onExit |
error | The journey hit a recoverable client-side failure. | { reason } | onEvent + onExit({error}) |
Notes:
successis terminal. After it fires, the popup closes andonSuccessreceives{ code, state }exactly once.- Closing the popup before a terminal event fires
onExit({ reason: "closed" }). There is no hung promise; the underlying session simply expires by its TTL. erroris surfaced toonExit({ reason: "error" }). The flow may still let the user retry in place —erroris the embedder’s signal, not a forced teardown.- Treat any event name you do not recognize as forward-compatible telemetry: log
it via
onEventand ignore it. Do not branch your completion logic on anything butsuccess.
Popup-blocked fallback
If the browser blocks the popup (common on mobile web), the loader navigates the
current page to the hosted flow as a full-page redirect instead. The journey
then completes through your registered redirect_uri — the same completion
contract as redirect mode — so your backend’s redirect handler must remain wired
even when you use the loader. No code path is lost; only the in-page feel is.
Security model
- Origin pinning, both directions. The loader accepts
postMessageevents only from the flow’s origin and only those tagged as Unbound Connect messages; everything else is ignored. The flow, in turn, posts events only to the origin of your registeredredirect_uri— never a wildcard, and never an origin supplied by an incoming message. The two pinned origins are the same one the relay posts the completion to. - No code persistence. The loader transmits only the connect token and the
event stream. The authorization code rides in the event payload to your
onSuccessand is never written to storage, a cookie, or a URL. - No secrets in the browser. Mint the connect token and exchange the code on your backend. The loader carries no client secret and embeds none.
These origin-pinning and no-persistence guarantees — along with the migration-free / cookie-free / code-never-in-URL invariants — are part of the loader’s contract and hold across flow updates.
Versioning
The loader is pinned by major version via the query string: connect.js?v=1. Its
contract is the event vocabulary only — within v1 the vocabulary only grows,
so a loader pinned to ?v=1 keeps working as the flow evolves. A breaking change
to the vocabulary or session semantics would ship as connect.js?v=2 under a
/connect/v2/ flow; v1 embeds are unaffected. The same loader bytes are served
regardless of the ?v value (it is a cache-buster), so pinning the major is what
matters.
Related Documents
- Connect overview — the hosted flow this loader opens, the delivery modes, and the versioning contract; see § JS loader
- Webhooks — the push channel for grant transitions that complete after the user has left the flow
- Authentication — how your backend authenticates to mint the connect token
- Authorization flow — the backend token-mint and code-exchange the loader hands off to
Code References
- connect-starter-kit — copyable BFF + example front-end implementing the token-mint / code-exchange / redirect_uri contract this guide describes.