Skip to main content
A Connection is one linked bank for one Client. It has a short, predictable lifecycle: you initiate it, the user finishes linking in a widget, and then data flows. This page walks the whole path, explains each status, and tells you what to do when a connection needs attention.
Connections live under a Client: Client → Connection → Account → Transaction / Statement. One Client can own many Connections (one per linked bank). See Connect a bank for the end-to-end setup, and Webhooks for the payloads referenced here.

The status flow

A connection has five statuses:

From initiate to active

You never poll the connection directly during setup. You poll the operation you get back from the initiate call.
1

Initiate the connection

POST /v3/clients/{id}/connections with the institution id returns 202 Accepted and an operation_id.
Initiate
202 Accepted
There is no source field. v3 routes to Finicity, MX, or FDE server-side from the institution catalog.
2

Poll the operation until it succeeds

GET /v3/operations/{operation_id} until status is succeeded. The widget URL is at result.connection.action.widget_url.
Operation succeeded
3

Hand off the widget

Open widget_url in the user’s browser (redirect, iframe, or webview). They pick their bank, sign in, pick accounts, and close it. You never see or transmit credentials. See Connect a bank for widget details.
4

Wait for connection.active

When the user finishes, the connection flips to active and you receive a connection.active webhook carrying the canonical connection id. Store that id.

The two id formats

This trips people up, so read it twice.
A connection has two id formats over its life. Only one works for reads.
  • Placeholdercon_<uuid> (e.g. con_9f1c2b7a-3e4d-4a11-8c2f-77e9b0d15a42). You get this while the connection is requires_action. It is valid only for the widget step.
  • Canonicalcon_<SOURCE>_<bankAccountId> (e.g. con_FINICITY_41294, con_MX_1224). It appears once the user finishes and connection.active fires. This is the only id that works on /accounts, /transactions, and /statements.
Always store the canonical id from the succeeded/active connection. Never persist the placeholder as your connection reference.
Ids encode their source everywhere: con_FINICITY_..., acc_MX_..., txn_FDE_.... You can read the source off any id at a glance.

Handling requires_action

requires_action means the ball is in the user’s court. They opened the widget but have not finished picking accounts and signing in. There is no auto-timeout to failed today. A connection stays requires_action until either:
  • the user re-opens the still-valid widget_url and completes it, or
  • you re-initiate the connection to get a fresh operation and widget URL.
So if a user wanders off mid-link, nothing breaks. Re-surface the same widget URL, or start over with a new POST /connections.

MFA and reauthorization

MFA is handled entirely inside the widget. When a real bank challenges the user for a one-time code or security question, that challenge is presented and answered in the LedgerSync-hosted page — the same widget_url you already opened. You route the user back to it and they finish there.
There is no programmatic MFA endpoint. You never receive, submit, or store bank credentials or MFA answers through the API. That is the point of the hosted model: raw secrets never touch your servers.
So handling requires_action for MFA is the same as handling it for the initial link: re-open the connection’s widget_url (or re-initiate to mint a fresh one) and wait for connection.active. The action object on a requires_action connection is a tagged union — read its kind to know what to render:
On a requires_action connection the action is always widget_url — first-time MFA, a step-up challenge, or a password-change reauthorization all surface there. The reauthorize kind is returned only when you explicitly call the reauthorize endpoint (below); mfa_challenge is not emitted yet. Branch on kind anyway so your integration keeps working when the other kinds ship.

Handling failed and disconnected

Both statuses mean the same thing to your UI: the user needs to re-link. You have two ways to do it, and they differ in one thing that matters — whether your stored ids survive.
  • failed — bad credentials, or the aggregator reported a failure. Common after a password change at the bank.
  • disconnected — the user revoked access on their side, or the link otherwise needs rebuilding.
POST /v3/connections/{connection_id}/reauthorize repairs the existing connection. It returns a reauthorize action with a LedgerSync-hosted reauth_url:
Redirect the user to reauth_url to re-enter credentials or re-consent at the institution, then wait for connection.active. When the repair stays on the same source (check the source field, and read the warning below), it repairs this very connection: its con_, acc_, and txn_ ids stay the same, so a delta re-sync over the overlap window dedupes cleanly against transactions you already have.
Ownership is enforced server-side: a connection_id that doesn’t belong to the authenticated customer returns 404. Available for FINICITY, MX, and FDE. Uploaded-statement (PDF) sources are read-only and have no connection to reauthorize.
Id stability is not unconditional. source names the source the returned URL actually leads to, and it is not always the source in the connection id you passed: at a few institutions a con_FINICITY_* connection reauthorizes through MX, the user links a new connection, and the accounts arrive under new con_ / acc_ / txn_ ids while the original connection keeps its old ones. Compare source against the source segment of the id you sent before assuming your ids survived. See Adding a new account.Whether an institution can be repaired in place at all also depends on the aggregator. If reauthorize can’t recover the connection, fall back to re-initiate.

Re-initiate — fresh connection, new ids

Re-linking with POST /v3/clients/{id}/connections runs the initiate → widget → active flow again for the same Client. Use it when you want (or need) a brand-new connection.
When the aggregator issues a new underlying login, a fresh connection mints new con_ / acc_ / txn_ ids for the same accounts — so a delta pull can return transactions you already hold under new ids. Prefer reauthorize when you want id stability; keep re-initiate as the fallback.
Prefer not to build a bank picker at all? Use the hosted connect sessionPOST /v3/clients/{id}/connect-session returns a LedgerSync-hosted url you email the user. Same lifecycle, none of the widget plumbing.

Reauthorizing an active connection

Reauthorize is not only a repair path. The endpoint does not check the connection’s status, so a healthy active connection is a valid target. The one common reason to use it that way is account discovery on Finicity. When a client opens a new account at a bank they already linked, a Finicity refresh will never return it: the account set is fixed at consent time, and only a hosted session offers the accounts the client did not share. Reauthorize mints that session against the existing connection. MX and FDE need nothing here, because their refresh already picks up new accounts.
Reauthorize does not always keep you on the same source. The response carries a source field naming the source the returned URL leads to, and on a few institutions a con_FINICITY_* connection reauthorizes through MX, which lands the accounts on a new connection with new ids. Branch on source rather than assuming id stability.
Do not loop and do not schedule reauthorize. Each call mints a session a human has to sit through, and a completed session ends in a real aggregation at the institution, which is not free. Call it once, on a real trigger, and wait for the client to open the link you already sent.

Adding a new account

The full per-source table, the Finicity reauthorize flow, and how to detect the new account once it lands.

Per-capability health

An active connection is not all-or-nothing. Each connection carries individual capabilities that can flip working or not-working on their own: transactions, balance, available_balance, statements, check_images. check_images is FDE-only; see Check images for how to read them. When one of these transitions, you get a connection.capability_changed webhook. To avoid flapping, LedgerSync applies hysteresis: 3 consecutive failed observations before a capability is marked failed, and 2 consecutive succeeded before it flips back. The event fires only on a real transition, not on every refresh, and includes previous_status, status, consecutive_observations, and observed_at. This lets you show precise UI, for example “transactions are syncing, but statements are temporarily unavailable,” without dropping the whole connection.

Capability payload details

See the full connection.capability_changed payload, statuses, and field-by-field breakdown in the Webhooks guide.

Data freshness and refresh

Once a connection is active, LedgerSync keeps its data fresh in the background. You do not poll for new data — you listen for refresh events:
  • account.refresh.completed — a refresh finished and new transactions/balances are available to read.
  • account.refresh.failed — a refresh attempt failed. If failures persist, the connection may move to failed or a capability may change.
Treat account.refresh.completed as your signal to re-read /accounts and /transactions for that Client and connection.
Read after a refresh
Reads are Client-scoped. Always pass client_id on every read, alongside the canonical connection_id.

Account types

Every account you read off an active connection carries a type. LedgerSync normalizes each aggregator’s native account type (Finicity and FDE report lowercase, MX reports UPPERCASE) down to one coarse, lowercase enum, so you branch on the same six values regardless of source:

Asset vs liability

For balance-sheet math, map type to a side. A positive current_balance means different things depending on the side, so decide sign handling from this table, not from the number alone:
Treat other as unknown, not as an asset. Don’t fold it into a net-worth total without inspecting the account. New or unclassified account kinds land here.

The enum is intentionally coarse

Banks expose far richer sub-types than these six. An investment household might show up as IRA, 401(k), or brokerage; a lending product as mortgage, auto loan, or line of credit. Those rich labels collapse into the coarse enum — IRA/401(k)/brokerage all become investment, mortgage/auto loan/line of credit all become loan. If you need the bank’s original label, keep the account’s name (e.g. “Home Mortgage”, “Roth IRA”); the type alone won’t recover it.
Per-account data availability is not derived from type. A savings account isn’t guaranteed to expose statements, and a loan isn’t guaranteed to expose transactions. Read each account’s realized_capabilities block to know what’s actually available. (Today the backend tracks capability status at the bank level, so accounts within one connection share the same snapshot — see Per-capability health.)

Test the whole lifecycle in sandbox

You can drive this entire flow without a real bank. Search ?q=FinBank, pick the row named exactly “FinBank”, and sign in with Banking Userid demo / Banking Password go. It flips to active immediately with no MFA and auto-populates accounts, transactions, and statements.

Connect a bank

The full initiate → widget → active walkthrough.

Webhooks

Every lifecycle event, its payload, and how to verify signatures.

Testing

Sandbox banks, MFA/OAuth variants, and credentials.

Errors

The error envelope and how to branch on code.