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:requires_action vs failed is the “can the user fix this?” split. Branch on it rather than
treating every unhealthy connection the same way:requires_action— the user can fix it. Wrong password, an expired one-time code, a bank-forced password change, an account locked after too many sign-in attempts. Prompting them is worth doing.failed— the user cannot fix it. The bank’s site is down, the bank is blocking automated access, or our extraction failed. Prompting them for credentials here is the wrong call: they will re-enter correct credentials and it will still fail.
requires_action, which sent
integrators (and end clients) chasing credentials during a bank outage. Those now correctly report
failed. If you branch on this field, expect a one-time shift of a small number of statement-only
connections from requires_action to failed.From initiate to active
You never poll the connection directly during setup. You read the operation you get back from the initiate call to collect the widget URL, then you wait for a webhook.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
Read the operation for the widget URL
GET /v3/operations/{operation_id}. The widget URL is at result.connection.action.widget_url. That is the only thing you need from this response.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.- Placeholder —
con_followed by a UUID (e.g.con_9f1c2b7a-3e4d-4a11-8c2f-77e9b0d15a42). This is what a Finicity or MX initiate returns. It is valid only for the widget step. - Canonical —
con_<SOURCE>_<bankAccountId>(e.g.con_FINICITY_41294,con_MX_1224). This is the only id that works on/accounts,/transactions, and/statements.
connection.active webhook payload (data.connection.id), or from GET /v3/clients/{client_id}/connections if you cannot run a webhook handler. Never persist a placeholder as your connection reference.
This is the single most common mistake in AI-generated integrations, because it fails at the next call rather than where the wrong id was stored. If an assistant wrote your code, see If something is not working.
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_urland completes it, or - you re-initiate the connection to get a fresh operation and widget URL.
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 samewidget_url you already opened. You route the user back to it and they finish there.
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.
Reauthorize in place — keeps your ids (recommended)
POST /v3/connections/{connection_id}/reauthorize repairs the existing connection. It returns a reauthorize action with a LedgerSync-hosted reauth_url:
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.Re-initiate — fresh connection, new ids
Re-linking withPOST /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.
Prefer not to build a bank picker at all? Use the hosted connect session —
POST /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 healthyactive 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.
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
Anactive 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. It carries a data.change object holding capability, previous_status, current_status, consecutive_observations, and last_error when the capability failed.
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 isactive, 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 tofailedor a capability may change.
account.refresh.completed as your signal to re-read /accounts and /transactions for that Client and connection.
Read after a refresh
Account types
Every account you read off anactive connection carries a type. It is never null, and it is always one of six values. LedgerSync folds each source’s own account label into this enum, so the same value means the same thing whichever aggregator the account came from and you can branch on it without knowing the source:
Asset vs liability
current_balance is signed from the account holder’s point of view, and that is a LedgerSync guarantee rather than a per-source quirk: positive is value the client holds, negative is value they owe. Summing current_balance across a client’s accounts gives net worth, with no per-source branching and no sign flipping of your own.
So a credit card or loan carrying a balance is negative, and a checking account in overdraft is negative too. The sign is meaningful in both directions: an overpaid card with a real credit balance comes back positive.
available_balance is not signed this way. On a credit card it is available credit, an amount the client can spend, so it is positive. MX is the only source that reports it.type sits on, for classification:
Credit-card terms
A credit card or line of credit can carry aliabilities object with the terms the bank publishes:
statement_balance follows the same sign rule as current_balance: what the client owes is negative, so the two agree on the same account.
payment_due_date is the bank’s own figure and only as current as the last refresh. A bank that has not yet published a new cycle is still reporting the previous one, so a date in the past means the cycle rolled over and we have not seen the new one, not that a payment is overdue. Read last_refreshed_at before acting on it.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 becomeinvestment, mortgage/auto loan/line of credit all become loan.
Nothing is lost when they do. The finer label survives on subtype, which is always present alongside type:
subtype is an open vocabulary, deliberately not an enum: new values appear as sources start reporting new products, and a new value is never a breaking change. type is the opposite, closed and stable. So branch on type; use subtype for display, or for finer routing you control and can update.
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.