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:
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.
Statement-only connections used to report some bank-side outages as 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.
GET and the webhooks report the same status. An MX connection’s status comes from MX’s own member status, the same one the connection.* webhooks report, and last_error says why. For example, MX PREVENTED (too many failed sign-ins) and DENIED read requires_action with last_error.code credentials_invalid; MX DISCONNECTED and CLOSED read disconnected; a healthy or still-syncing member reads active. Before 2026-09-23 the read endpoint reported any unhealthy MX connection as disconnected. Expect those MX connections to read their real status now.One MX pairing is deliberate: an MX account the bank locked reads requires_action with last_error.code account_locked_by_institution and is_user_actionable: false. The user has to act, but at the bank, not in the widget. (A locked Finicity account on an existing connection reads failed with the same code; the sandbox locked login does not simulate one.)

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
succeeded here means the widget URL was issued, not that the bank is linked. This result is a snapshot written once and never rewritten, so whatever id it holds is the id it will hold forever. For Finicity and MX that is a con_<uuid> placeholder. Do not wait on this endpoint for the id to change; it never does.
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.
  • 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.
Tell them apart by shape, not by where you got them. The initiate operation writes its result once and never rewrites it, so a placeholder there stays a placeholder however long you poll. (FDE is the exception: it can return the canonical id at initiate. Checking the shape handles both without special-casing the source.) When you hold a placeholder, the canonical id comes from the 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.
Ids encode their source everywhere: con_FINICITY_..., acc_MX_..., txn_FDE_.... You can read the source off any id at a glance.
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_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: the bank or the aggregator failed in a way the user cannot fix by re-entering credentials (bank site down, the bank blocking automated access, an aggregator error). A later refresh often recovers it on its own, and a recovery sends connection.active. A changed password at the bank is not failed: it reads requires_action with last_error.code credentials_invalid.
  • disconnected: the source stopped aggregating the connection (MX reports the member disconnected or closed), you deleted it, or, for Finicity and portal connections, LedgerSync’s own refresh of it failed. A connection you delete sends connection.disconnected. The last case sends no connection.* event of its own today, so re-read the connection when a refresh fails.
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 watch for recovery and re-read the connection and accounts. A repair aims to preserve the existing connection and account identities. For Finicity, the same institution login and provider account IDs reuse the stored rows; a changed login requires a successful repair match to retain those identities. Check the returned source and canonical account IDs before assuming an overlap pull will deduplicate against records you already hold.
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 When to use reauthorize.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 a hosted session

POST /v3/clients/{id}/connections starts another hosted session for the same Client. For Finicity, it opens Full Connect using the existing customer. Choosing the add-account option under an existing bank login can extend the stored connection; calling this endpoint does not force a new bank connection. It is also a fallback when a repair cannot recover access.
If the source returns a different underlying login that is not matched to the existing connection, a new connection can carry new account and transaction IDs for bank accounts you already hold. Prefer reauthorize for a repair and Full Connect for Finicity account addition. Re-list the client’s accounts after either flow to confirm their canonical identities.
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

The endpoint does not reject a request just because the connection is active. That is separate from whether the provider has a repair for the user to complete. On Finicity, the URL uses Connect Fix and can show “No action required” on a healthy connection. Use Full Connect to add Finicity accounts. Call POST /v3/clients/{client_id}/connections for the existing client, obtain the operation’s result.connection.action.widget_url, and choose the existing bank’s add-account option. The same institution login reuses the existing connection. A v3 Finicity refresh only updates stored accounts; MX and FDE refresh paths also import newly returned accounts. See Adding a new account for the complete flow and account-list comparison.
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 per-source table, the Finicity Full Connect 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. 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 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. 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.
The side each type sits on, for classification:
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.

Credit-card terms

A liability account (credit card, line of credit, loan or mortgage) can carry a liabilities object with the terms the bank publishes:
The object is absent unless we know something. It is never present on a checking, savings or investment account, even when the bank reports a figure there, and it is null on a liability account whose bank publishes no terms. Check for the object before reading fields inside it. Individual fields are independently nullable for the same reason: an absent field means the bank did not report it, never that the value is zero.
Identify cards by type: credit_card, not by whether liabilities is present. Loans, mortgages and lines of credit carry the object too, usually with just a minimum_payment and a payment_due_date. Before 2026-09-23 the object could also appear on checking, savings and investment accounts; it no longer does.
Coverage differs by source, and within a source by bank. Measured in September 2026 on credit cards whose connection was working, as the share of cards where each field is present: The bank matters more than the source. Through MX, Chase, Bank of America, Wells Fargo, U.S. Bank and Citi report the terms on nearly every card, while American Express reports the statement balance and, on very few cards, anything else. Through Finicity, Capital One reports no credit limit and no available credit. Terms are refreshed whenever the account’s balance is, including on LedgerSync’s scheduled daily refresh. statement_balance follows the same sign rule as current_balance: what the client owes is negative, so the two agree on the same account.
That sign is normalized by us, not reported. Banks disagree with each other about it, several of them reporting a card’s statement balance as a positive number while reporting the same debt as a negative account balance. We resolve it to “owed is negative” so you never have to know which convention a given bank used.One consequence is worth knowing: a card that was in credit at statement close, because the holder overpaid, is indistinguishable from one that owed the same amount and is reported as owed. credit_limit, available_credit and minimum_payment are always positive.
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 become investment, 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.
subtype is unknown when the source reported no account type at all. That is every FDE account, and roughly half of the accounts created from uploaded statements. Those accounts report type: other, so treat other as “not classified”, not as a category.
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.