# Banks that need the LedgerSync desktop app Source: https://docs.ledgersyncappv2.com/ai/desktop-app Set up the LedgerSync desktop app once, for the few banks that need it. Ask the AI: *"Help me set up the LedgerSync desktop app."* It gives you the download link. Or download it here:
Download for Windows Download for Mac
Install it on a computer at your firm.
Open the app. Open **Settings**. Set **Connect with** to **AI chat**. The app shows a code of 7 letters and numbers. Type the code in the chat. The AI asks you to confirm. Say yes. It says **The desktop app is linked.** **Keep that computer on and the app open.** The AI can refresh these banks only while the app is open. To check, ask: *"Is the LedgerSync desktop app connected?"* **Linking a new computer replaces the old one.** Only one computer is linked at a time. Something went wrong with the code or the app? See [Troubleshooting](/ai/troubleshooting).
# For developers Source: https://docs.ledgersyncappv2.com/ai/developers The LedgerSync MCP for developers and admins: sandbox, access, tools, limits and error codes.
Add `https://api-sandbox.ledgersyncappv2.com/mcp` as a second connector. Test with **FinBank**, **MX Bank** or **Ledgersync Bank** and the test sign-ins in [Testing in sandbox](/guides/testing). Desktop app tools are live only. Firms that use the LedgerSync app (the firm owner, with the app login) and approved developer accounts. A firm new to LedgerSync signs up in the LedgerSync app first. Claude Team and Enterprise Owners add it under **Organization settings** first.
* Firms that use the LedgerSync app: the firm owner signs in with the LedgerSync app login (email or username, password, and the two-step code if the account has one). * Firms new to LedgerSync sign up in the LedgerSync app first ([ledgersyncappv2.com/sign-up](https://ledgersyncappv2.com/sign-up)), then connect like any other firm. Until our team has checked a new firm, `ledgersync_create_connect_link` answers `firm_under_review` and `ledgersync_get_account_status` adds the same sentence; every other tool works. * Approved developer accounts: type the developer portal email on the LedgerSync sign-in page. It sends you to the developer portal sign-in. The account must be approved for that environment; live also needs the payment step. An application still waiting for its first approval cannot connect yet. * Only the firm owner can connect for now. A teammate gets **For now, only the firm owner can use LedgerSync in their own Claude or ChatGPT.** * ChatGPT needs a paid plan (Plus or higher) and the web version on chatgpt.com. If **Create MCP App** is missing, turn on **Developer mode** (**Settings**, then **Security and login**). On ChatGPT Business, Enterprise or Edu, only the workspace admin can add it for everyone. * On Claude Team and Enterprise, an Owner first adds it under **Organization settings > Connectors** (**Add**, then **Custom**, then **Web**). Members then open **Connectors**, find LedgerSync and click **Connect**. The Claude Free plan allows one custom connector. Once added, it also works in Claude Desktop, the Claude mobile apps, and Claude Code signed in with the same account. * AI apps other than Claude and ChatGPT are not supported yet (they are blocked). Ask support if you need one. ## Tools 17 | Tool | What it does | Changes data | | - | - | - | | `ledgersync_get_account_status` | Is the connection ready; sandbox or live; refreshes left | No | | `ledgersync_find_clients` | Finds clients by name or email | No | | `ledgersync_create_client` | Adds a client, checking for duplicates first | Yes | | `ledgersync_create_connect_link` | Secure page where the account holder connects a bank | Yes | | `ledgersync_search_institutions` | Is a bank supported; does it need the desktop app | No | | `ledgersync_list_connections` | A client's bank connections and last refresh | No | | `ledgersync_get_connection` | Checks one bank connection | No | | `ledgersync_list_connections_needing_attention` | Broken connections across all clients | No | | `ledgersync_list_accounts` | A client's accounts with balances | No | | `ledgersync_list_transactions` | An account's transactions (last 30 days by default) | No | | `ledgersync_list_statements` | An account's statements | No | | `ledgersync_get_statement_download_link` | One-time link to a statement PDF | No | | `ledgersync_refresh_connection` | Asks the bank for the latest data | Yes | | `ledgersync_get_operation_status` | Has a refresh finished | No | | `ledgersync_get_fix_link` | Secure page to fix a broken connection | Yes | | `ledgersync_get_desktop_app_status` | Is the desktop app linked and connected (live only) | No | | `ledgersync_pair_desktop_app` | Links the desktop app with its 7-character code (live only) | Yes | * `ledgersync_get_account_status` also says whether the connection is full or read-only. Refreshes left is your firm's shared daily budget, not the per-app cap. * `ledgersync_create_client` checks for an existing client with the same name or email first. * `ledgersync_create_connect_link` creates a secure page where the account holder picks their bank and signs in. `ledgersync_get_fix_link` creates one where they sign in to their bank again to fix a broken connection. * `ledgersync_search_institutions` also says whether a bank gives transactions and statements. `ledgersync_list_connections` also gives each connection's status. * `ledgersync_list_connections_needing_attention` lists the connections that are not working, across all your clients, the ones that need the account holder first. * `ledgersync_get_desktop_app_status` checks the app live and returns the Windows and Mac download links and the setup steps. * `ledgersync_pair_desktop_app` replaces any computer linked before, so the AI asks you to confirm first. * A bank or connection that needs the desktop app for refreshes carries `requires_desktop_app: true` in the search, connection and refresh-status results; the field is left out otherwise. A connection that is not working on such a bank also gets a `next_step` that says so. * Bank sign-ins only happen on LedgerSync pages. Connect links expire after 72 hours. * Statement download links expire after 10 minutes and work once; if a download fails, ask for a new link. * The connector cannot see or change API keys, webhooks or account settings. * Firms that sign in with the LedgerSync app login: changing the LedgerSync password, or turning off two-step sign-in, turns off every AI app connected with that login within a minute. * Developer accounts: open [portal.ledgersyncappv2.com](https://portal.ledgersyncappv2.com) on a computer, click **Connected apps**, then **Disconnect** next to each app. It cuts the app off at once; adding it again signs in fresh. The page lists only the apps of the environment picked in the portal's **Sandbox** / **Live** switch, so pick **Live** to see the live connector's apps. Anyone with the account's LedgerSync login can add it again. To turn it off for good, email support: support turns off the account's access for that environment, which stops every connected app and, on live, the account's API keys too. * Linking the desktop app needs the code the app shows, and the AI asks you to confirm first. The code is never logged or repeated back. * The desktop app is linked to the account owner's LedgerSync login, the one the connector acts as. Linking a computer replaces any computer linked before: only one computer is connected at a time. Every link is recorded and the LedgerSync team is alerted at once. * LedgerSync refreshes every working connection about once a day. A refresh you ask for takes a few minutes; see [Keeping data fresh](/guides/data-freshness). * A connection refreshed in the last 30 minutes is skipped unless you ask for a new pull anyway. * Each AI app can start 50 refreshes a day on live (20 on sandbox). They also count against your firm's daily budget of 500 on live (100 on sandbox), shared with API keys, which is the number the AI shows as refreshes left. * Per connected AI app: 50 new clients a day, 30 connect links an hour, and 120 tool calls a minute on sandbox (60 on live). * Long lists of connections, accounts, transactions and statements come back in pages. * A refresh stopped by the desktop app (`desktop_app_required` or `desktop_app_route_choice`) never reached the bank. It counts against neither daily refresh limit, and the 30-minute skip does not block trying again. * Linking the desktop app: 10 tries an hour and 20 a day per LedgerSync account (UTC hours and days), whether the code works or not. A code that is not 7 letters or digits is refused without counting. Every error has a `code`, the same as the API's (see [Errors](/guides/errors)). Access errors (except `firm_under_review`, whose message is the whole sentence), desktop app errors and the per-minute limit also carry a `next_step` sentence the AI reads out. So does a connection or refresh stopped by the desktop app, in its result. The 15 codes you are most likely to see are listed below. The connector can also answer `invalid_request`, `internal_error`, `idempotency_conflict` or `upstream_unavailable` (LedgerSync could not check the firm just now; try again in a minute). | Code | What it means | What to do | | - | - | - | | `access_not_active` | This sign-in has no working LedgerSync account yet: it is not set up, the sign-up is not finished, it is being checked, we need an answer, it was not approved, it closed, or it was turned off. For a firm that signs in with its app login: the signed-in user is not the firm owner, the login or account is locked, LedgerSync is not active for the firm, or connecting AI assistants is turned off for the firm. | Follow the step the AI reads out. Firms: [connect again](/ai/troubleshooting#connect-again) and sign in with the LedgerSync app login. Developers apply at [portal.ledgersyncappv2.com](https://portal.ledgersyncappv2.com). | | `access_request_not_approved` | This sign-in has a LedgerSync account, but not for this environment yet: its application is still being reviewed, needs more information, or has not finished the payment step. Or its access for this environment was turned off. | Follow the step the AI reads out: wait for the review, answer the question in the portal, or finish the payment step. If access was turned off, email support. | | `billing_not_configured` | Live access is approved, but no card is on file (the payment step was not completed). | Complete the payment step in the portal. | | `connector_app_pending_approval` | This AI app is not supported yet. Only Claude and ChatGPT are. | Use Claude or ChatGPT, or email support. | | `connector_access_revoked` | This AI app was disconnected from your LedgerSync account, or the firm owner changed the LedgerSync password or turned off two-step sign-in after it connected. | [Connect again](/ai/troubleshooting#connect-again) and sign in. | | `firm_under_review` | A new firm is still being reviewed by our team. It can add clients; bank connections open after the review. | Add clients now and connect banks after the review. | | `insufficient_scope` | This connection is read-only, or lacks permission for what you asked. | Ask for something the connection may do, or email support. | | `rate_limit_exceeded` | A limit was reached. | Try again when the AI says you can, or tomorrow for a daily limit. | | `not_found` | No client, connection, account or statement matches, or it belongs to another account. | Ask the AI to find the client first, then try again. | | `desktop_app_required` | This bank needs the LedgerSync desktop app for refreshes, and the app is not connected right now. The bank was not called. | Open the desktop app on the computer it is set up on, then refresh again. The account holder has nothing to do. | | `desktop_app_route_choice` | The refresh is waiting for someone to choose whether LedgerSync reaches the bank through the desktop app. The bank was not called. | The AI gives you a fix link for the account holder. They make the choice on that LedgerSync page. Then refresh again. | | `pairing_code_invalid` | The desktop app code did not work: it is wrong, expired or already used. | Get a new code in the desktop app (**Settings**, **Connect with: AI chat**) and tell it to the AI. | | `desktop_app_session_closed` | The desktop app that showed the code is not open anymore. | Open the desktop app, get a new code and tell it to the AI. | | `desktop_service_unavailable` | LedgerSync could not reach the desktop app service. Nothing you did is wrong. | Try again in a few minutes. If it keeps failing, email support. | | `pairing_rate_limited` | Too many tries to link the desktop app. The AI says how many minutes to wait. | Wait that long, then try again with a new code. |
# Set up in ChatGPT Source: https://docs.ledgersyncappv2.com/ai/setup-chatgpt Add LedgerSync to ChatGPT, sign in, and ask your first question. Only your workspace admin can add LedgerSync for your workspace. Adding it needs a LedgerSync sign-in, and only your firm's LedgerSync owner can sign in. Are your admin and your LedgerSync owner different people? [Book a call with our team](https://ledgersync.com/book-a-demo) and we'll set it up with you. Nobody creates a new LedgerSync account. Otherwise, send this page to your admin: the address is in step 3. They add it from **Workspace settings** and then click **Publish**. Until then it is a draft that only they can use. Everyone in your workspace then sees LedgerSync. For now, only your firm's LedgerSync owner connects it. Once it is published, your firm's LedgerSync owner opens [chatgpt.com/settings/plugins-settings](https://chatgpt.com/settings/plugins-settings) (**Settings**, then **Plugins**), clicks **LedgerSync**, clicks **Connect**, and does steps 4 and 5 below, and the test. Open [chatgpt.com/settings/security](https://chatgpt.com/settings/security) (it opens in a new tab). That is **Settings**, then **Security and login**. Turn on **Developer mode**. ChatGPT may show a risk warning. Turn it on anyway: LedgerSync needs it. Then come back to this tab for step 2. Don't see **Developer mode** there? Skip this step and go on to step 2. Open ChatGPT Plugins ChatGPT opens in a new tab. Keep it open, and come back to this tab for step 3. In ChatGPT, click **Add** at the top right, then **Create MCP App**. LedgerSync is already under **Settings**, then **Plugins**? Don't create it again: [connect again](/ai/troubleshooting#connect-again) instead. Only under **Plugins**, then **Personal**? Click it, then **Install plugin**. Don't see **Create MCP App**? Turn on **Developer mode** (step 1). On ChatGPT Business, Enterprise or Edu, ask your workspace admin. Still not there? [Book a call with our team](https://ledgersync.com/book-a-demo) and we'll set it up with you. 1. In **Name**, type **LedgerSync**. 2. Click **Copy** below. Under **Connection**, click the long box that says `https://example.com/mcp` and press **Ctrl+V** (Windows) or **Command+V** (Mac). 3. Leave everything else as it is. 4. Tick **I understand and want to continue**. 5. Click **Create**. ChatGPT shows this warning for every app you add yourself, because LedgerSync is not in ChatGPT's list yet. It's safe to tick. ChatGPT shows **Connect LedgerSync**. Click **Continue to LedgerSync**. The LedgerSync sign-in page opens. Type the email or username you use in the LedgerSync app. Click **Next**. Type your password and click **Sign in**. Asked for a **Two Factor Authentication** code? Type the code from your Google Authenticator app and click **Verify**. **The page says Create your LedgerSync account?** No LedgerSync login uses that email. Click **Change** and type your LedgerSync username. Don't create a second account. Your firm has no LedgerSync account yet? Sign up at [ledgersyncappv2.com/sign-up](https://ledgersyncappv2.com/sign-up) first, then come back here. You see your firm's name. Click **Allow**. Then ChatGPT asks to access your account. Click **Allow access**. You only see this the first time. ChatGPT opens its Plugins page again. Click **Copy**. In ChatGPT, start a new chat (**New chat**, or **Home** at the top left). Type **@**, then **Ledg**, and pick **LedgerSync**. Press **Ctrl+V** (Command+V on a Mac), then press **Enter**. * ChatGPT says your LedgerSync account is ready: you're done. * LedgerSync is not in the list after **@**? Open [Plugins](https://chatgpt.com/plugins), click **Personal**, then **LedgerSync**, then **Install plugin**. Not there either? Do steps 2 to 5 again. On Business, Enterprise or Edu, ask your workspace admin. * Anything else: see [Troubleshooting](/ai/troubleshooting). Your clients from the LedgerSync app are already here. Acme is an example: use your client's name. ### Add a client and connect their bank Ask: "Add my client Acme." Ask: "Send Acme a link to connect their bank." Open the link. Pick your client's bank and sign in with their bank login. This happens on a LedgerSync page, never in the chat. Don't have their bank login? Send the link to your client by email or text, and they do it. The link works for 3 days. When the bank is connected, ask: "Show Acme's bank balances." ### More things to ask "Does LedgerSync work with First National Bank?" "Find my client Acme." "Show Acme's bank balances." "Show last month's transactions for Acme's checking account." "Get Acme's September bank statement." "Which of my clients' banks are not working?" "Send Acme a link to connect their bank." Before the AI changes something, it asks you first. Allow it if it looks right. A bank needs the LedgerSync desktop app? The AI tells you when a refresh needs it. See [Banks that need the desktop app](/ai/desktop-app). # Set up in Claude Source: https://docs.ledgersyncappv2.com/ai/setup-claude Add LedgerSync to Claude, sign in, and ask your first question. Your firm's Claude **Owner** adds LedgerSync to your firm's Claude once. Then your firm's LedgerSync owner connects it. Skip steps 1 and 2 below. Not the Claude Owner? Send them this page. **The Claude Owner, once:** * Open [claude.ai](https://claude.ai) and check that the bottom left shows your firm's account. If it doesn't, click your name or initials there and pick your firm. Then come back to this tab. * Click the button below. Claude opens in a new tab with **LedgerSync** and the address already filled in. A red note about an external link is fine. Change nothing, and keep **Use Claude's published identity** picked if you see it. Click **Continue** each time you see it, then click **Add**. Don't see **Add**? Scroll down inside Claude's box. Then come back to this tab. Add LedgerSync for your firm **The button did not work?** Click **Copy** below first. Then open [claude.ai/admin-settings/connectors](https://claude.ai/admin-settings/connectors) and click **Add**, then **Custom**, then **Web**. Type **LedgerSync** in the name box. Click the address box and press **Ctrl+V** (**Command+V** on a Mac). Then click **Continue** and **Add** as above. * Everyone in your firm's Claude now sees LedgerSync. Tell your team: for now only your firm's LedgerSync owner connects it, and nobody creates a new LedgerSync account. **Then your firm's LedgerSync owner (it may be the same person):** * Check that the bottom left of Claude shows your firm's account (click your name or initials to switch). * Open [claude.ai/customize/connectors/yours](https://claude.ai/customize/connectors/yours). Click **LedgerSync** (it may say **Custom**), then **Connect**. Not in that list? Type **LedgerSync** in **Search connectors**. * Then do [Step 3](#sign-in), Step 4 and Step 5 below, and the test. Add LedgerSync to Claude Claude opens in a new tab, with **LedgerSync** and the address already filled in. It also shows a red note: **This connector was suggested by an external link**. That is expected. Come back to this tab for step 2. LedgerSync is already in your Claude connectors and says **Connected**? Go to step 5. Click **Copy**. Open [claude.ai/customize/connectors](https://claude.ai/customize/connectors). Click **Add**, then **Add custom connector**. Type **LedgerSync** in the name box. Click the address box and press **Ctrl+V** (Windows) or **Command+V** (Mac). Then do step 2. Click **Continue** at the bottom right of the box. The next box has technical words. Don't change anything: scroll down inside the box and click **Add**. Then click **Connect**. The LedgerSync sign-in page opens. **A connector with this URL already exists in your organization**: LedgerSync is already added. Open [claude.ai/customize/connectors/yours](https://claude.ai/customize/connectors/yours), click **LedgerSync**, then **Connect**. On the Claude Free plan you can add only one connector. Remove the other one first. Type the email or username you use in the LedgerSync app. Click **Next**. Type your password and click **Sign in**. Asked for a **Two Factor Authentication** code? Type the code from your Google Authenticator app and click **Verify**. **The page says Create your LedgerSync account?** No LedgerSync login uses that email. Click **Change** and type your LedgerSync username. Don't create a second account. Your firm has no LedgerSync account yet? Sign up at [ledgersyncappv2.com/sign-up](https://ledgersyncappv2.com/sign-up) first, then come back here. You see your firm's name. Click **Allow**. Then Claude asks to access your account. Click **Allow access**. You only see this the first time. Claude opens again and says **Connected to LedgerSync**. Claude now shows the LedgerSync page. Next to **Read-only tools**, click **Needs approval**, then click **Always allow**. Before Claude changes something, it still asks you first. Allow it if it looks right. Came back later? Open [claude.ai/customize/connectors/yours](https://claude.ai/customize/connectors/yours) and click **LedgerSync**. Click **Copy**. In Claude, start a new chat, click the message box, press **Ctrl+V** (Command+V on a Mac), then press **Enter**. * Claude says your LedgerSync account is ready: you're done. * Anything else: see [Troubleshooting](/ai/troubleshooting). Your clients from the LedgerSync app are already here. Acme is an example: use your client's name. ### Add a client and connect their bank Ask: "Add my client Acme." Ask: "Send Acme a link to connect their bank." Open the link. Pick your client's bank and sign in with their bank login. This happens on a LedgerSync page, never in the chat. Don't have their bank login? Send the link to your client by email or text, and they do it. The link works for 3 days. When the bank is connected, ask: "Show Acme's bank balances." ### More things to ask "Does LedgerSync work with First National Bank?" "Find my client Acme." "Show Acme's bank balances." "Show last month's transactions for Acme's checking account." "Get Acme's September bank statement." "Which of my clients' banks are not working?" "Send Acme a link to connect their bank." Before the AI changes something, it asks you first. Allow it if it looks right. A bank needs the LedgerSync desktop app? The AI tells you when a refresh needs it. See [Banks that need the desktop app](/ai/desktop-app). # Use LedgerSync in Claude or ChatGPT Source: https://docs.ledgersyncappv2.com/ai/start-here Ask Claude or ChatGPT about your clients' bank accounts, in plain words. **Sign in with your normal LedgerSync login.** Only your firm's LedgerSync owner can connect it, for now. Someone else at your firm set it up? Ask them. Please don't create a second account. Your firm has no LedgerSync account yet? Sign up at [ledgersyncappv2.com/sign-up](https://ledgersyncappv2.com/sign-up) first, then come back here. ## Which AI do you use?
Works on any Claude plan. On Claude Team or Enterprise, your firm's Claude Owner adds LedgerSync first. Needs ChatGPT Plus or higher, on chatgpt.com in a web browser. On ChatGPT Business, Enterprise or Edu, your workspace admin adds LedgerSync first.
**Good to know.** Claude calls this a **connector**. ChatGPT calls it an **app** or **plugin**. They are the same thing. **Your clients' bank passwords are never typed in the chat.** When a bank needs a password, the AI gives you a link to a LedgerSync page. Developer building on the LedgerSync API? Go to [For developers](/ai/developers).
# Troubleshooting Source: https://docs.ledgersyncappv2.com/ai/troubleshooting What the AI's LedgerSync messages mean, and what to do. ## Most common problems | The AI says | Do this | | - | - | | **We're reviewing your new firm. You can add clients now. Bank connections open after the review.** | Nothing to fix. Add your clients now: ask "Add my client" and the client's name. Bank connections open after our review. | | **LedgerSync is not set up for this sign-in yet.** Or: **This LedgerSync connection is not active.** | [Connect again](#connect-again). Sign in with the username or email you use in the LedgerSync app. Don't create a second account. Still not working? Email . | | **Your LedgerSync password was changed, so this connection was turned off.** | [Connect again](#connect-again). Sign in again, with your new password if you changed it. | | **For now, only the firm owner can use LedgerSync in their own Claude or ChatGPT.** | Access for teammates is coming. For now, ask your firm's LedgerSync owner. | | It doesn't seem to know LedgerSync | Start a new chat and ask again. In Claude, click **+** in the message box, then **Connectors**, and turn **LedgerSync** on. In ChatGPT, type **@** and pick **LedgerSync**. Not in the list? Open [Plugins](https://chatgpt.com/plugins), click **Personal**, then **LedgerSync**, then **Install plugin**. | **Claude:** open [Customize, then Connectors, then Yours](https://claude.ai/customize/connectors/yours) and click **LedgerSync**. Click **Disconnect** and confirm, if you see it. Then click **Connect**. **ChatGPT:** open [Settings, then Plugins](https://chatgpt.com/settings/plugins-settings) and click **LedgerSync**. Click **...** next to your account, then **Reconnect**. The LedgerSync sign-in page opens. Sign in, then click **Allow**. ## More problems | The page says | Do this | | - | - | | **Use your work email to create a LedgerSync account.** Or: **Use an email without a plus sign (+) to create a LedgerSync account.** | No LedgerSync login uses that email. In the same box, type the email or username you use in the LedgerSync app and click **Next**. Don't create a second account. Your firm has no LedgerSync account yet? Sign up at [ledgersyncappv2.com/sign-up](https://ledgersyncappv2.com/sign-up) first. | | **We can't find that username. Try your email.** | Type your email instead. | | **More than one account uses this email. Enter your username.** | Type your LedgerSync username. | | **Wrong password. Try again.** | Type your password again, or click **Forgot password?**. | | **Too many wrong passwords. Reset your password to continue.** | Click **Reset your password**. | | **Too many tries. Wait 10 minutes, then try again.** Or: **Too many tries. Wait 10 minutes and try again.** | Wait 10 minutes. Then try again. | | **This email belongs to a closed LedgerSync account.** | Email . | | **This sign-in is for accounting firms.** | This page is for accounting firms. Your accountant connects LedgerSync. | | **Create your LedgerSync account** (No LedgerSync account uses this email yet.) | Don't click **Create account**. Click **Change** and type the username you use in the LedgerSync app. Your firm has no LedgerSync account yet? Sign up at [ledgersyncappv2.com/sign-up](https://ledgersyncappv2.com/sign-up) first. | | **Finish creating your account** | Your firm already uses LedgerSync? Don't click **Finish**. Click **Change** and type the username you use in the LedgerSync app. Need help? Email . | | **This connect link has expired.** Or: **This connect link was already used.** | Go back to Claude or ChatGPT and [connect again](#connect-again). | | **Your password was changed after you signed in.** | Click **Sign out**. Then sign in again with your new password. | | **Sign out, then sign in again with your two-step code.** | Click **Sign out**. Then sign in again and type your two-step code. | | **Check your email and try again.** | Check the email you typed. Then try again. | | **We could not finish connecting. Wait a minute, then click Allow again.** | Wait a minute. Then click **Allow** again. | | **Something went wrong. Try again.** Or: **Something went wrong on our side.** | Try again. Still there? Go back to Claude or ChatGPT and [connect again](#connect-again). | | **This account signs in with its email.** | Click **Sign out**. Go back to Claude or ChatGPT and [connect again](#connect-again). | | **Your LedgerSync account setup is not finished.** Or: **Your LedgerSync account needs a quick check.** Or: **This email is already connected to LedgerSync for another firm.** Or: **We could not connect this account.** Or: **This LedgerSync account is closed.** Or: **Connecting an AI assistant is turned off for this account.** | Email . | | The AI or the page says | Do this | | - | - | | **Your LedgerSync login is locked.** | Click **Forgot password?** on the LedgerSync sign-in page and set a new password. Then [connect again](#connect-again). If the reset says your account is locked, email . | | **Your LedgerSync account is locked because a subscription payment did not go through.** Or: **Your account is locked because a subscription payment didn't go through.** | Email and we will help you update your card. Then try again. | | **LedgerSync is not active for your firm.** | Email and we will help. | | **Connecting an AI assistant is turned off for your firm.** | Email . | | **This app was disconnected from the LedgerSync account.** | [Connect again](#connect-again). | Using a LedgerSync developer account? See [For developers](/ai/developers). | The AI says | Do this | | - | - | | **Too many requests for this API key. Slow down and try again.** Or: wait some seconds before trying again | Wait the time the AI says, then ask again. You have nothing to fix, and you do not need an API key.
It gives no wait time? Your firm's daily limit for refreshes is used up. Try again tomorrow. | | This connection has used a tool too many times this hour or this day, its limit | Try again next hour or tomorrow, as the AI says. Your firm also uses the LedgerSync app? You can do it there now. | | **LedgerSync could not check your account just now.** | Try again in a minute. | | **LedgerSync could not complete this request.** | Try again in a minute. If it keeps failing, email support. | | **This AI app is not supported yet.** | LedgerSync works in Claude and ChatGPT. To use another app, email support. |
| The AI says | Do this | | - | - | | It cannot find the client, or something was **not found** | Ask "Find my client ..." first, then ask again. | | **This bank needs the LedgerSync desktop app, and it is not connected right now.** | Open the desktop app on the computer where you set it up. Keep that computer on. Then ask the AI to try again. | | **This refresh is waiting for someone to choose whether LedgerSync reaches the bank through the LedgerSync desktop app.** | The AI gives you a fix link. Send it to the account holder. They make the choice on that LedgerSync page. Then ask the AI to refresh again. | | **This connection is read-only or lacks permission for this action.** | Ask for something else, or email support. | | The AI says | Do this | | - | - | | **No desktop app is linked to this account yet.** | Do [Banks that need the desktop app](/ai/desktop-app). | | **The desktop app is linked but not connected right now.** | Open the desktop app on the computer where you set it up. Keep that computer on. Then ask the AI to try again. | | **That code did not work.** | In the desktop app, open **Settings** and set **Connect with** to **AI chat** to get a new code. Tell the AI the new code. | | **The desktop app that showed this code is not open anymore.** | Open the desktop app, get a new code, and tell the AI the new code. | | **Too many tries to link the desktop app.** | Wait the minutes the AI says. Then get a new code and try again. | | **LedgerSync could not reach the desktop app service.** Or: **The desktop app could not be checked right now.** | Wait a few minutes and try again. Still not working? Email support. |
## Remove or turn off LedgerSync
**Claude:** [Customize, then Connectors, then Yours](https://claude.ai/customize/connectors/yours), then **LedgerSync**, then the **⋮** menu, then **Remove**. On Claude Team or Enterprise, click **Disconnect** instead. **ChatGPT:** [Settings, then Plugins](https://chatgpt.com/settings/plugins-settings), then **LedgerSync**, then **Uninstall**. To remove it completely, also open [Plugins](https://chatgpt.com/plugins), click **Personal**, then **LedgerSync**, then the **...** menu, then **Delete plugin**. Change your LedgerSync password. Every AI you connected with your LedgerSync app login stops working within a minute. To use LedgerSync in your AI again, [connect again](#connect-again) and sign in with your new password. To stop it for good, email and we will turn it off.
# Get an account Source: https://docs.ledgersyncappv2.com/api-reference/accounts/get-an-account /openapi.yaml get /accounts/{account_id} Look up a single Account by id. Returns name, type, current balance, mask (at most the last 4 digits of the account number, on every source), and the Connection it came from. # List accounts Source: https://docs.ledgersyncappv2.com/api-reference/accounts/list-accounts /openapi.yaml get /accounts List every Account across all of your Clients. Filter to one Client, one Connection, or one source if you only want a subset. Balances on each Account reflect the most recent refresh — check `last_refreshed_at` to see how fresh that is. # Change an API key's scopes Source: https://docs.ledgersyncappv2.com/api-reference/apikeys/change-an-api-keys-scopes /openapi.yaml patch /api-keys/{api_key_id} Replace the scopes on an existing key, keeping the same secret — so a scope change doesn't force you to redeploy a credential. Mostly useful when we add a new scope: keys issued before it existed don't carry it, and calls that need it fail with `insufficient_scope` even though nothing about your key is wrong. PATCH it instead of rotating. `scopes` is the **complete** set, not a delta — send every scope the key should end up with. Unknown scopes are rejected, and the change applies to your next request. Revoked keys can't be re-scoped; create a new key instead. A key can only be re-scoped by a credential in its own environment — a sandbox key can't widen a live key. # Create an API key Source: https://docs.ledgersyncappv2.com/api-reference/apikeys/create-an-api-key /openapi.yaml post /api-keys Mint a new API key for one environment. The plaintext `secret` is returned exactly once in this response — store it somewhere safe (a secrets manager, an env var on your server) right away. We only keep a hash on our side, so we can't recover it for you later. # List your API keys Source: https://docs.ledgersyncappv2.com/api-reference/apikeys/list-your-api-keys /openapi.yaml get /api-keys All keys belonging to your customer. The plaintext secret isn't returned — keys are identified by their prefix and the `id`. Filter by `environment` (`sandbox` or `live`) when you're managing them separately. # Revoke an API key Source: https://docs.ledgersyncappv2.com/api-reference/apikeys/revoke-an-api-key /openapi.yaml delete /api-keys/{api_key_id} Immediately stop accepting requests signed by this key. There's no undo — if you revoked the wrong one, just create a new key. Any in-flight requests already past auth keep running. # Download the check image Source: https://docs.ledgersyncappv2.com/api-reference/checks/download-the-check-image /openapi.yaml get /accounts/{account_id}/checks/{check_id}/download Streams the check image bytes. This is the same URL exposed in `Check.download_url` — call it directly with your API key. The `Content-Type` reflects the image's actual format, which is typically `image/jpeg`. Don't infer it from the id. # Get a check Source: https://docs.ledgersyncappv2.com/api-reference/checks/get-a-check /openapi.yaml get /accounts/{account_id}/checks/{check_id} Returns one check's metadata, including a `download_url` for its image. # List an account's check images Source: https://docs.ledgersyncappv2.com/api-reference/checks/list-an-accounts-check-images /openapi.yaml get /accounts/{account_id}/checks Images of the paper checks written against this account, captured alongside FDE statement extraction. **FDE only.** Check images are exclusive to the FDE source — the `check_images` capability reports `unsupported` on Finicity and MX, and such accounts list empty here rather than erroring. Read `capabilities.check_images` on [`GET /institutions`](#operation/listInstitutions) to find institutions that supply them. **Fronts only.** LedgerSync captures the front of each check. There is no back image and no `side` field — one check is one image. Checks are nested under their account because the account is the access boundary. Follow `download_url` to fetch the image bytes. # Create a client Source: https://docs.ledgersyncappv2.com/api-reference/clients/create-a-client /openapi.yaml post /clients Create a Client (your end-user) before linking any bank accounts. The only field we need is something to identify them — use `external_id` to join back to a user record in your system, plus optional `email` and `name` for display. Set `phone` to store a contact number for the client. To also text the client a login invitation on creation, send `sms_invitation: true` together with a non-blank `phone` (a request with `sms_invitation: true` and no phone is rejected with `400`). Note: if the SMS cannot be delivered (e.g. an unsubscribed or invalid number), the client is not created and the call fails — retry without `sms_invitation` to create the client regardless. # Create a hosted pick-and-connect link for a client Source: https://docs.ledgersyncappv2.com/api-reference/clients/create-a-hosted-pick-and-connect-link-for-a-client /openapi.yaml post /clients/{client_id}/connect-session Returns a LedgerSync-hosted URL you email to an end-user (a member). They open it, search for their own bank, pick it, and connect it — no `institution_id` needed up front. This is the v3 replacement for the old "add/lite" widget for integrators who don't want to build their own bank picker. Call this with your normal API key. Optionally pass a `redirect_url` (which must be on your environment's allowlist) to send the member back to your app after they finish connecting. # Delete a client and everything attached Source: https://docs.ledgersyncappv2.com/api-reference/clients/delete-a-client-and-everything-attached /openapi.yaml delete /clients/{client_id} Destructive. Removes the Client, every Connection they own, and all derived accounts / transactions / statements. This also revokes any active aggregator-side authorizations. There's no undo and no soft-delete — make sure your user intends to disconnect before calling this. A clean end-of-relationship offboarding usually looks like: revoke connection-by-connection first, then delete the Client when nothing's left. # Get a client Source: https://docs.ledgersyncappv2.com/api-reference/clients/get-a-client /openapi.yaml get /clients/{client_id} Fetch a single Client by id. Returns everything you sent at creation plus the LedgerSync-assigned `id` and timestamps. # List clients Source: https://docs.ledgersyncappv2.com/api-reference/clients/list-clients /openapi.yaml get /clients Page through the Clients you've created. Use `cursor` for pagination (it's opaque — just hand back what we sent), and filter by `email` or by your own `external_id` to look up a specific user. Sending both applies both. # Mint a short-lived end-user token Source: https://docs.ledgersyncappv2.com/api-reference/clients/mint-a-short-lived-end-user-token /openapi.yaml post /clients/{client_id}/end-user-token A few endpoints act on behalf of a specific end-user — for example, submitting an MFA challenge during connection setup. Those use a different credential than your normal API key: a short-lived (5-minute) bearer token bound to one Client. Call this with your normal API key to mint one when you need it. Keep it server-side and short-scoped. # Revoke a hosted connect link Source: https://docs.ledgersyncappv2.com/api-reference/clients/revoke-a-hosted-connect-link /openapi.yaml delete /clients/{client_id}/connect-session/{sid} Invalidate a previously created connect link so it can no longer be opened. Pass the `sid` the create call returned. Scoped to your account and to the client the link was created for: an unknown, already-revoked, or foreign session returns `404`, as does a session that belongs to one of your other clients. Use the same `client_id` you created the link with. # Update a client Source: https://docs.ledgersyncappv2.com/api-reference/clients/update-a-client /openapi.yaml patch /clients/{client_id} Partial update — send only the fields you want to change. Omitted fields stay as they are. Useful for keeping `name` or `metadata` in sync with your own user record. # Disconnect and remove a connection Source: https://docs.ledgersyncappv2.com/api-reference/connections/disconnect-and-remove-a-connection /openapi.yaml delete /connections/{connection_id} Revokes our access to the user's bank for this connection and removes the Connection record. Accounts and transactions are kept for your historical reference unless you delete the whole Client. Once the delete succeeds, a `connection.disconnected` webhook is sent for the connection. It carries `client_id` whenever LedgerSync could read the connection's owner just before the delete. Repeating the call afterwards returns 404 and sends nothing. # Get a connection Source: https://docs.ledgersyncappv2.com/api-reference/connections/get-a-connection /openapi.yaml get /connections/{connection_id} Look up a Connection by id. The `status` field tells you where it is in its lifecycle and `last_error` carries any recent failure details. # LedgerSync-hosted MX Connect wrapper page (browser-facing) Source: https://docs.ledgersyncappv2.com/api-reference/connections/ledgersync-hosted-mx-connect-wrapper-page-browser-facing /openapi.yaml get /connections/mx-wrapper Browser-facing HTML page — **not** an integrator API call. When a connection routes to MX, the `widget_url` action returned by `POST /clients/{client_id}/connections` points here (MX Connect has no native post-Connect redirect of its own). Open it in the user's browser or iframe it, exactly like Finicity Connect. The page verifies the short-lived wrapper token, embeds the MX Connect widget, and when the user finishes linking either redirects its own window to your registered `redirect_url` with an `ls_token` query parameter — the **same** contract as the Finicity flow, verified at `POST /connections/redirect-token/verify` — or shows a "Connected" end state when no `redirect_url` was supplied at initiate time. No API key is required: the HS256 wrapper token in the query string is the capability. Tampered, expired, or missing tokens render a static error page (`text/html`, not the JSON error envelope). # LedgerSync-hosted pick-and-connect page (browser-facing) Source: https://docs.ledgersyncappv2.com/api-reference/connections/ledgersync-hosted-pick-and-connect-page-browser-facing /openapi.yaml get /connect-session Browser-facing HTML page — **not** an integrator API call. A member lands here when they open the `url` returned by `POST /clients/{client_id}/connect-session`. They search for their own bank, pick it, and are handed off to the per-source connect widget (Finicity Connect / the MX wrapper page / the FDE connect page) to enter credentials. LedgerSync never collects bank credentials on this page. No API key is required: the opaque session token in the query string is the capability. Tampered, expired, or revoked tokens render a static error page (`text/html`, not the JSON error envelope). The page drives its own same-origin search/connect/poll calls under `/connect-session/*` — those are internal browser plumbing, not part of the integrator API. # List a client's connections Source: https://docs.ledgersyncappv2.com/api-reference/connections/list-a-clients-connections /openapi.yaml get /clients/{client_id}/connections List every Connection a Client has linked. Filter by `source` (Finicity / MX / FDE) or by `status` to find the connections you care about — for example, all `requires_action` connections so you can nudge the user back into the widget. # Open a bank connection session Source: https://docs.ledgersyncappv2.com/api-reference/connections/open-a-bank-connection-session /openapi.yaml post /clients/{client_id}/connections Kick off a connection between a Client and a bank. Send only `institution_id` (a v3 catalog id from `GET /v3/institutions`). LedgerSync's router picks the underlying source (Finicity vs MX) per its routing rules — integrators don't pick a source. The response: - **Finicity / MX** — we hand back a widget URL. Open it in the user's browser (or your mobile webview) and they'll finish linking. For MX the URL points at a LedgerSync-hosted wrapper page (`GET /connections/mx-wrapper`) that embeds the MX widget and gives you the same `redirect_url` + `ls_token` completion hop as Finicity (verify it at `POST /connections/redirect-token/verify`). - **FDE** — we return a `widget_url` to a LedgerSync-hosted connect page where the end user enters their bank credentials and answers any security questions in-session. Raw credentials are never sent to the API. You get `202 Accepted` and an `operation_id` right away. Poll `GET /operations/{operation_id}` until the widget URL is ready, then open `result.connection.action.widget_url`. Operation `succeeded` means the URL was issued; the user still needs to complete the widget. Connection lifecycle webhooks report later status changes. ### Add accounts to an existing Finicity connection Call this endpoint with the existing `client_id` and the bank's v3 catalog `institution_id`. When routed to Finicity, it opens Full Connect using that client's existing Finicity customer. In the widget, use the option to add accounts under the existing bank and complete the bank's authorization and account selection. `POST /connections/{connection_id}/reauthorize` opens Finicity Connect Fix for repairs and does not guarantee account selection on a healthy connection. If Finicity returns the same institution login and account identities, LedgerSync reuses the existing connection and account ids and attaches newly shared accounts. A different provider login can create a separate connection, so this is not an unconditional guarantee that all ids stay the same. Snapshot all pages of `GET /v3/accounts?client_id=...` before the session, then re-list after completion and compare account ids. There is no public `account.added` webhook, and adding accounts to an active connection does not guarantee another `connection.active` event. The widget completion redirect and operation success do not establish that the accounts are already persisted; allow time for the account list to update. **Note on `connection.id`.** For Finicity and MX the embedded `result.connection.id` is a UUID-shaped **placeholder** (`con_`) that cannot be used with any other endpoint. For a first-time connection, the canonical id, `con__` such as `con_FINICITY_41294` or `con_MX_1224`, is assigned when the source callback creates the connection. An account-addition session can reuse an already assigned canonical id. This operation reaches `succeeded` as soon as the widget URL is issued, and its stored `result` is never rewritten, so a placeholder here stays a placeholder. Check the shape before storing an id, and read the canonical one from the `connection.active` webhook or from `GET /clients/{client_id}/connections`. See the **Connection lifecycle** section at the top of this reference for the full handshake. # Reauthorize a connection Source: https://docs.ledgersyncappv2.com/api-reference/connections/reauthorize-a-connection /openapi.yaml post /connections/{connection_id}/reauthorize Mint a hosted page the end-user opens to repair credentials or re-consent at the institution, typically when the connection reports `status = requires_action`. **This endpoint is not gated on status.** It accepts an `active` connection, but Finicity Connect Fix may report that there is nothing to repair. Calling it on a healthy connection does not guarantee that an account picker will open. ### Adding Finicity accounts Use `POST /v3/clients/{client_id}/connections` with the existing Client and the bank's v3 catalog `institution_id`. When routed to Finicity, that opens Full Connect on the existing provider customer. Have the end-user choose the option to add accounts under the existing bank, then complete authorization and account selection. The same institution login reuses the existing LedgerSync connection. Starting Full Connect does not force a new connection on every session; a different provider login can create one. See that endpoint for detecting newly added accounts. A Finicity `POST /connections/{connection_id}/refresh` updates accounts already held by LedgerSync. It does not open account selection or import an account that the user has not shared. ### What you get back A hosted `reauth_url`, a LedgerSync-hosted page the end-user opens to complete the source-specific flow (Finicity Connect Fix, MX member reconnect, FDE credential recapture) against the existing connection. Redirect the user to it; connection status changes produce `connection.active` / `connection.failed` webhooks. An already active connection may have no new status transition, so do not require another `connection.active` event to finish a no-op session. The response also carries `source`: the provider the URL leads to. See "Ids are not always stable" below. ### The URL is a reusable bearer credential The link is **not** single-use. It stays valid until `action.expires_at` (currently about six days out) and anyone holding it can open the end-user's bank-linking session. Deliver it over a channel you would use for a password reset, do not log it, and do not embed it in a page that a third party can scrape. ### Ids are not always stable Repairs aim to retain existing `con_` / `acc_` / `txn_` ids. Finicity can issue a different underlying login during repair; when that cannot be matched back to the existing connection, a separate connection can result even if the response `source` is unchanged. Re-list the Client's connections and accounts after the session instead of treating a matching `source` as proof of stable ids. For a bank LedgerSync now serves exclusively through a different provider, the URL we hand back is an add-bank link for that provider rather than a repair link for the connection you named. Today that is Finicity connections at such banks being redirected to MX, and American Express is on that list in production. The list is configuration and can change without an API version bump, so branch on the response rather than on a bank you hard-coded. When the redirect happens, the end-user's session creates a **new** connection with a new `con_` id and new `acc_` / `txn_` ids, and the connection you passed is left as it was. Detect it from the response: `source` differs from the source segment of the `connection_id` you sent. On that branch the `connection.active` webhook that follows carries the **new** connection's id, not the one you passed, so key off the Client rather than the connection: wait for `connection.active`, then re-list `GET /v3/clients/{client_id}/connections` and `GET /v3/accounts?client_id=...` and re-map, rather than expecting the old ids to come back. ### Call it because a person asked, not on a timer Each completed session can share accounts you were not syncing before, and newly shared accounts pull their statement history, which is billable. Trigger this from an explicit user action. Do not schedule it, do not retry it in a loop, and do not mint a second link while the first is still valid. Available for `FINICITY`, `MX`, and `FDE`. Uploaded-statement (`PDF`) data is read-only and has no connection to reauthorize (`400`). On `MX` and `FDE` you do not need this endpoint to pick up a newly opened account: a plain refresh discovers it. See `POST /connections/{connection_id}/refresh`. Ownership is enforced server-side: a `connection_id` that doesn't belong to the authenticated customer returns `404`. ### Empty-body POST This endpoint takes no request body. Send an explicit `Content-Length: 0` header so the upstream HTTPS load balancer doesn't reject the request with `411 Length Required`. # Refresh a connection Source: https://docs.ledgersyncappv2.com/api-reference/connections/refresh-a-connection /openapi.yaml post /connections/{connection_id}/refresh Force a re-pull of accounts and recent transactions for this Connection. Banks usually post new transactions once a day so we already refresh active connections automatically — call this only when you have a specific reason to need fresher data (user just clicked a "refresh" button, you're reconciling something time-sensitive). ### Source-specific behavior On-demand refresh is not uniformly available across sources. The HTTP outcome depends on the connection's `source`: - **Finicity (`con_FINICITY_*`)** — returns `202 Accepted` with an `operation_id`. The backend queues the refresh asynchronously; on success the `account.refresh.completed` webhook fires when the bank call finishes, and on terminal failure the `account.refresh.failed` webhook fires instead. Refreshes **only the accounts already linked on this connection**: it updates balances and transactions on rows we already hold and never adds an account. An account the end-user opened, or chose not to share, after the original link is not picked up here no matter how often you call it. Use `POST /connections/{connection_id}/reauthorize` for that. - **MX (`con_MX_*`)** — returns `202 Accepted` with an `operation_id`. MX has no synchronous refresh API, so the backend triggers a member aggregation and returns immediately; MX pulls fresh balances and transactions asynchronously, and the `account.refresh.completed` webhook fires when it finishes (`account.refresh.failed` on terminal failure). The aggregation re-reads the member's **whole** account list, so an account added at the bank since link time is created here without any end-user step. The same happens on the scheduled passes, so on MX you can simply wait. - **FDE (`con_FDE_*`)** — returns `202 Accepted` with an `operation_id`. FDE re-runs its document extraction pipeline; on success the `account.refresh.completed` webhook fires on completion, and on terminal failure the `account.refresh.failed` webhook fires instead. The run re-reads the portal's sub-accounts, so a sub-account that has appeared since link time is created here too. ### Accounts opened after linking Only Finicity is closed: refresh there is update-only. MX and FDE discover new accounts on a refresh, and on MX the scheduled passes do the same without you calling anything. Either way nothing pushes you a notification, because there is no `account.added` webhook. After a refresh completes, re-list `GET /v3/accounts?client_id=…&connection_id=…` and diff on account id. `Account.created_at` carries when LedgerSync first saw the account, which is the field to sort by. ### Refresh support by source All three connectable sources — Finicity, MX, and FDE — support on-demand refresh and answer `202 Accepted` with an `operation_id`; the outcome always arrives asynchronously on the `account.refresh.completed` / `account.refresh.failed` webhook, never in the `202` body. Uploaded-statement (`PDF`) data is read-only, has no connection, and is never refreshed. ### Reading the operation The operation this returns reaches a terminal state, so polling `GET /operations/{operation_id}` is a real fallback when you cannot run a webhook handler. A refresh is one aggregation at the bank but reports per sub-account, so it stays `queued` until every account on the connection has reported, and resolves itself if those reports never arrive. On success `result` (`kind: account_refresh`) carries `accounts_refreshed` and an `accounts` array. A refresh where the aggregation ran but an account did not come back is reported as `failed` with `refresh_partially_failed`, carrying the same per-account breakdown under `error.accounts`, so branching on `status` alone cannot silently accept a connection that is missing an account's transactions. ### Rate limiting Refresh carries a daily per-customer budget separate from the standard request-rate tiers, because each call is a real aggregation at the institution rather than just another API request. Exceeding it returns `429` without queueing anything or contacting the bank. Active connections already refresh about once a day on their own, so this endpoint is for when something actually asked for fresher data. ### Empty-body POST This endpoint takes no request body. Send an explicit `Content-Length: 0` header — Google's HTTPS load balancer in front of the API rejects body-less POSTs without one with `411 Length Required`. Most HTTP clients add this automatically; some (raw `fetch`, certain SDKs in keep-alive mode) do not. # Verify a redirect-token (ls_token) from the post-Connect callback Source: https://docs.ledgersyncappv2.com/api-reference/connections/verify-a-redirect-token-ls_token-from-the-post-connect-callback /openapi.yaml post /connections/redirect-token/verify Public, unauthenticated endpoint. After a Finicity Connect widget completes, the integrator's `redirect_url` is hit with an `ls_token` query parameter — a short-lived HS256 JWT minted by v3. The integrator's frontend POSTs that token to this endpoint to retrieve the canonical `connection_id`, the original `redirect_url` it was bound to, and the `client_id`. CORS-enabled so a browser-side integrator page can call this directly. No API key is required because the token itself is the capability; its HMAC signature is verified against `V3_REDIRECT_TOKEN_SECRET`. Tampered, expired, or missing tokens return `400`. # Service health check Source: https://docs.ledgersyncappv2.com/api-reference/health/service-health-check /openapi.yaml get /health Returns `200` when the service is alive. Unauthenticated. # Get one institution by v3 catalog id Source: https://docs.ledgersyncappv2.com/api-reference/institutions/get-one-institution-by-v3-catalog-id /openapi.yaml get /institutions/{id} Returns the v3 catalog row for a single institution, including provider routing metadata, supported product capabilities, and the integrator-facing name and logo. Use this after a search to confirm the selected institution before opening a connection. # List or search the unified institution catalog Source: https://docs.ledgersyncappv2.com/api-reference/institutions/list-or-search-the-unified-institution-catalog /openapi.yaml get /institutions Returns rows from the unified v3 institution catalog. Each row carries a v3 catalog id (`ins_...`) and a `capabilities` block advertising what LedgerSync can deliver (OAuth, transactions, statements, check images) for this institution. Use the returned `id` when initiating a connection — LedgerSync's router picks the underlying source. Catalog is populated nightly from upstream Finicity, MX, and FDE institution data. Stale matches (rows added/removed upstream since the last reconciliation) may appear; the catalog converges within 24h. Only institutions LedgerSync can currently get data from are returned: at least one of `transactions`, `statements` or `check_images` is `true`. An institution with none of them (for example one its provider has taken offline) is left out until a provider supports it again. `GET /institutions/{id}` still resolves every catalog id. # API request volume, error rate, and latency percentiles Source: https://docs.ledgersyncappv2.com/api-reference/metrics/api-request-volume-error-rate-and-latency-percentiles /openapi.yaml get /metrics/api-usage Aggregated traffic snapshot for the authenticated customer + environment over the requested window. Returns total volume, success vs error counts, p50/p95 latency in milliseconds, and the 10 most-trafficked `method + path` combinations. Latency percentiles use PostgreSQL's `percentile_cont(0.5/0.95) within group (order by duration_ms)` over `v3.request_log`. # Bucketed time-series for one metric Source: https://docs.ledgersyncappv2.com/api-reference/metrics/bucketed-time-series-for-one-metric /openapi.yaml get /metrics/time-series Returns a dense (gap-filled) time-series for the requested metric. The portal renders this directly into a line/area chart without client-side reindexing. Available metrics: - `api_requests` — count of `/v3/*` requests in `v3.request_log` - `errors` — subset of `api_requests` with `status >= 400` - `webhook_deliveries` — count of `v3.webhook_event_attempt` rows - `transactions` — bridged from the main backend - `statements` — bridged from the main backend `bucket` granularity is one of `hour`, `day` (default), `week`, `month`. # Connection counts broken down by health state Source: https://docs.ledgersyncappv2.com/api-reference/metrics/connection-counts-broken-down-by-health-state /openapi.yaml get /metrics/connection-health Donut-chart fuel: how many of your active connections fall in each health bucket (`HEALTHY`, `NEEDS_REAUTH`, `FAILING`, `DISCONNECTED`). Buckets with zero count are still returned so the chart can render an empty-state outline. Not windowed — this is the current health snapshot, not a time-bounded count. # Headline counts for the portal tile grid Source: https://docs.ledgersyncappv2.com/api-reference/metrics/headline-counts-for-the-portal-tile-grid /openapi.yaml get /metrics/summary Six aggregated counts powering the at-a-glance tile grid: total clients, active connections, linked accounts, statements and transactions in the window, and the count of accounts the proactive-staleness job has flagged. All counts are scoped to the authenticated customer and environment. Window defaults to the last 30 days when `from`/`to` are omitted (max 366 days). # Top-N institutions ranked by count or failure rate Source: https://docs.ledgersyncappv2.com/api-reference/metrics/top-n-institutions-ranked-by-count-or-failure-rate /openapi.yaml get /metrics/top-institutions Returns the top-N institutions ordered by the requested key. - `by=connection_count` (default) ranks by active connection count. - `by=failure_rate` ranks by ratio of failed refreshes in window. Defaults: `limit=10`, max `50`. Tie-breaking is by institution id (stable across calls). # Webhook delivery volume, success rate, and latency Source: https://docs.ledgersyncappv2.com/api-reference/metrics/webhook-delivery-volume-success-rate-and-latency /openapi.yaml get /metrics/webhook-delivery Aggregated webhook-attempt stats over the requested window. Returns total deliveries, success vs failure counts, p95 end-to-end latency (event creation to attempted delivery), and the top failing event types (top 10 by failure count). # Check on an async operation Source: https://docs.ledgersyncappv2.com/api-reference/operations/check-on-an-async-operation /openapi.yaml get /operations/{operation_id} A handful of endpoints (initiate connection, refresh) hand you back an `operation_id`. Polling here is the fallback if you can't run a webhook handler — usually you'll get the result via webhook first. The `result` field is populated when `status=succeeded`; `error` when `status=failed`. **For `connection.initiate` operations specifically:** `status=succeeded` here means the widget URL was issued, not that the bank was linked, and the stored `result` is never rewritten afterwards. Use this endpoint to collect `result.connection.action.widget_url`. Judge `result.connection.id` by its shape before storing it: for Finicity and MX it is a UUID-shaped placeholder (`con_`) that is **not** valid against any other endpoint and that stays a placeholder for the life of the operation. To get the canonical `con__` (e.g. `con_FINICITY_41294`, `con_MX_1224`), read the `connection.active` webhook or call `GET /clients/{client_id}/connections`. See the **Connection lifecycle** section at the top of this reference for the full handshake. # Edit a live application while still under review Source: https://docs.ledgersyncappv2.com/api-reference/portalgating/edit-a-live-application-while-still-under-review /openapi.yaml patch /portal/live-request Patches the integrator-supplied fields of a live application that is still `PENDING` or in `NEEDS_INFO`. Reviewer-managed fields (status, denial codes) are immutable here. Returns 400 when the row is already `APPROVED` or `DENIED`. # Read the gating status for the signed-in user Source: https://docs.ledgersyncappv2.com/api-reference/portalgating/read-the-gating-status-for-the-signed-in-user /openapi.yaml get /portal/access-request/status Returns the latest sandbox + live status for the WorkOS user whose id is supplied as the `X-Portal-Workos-User-Id` header. The HMAC signature is computed over that header value (the endpoint has no body), keeping the identity unforgeable. Either status field is null when the user has not yet filed a sandbox or live application. # Submit a live-environment access request Source: https://docs.ledgersyncappv2.com/api-reference/portalgating/submit-a-live-environment-access-request /openapi.yaml post /portal/live-request Same auth + rate-limit shape as `POST /portal/access-request`, with the production-readiness fields collected by the live application form. Writes both an `access_request` row (`environment=LIVE`) and a `live_profile` sidecar row in a single transaction. # Submit a sandbox access request Source: https://docs.ledgersyncappv2.com/api-reference/portalgating/submit-a-sandbox-access-request /openapi.yaml post /portal/access-request Records a developer's sandbox sign-up. The portal calls this immediately after the WorkOS sign-up flow completes; the request is HMAC-signed with the shared bootstrap secret (`X-Portal-Bootstrap-Signature` header), so the embedded `workos_user_id` is trusted without an additional JWT round-trip. The endpoint: - Verifies the Cloudflare Turnstile token against the canonical `https://challenges.cloudflare.com/turnstile/v0/siteverify` endpoint using `TURNSTILE_SECRET_KEY`. - Normalizes the email (strip `+tag`, gmail dot-stripping) and rejects when the source IP exceeds 3 sign-ups in 24h or the email domain exceeds 10 in 24h. - Inserts a `v3.access_request` row with `status=PENDING` and `environment=SANDBOX`. Feature-flagged behind `ledgersync.v3.portal-gating.enabled`; with the flag off, returns 503. # List sandbox-eligible test banks Source: https://docs.ledgersyncappv2.com/api-reference/sandbox/list-sandbox-eligible-test-banks /openapi.yaml get /sandbox/institutions Returns the canonical sandbox FIXTURE catalog (`ins_sandbox_001` / `ins_sandbox_002`) for synthetic flows. NOTE: real sandbox bank routing uses the standard `/v3/institutions` catalog; `ins_3c97c5da90335a04` (FinBank, Finicity sandbox) and `ins_73df5077e90a1b63` (MX Bank, MX sandbox) live there. Look them up with `GET /v3/institutions?q=FinBank` or `?q=MX Bank` rather than hardcoding the ids. This endpoint exists for backward-compat with early sandbox readers. Drive the widget end-to-end with the public test bank credentials documented in the developer portal's testing playbook (`customer1` / `go` for Finicity, `mxuser` / `correct` for MX). Calling this with a live key returns 403. # Delete the redirect-URL allowlist row for the current account + environment Source: https://docs.ledgersyncappv2.com/api-reference/settings/delete-the-redirect-url-allowlist-row-for-the-current-account-+-environment /openapi.yaml delete /settings/redirect-urls Drops the allowlist row entirely. After this call, any `/connections/initiate` carrying a `redirect_url` is rejected with `400`. To keep the row but disable all redirects, POST an empty array instead. # Read the redirect-URL allowlist for the current account + environment Source: https://docs.ledgersyncappv2.com/api-reference/settings/read-the-redirect-url-allowlist-for-the-current-account-+-environment /openapi.yaml get /settings/redirect-urls Returns the redirect URLs registered for your account in the environment of the API key used to call this endpoint (sandbox or live). Empty list means "no allowlist row configured" — any subsequent `/connections/initiate` carrying a `redirect_url` returns `400`. Scoped to `(customer_id, environment)` so a sandbox key only sees the sandbox row. # Replace the redirect-URL allowlist for the current account + environment Source: https://docs.ledgersyncappv2.com/api-reference/settings/replace-the-redirect-url-allowlist-for-the-current-account-+-environment /openapi.yaml post /settings/redirect-urls Overwrites the full allowlist (no add/remove primitives, no half-applied state). Up to 20 entries, each up to 2048 chars. Every URL must be `https://`, must have a host, must not include a `userinfo` (user:password@) component, and must not resolve to a private/loopback/link-local/cloud-metadata range. Sending an empty list stores an empty row, which the runtime validator treats as deny-all. To remove the row entirely (and return to the "no allowlist configured" denial shape) call `DELETE /v3/settings/redirect-urls`. # Download the statement PDF Source: https://docs.ledgersyncappv2.com/api-reference/statements/download-the-statement-pdf /openapi.yaml get /statements/{statement_id}/download Streams the original statement PDF bytes for the given statement. This is the same URL exposed in `Statement.download_url` — call it directly with your API key. # Extract transactions from an uploaded bank-statement PDF Source: https://docs.ledgersyncappv2.com/api-reference/statements/extract-transactions-from-an-uploaded-bank-statement-pdf /openapi.yaml post /statements/extract Upload a bank-statement PDF and get back the OCR-extracted transactions — the same engine behind the LedgerSync app's Bank Statement Converter. Unlike the other statement endpoints, this call is **customer-level**: no `client_id` is required, and the upload is standalone — it never attaches to a connection, account, or stored statement. Constraints: the file must be a PDF (`%PDF-` header) of at most **30 MB** — larger uploads are rejected with `413`, and requests over 32 MB may be cut at our edge with a non-JSON 413. Requires the `write:statements` scope. Concurrency and daily budgets apply per customer beyond the standard request-rate tiers (each extraction runs a real OCR pass); exceeding them returns `429 rate_limit_exceeded`. This one's genuinely async: you get a `202` with an `operation_id`. Poll [`GET /operations/{operation_id}`](#operation/getOperation) — a typical statement completes in well under a minute. On success the operation's `result` carries `kind: statement_extraction` with the extracted transactions; output is JSON only (no CSV/XLSX export). There is no webhook for this operation type today — polling is the only completion signal. # Get a statement Source: https://docs.ledgersyncappv2.com/api-reference/statements/get-a-statement /openapi.yaml get /statements/{statement_id} Returns the statement metadata plus, when available, a `download_url` for the original PDF. That URL is a **relative `/v3` path scoped to your API key**, not a signed link: join it to the API host and send your `Authorization` header. It carries no signature and does not expire, so handing it to a browser tab on its own returns `401`. For FDE connections this is the document the user uploaded; for bank-sourced statements it's the bank's published file. # List an account's statements Source: https://docs.ledgersyncappv2.com/api-reference/statements/list-an-accounts-statements /openapi.yaml get /accounts/{account_id}/statements Period statements (usually monthly) the bank publishes. For FDE connections, statements come from the document the user uploaded; for Finicity/MX they come from the bank's statement feed when available. # Get a single transaction Source: https://docs.ledgersyncappv2.com/api-reference/transactions/get-a-single-transaction /openapi.yaml get /transactions/{transaction_id} Look up one transaction by id. Useful when a webhook just fired for a specific `transaction_id` and you want the full record. # List transactions for an account Source: https://docs.ledgersyncappv2.com/api-reference/transactions/list-transactions-for-an-account /openapi.yaml get /accounts/{account_id}/transactions Page through an account's transactions, optionally filtered by date range. Pending and posted are both included — check the `pending` boolean. New transactions land here after each refresh (auto-daily or your manual `POST /refresh` call); a future push-delivery event for transactions will be offered for push consumers. # Create a webhook subscription Source: https://docs.ledgersyncappv2.com/api-reference/webhooks/create-a-webhook-subscription /openapi.yaml post /webhooks/subscriptions Register a URL to receive event notifications. Pick which event types to subscribe to via `event_types`, or use `"*"` to subscribe to every event. We deliver via HTTPS POST and sign every body with HMAC-SHA256 — see the **Webhooks** tab for the full signature scheme. The response includes `signing_secret` exactly once. Save it somewhere safe; we can't show it again. (You can always rotate it later if you lose track.) # Delete a webhook subscription Source: https://docs.ledgersyncappv2.com/api-reference/webhooks/delete-a-webhook-subscription /openapi.yaml delete /webhooks/subscriptions/{subscription_id} Stop delivering events to this URL. We immediately drop any retries already queued for it. Past delivery attempts stay in the audit log. # Get a webhook subscription Source: https://docs.ledgersyncappv2.com/api-reference/webhooks/get-a-webhook-subscription /openapi.yaml get /webhooks/subscriptions/{subscription_id} Fetch one subscription by id. The signing secret is omitted — use rotate-secret if you've lost it. # List webhook subscriptions Source: https://docs.ledgersyncappv2.com/api-reference/webhooks/list-webhook-subscriptions /openapi.yaml get /webhooks/subscriptions Every URL we're currently delivering events to, plus the event types each one is subscribed to. The signing secret isn't returned here — that only comes back at creation / rotation. # Rotate the signing secret Source: https://docs.ledgersyncappv2.com/api-reference/webhooks/rotate-the-signing-secret /openapi.yaml post /webhooks/subscriptions/{subscription_id}/rotate-secret Generate a fresh signing secret. The old one stays valid for a 24-hour grace period so you can deploy your new verifier without dropping a delivery. After that, only the new secret will pass signature checks. Store the returned `signing_secret` immediately — same deal as creation, you only get to see it once. ### Empty-body POST This endpoint takes no request body. Send an explicit `Content-Length: 0` header — Google's HTTPS load balancer in front of the API rejects body-less POSTs without one with `411 Length Required`. # Send a test event Source: https://docs.ledgersyncappv2.com/api-reference/webhooks/send-a-test-event /openapi.yaml post /webhooks/subscriptions/{subscription_id}/test Fire a synthetic `webhook.test` event to this subscription's URL, signed the exact same way real events are. Great for verifying your handler and signature scheme before going live. ### Empty-body POST This endpoint takes no request body. Send an explicit `Content-Length: 0` header — Google's HTTPS load balancer in front of the API rejects body-less POSTs without one with `411 Length Required`. # Update a webhook subscription Source: https://docs.ledgersyncappv2.com/api-reference/webhooks/update-a-webhook-subscription /openapi.yaml patch /webhooks/subscriptions/{subscription_id} Change the delivery URL, the subscribed event types, or pause/resume delivery (`status` active ↔ paused). All fields are optional; omitted fields are left unchanged. Use DELETE to permanently disable. # Authentication Source: https://docs.ledgersyncappv2.com/authentication Bearer keys, sandbox vs live, and how to keep them safe. Every request carries your secret key as a Bearer token: ```bash theme={null} curl https://api-sandbox.ledgersyncappv2.com/v3/clients \ -H "Authorization: Bearer sk_test_..." ``` ## Two environments, two key prefixes Keys start with **`sk_test_`**. Free, safe to experiment, routes to aggregator test banks. Base URL `https://api-sandbox.ledgersyncappv2.com/v3`. Keys start with **`sk_live_`**. Real banks, real data. Base URL `https://api.ledgersyncappv2.com/v3`. Treat secret keys like passwords. Never embed them in mobile apps, browsers, or anything client-side, they belong on your server only. If a key leaks, roll it in the developer portal. ## End-user tokens A few flows act on behalf of one specific end-user (for example, submitting an MFA answer mid-connection). Those use a separate, short-lived token you mint with your API key and hand to client-side code, never your `sk_` key itself. ## Getting a trace id Every response includes an `X-LS-Trace-Id` header. Paste it into any support request and we can jump straight to your exact call. # Changelog Source: https://docs.ledgersyncappv2.com/changelog New features, improvements, and fixes to the LedgerSync v3 API. Newest first. ## A simpler AI connector guide for LedgerSync customers The AI connector pages are now written for firms that already use LedgerSync. * A firm new to LedgerSync signs up in the LedgerSync app first ([ledgersyncappv2.com/sign-up](https://ledgersyncappv2.com/sign-up)), then connects Claude or ChatGPT. * The setup pages keep their title and progress bar at the top while you scroll, and the setup videos open over the page. * The Claude Team or Enterprise and ChatGPT Business, Enterprise or Edu steps are now at the top of each setup page. See [Use LedgerSync in Claude or ChatGPT](/ai/start-here). ## Sign in to the AI connector with your LedgerSync app login When you connect LedgerSync in Claude or ChatGPT, the LedgerSync sign-in page now opens. * Firms that use the LedgerSync app sign in with their normal LedgerSync username or email and password, plus their two-step code if they use one. No developer portal account and no email to support. * New firms create their LedgerSync account on that page: **Create account**, the sign-up form, then the order page. Our team reviews each new firm once. Until then the connector answers the new code `firm_under_review` when asked to connect a bank. Clients can be added. * Only the firm owner can connect for now. * Approved developer accounts type their developer portal email on the LedgerSync page, which sends them to the developer portal sign-in. An application still waiting for its first approval cannot connect yet. * Connections made before this change keep working. * The portal's AI sign-up page (`/start/ai`) now sends new firms to the LedgerSync app sign-up. See [Use LedgerSync in Claude or ChatGPT](/ai/start-here). ## See which banks need the LedgerSync desktop app, and link it from Claude and ChatGPT A few banks that LedgerSync reaches through FDE need the LedgerSync desktop app for refreshes. The app runs on a computer at the firm. The API and the AI connector now say which ones, and the connector can link the app. **New field `requires_desktop_app`.** It is optional, so it is additive for existing clients. * On institutions (`GET /v3/institutions` and `GET /v3/institutions/{id}`): `true` when LedgerSync reaches the bank through FDE and needs the desktop app to refresh it. Omitted when unknown. * On connections (`GET /v3/connections/{id}` and `GET /v3/clients/{client_id}/connections`): on FDE connections only, omitted for other sources. When `true`, the desktop app must be open and connected on the firm's computer for a refresh to run. **Two new AI connector tools, on the live connector only:** * `ledgersync_get_desktop_app_status` says whether the desktop app is linked and connected right now, with the Windows and Mac download links and the setup steps. * `ledgersync_pair_desktop_app` links the app with the 7-character code it shows. It replaces any computer linked before, so the AI asks the user to confirm first. Each account gets 10 tries an hour and 20 a day. * Bank search results and connections in the connector mark the banks that need the app. * New codes for linking: `pairing_code_invalid`, `desktop_app_session_closed`, `desktop_service_unavailable` and `pairing_rate_limited`. **Refreshes stopped by the desktop app say so.** * A refresh FDE stops because the bank needs the desktop app and the app is not connected fails at once with `desktop_app_required`, and the connection reads `requires_action` with the same code. A refresh stopped while waiting for the choice of whether to use the desktop app fails with `desktop_app_route_choice`. Neither reached the bank, so neither counts against the daily refresh budget. * When FDE asks at the start of a refresh whether to use the desktop app, a working FDE connection no longer shows `requires_action` with `mfa_required` for that moment. It stays `active`, and no webhook fires for the question. See [Errors](/guides/errors) and [Banks that need the LedgerSync desktop app](/ai/desktop-app). ## New firms can sign up for Claude and ChatGPT in a few minutes A firm new to LedgerSync can now sign up with only its name and a card at [portal.ledgersyncappv2.com/start/ai/new](https://portal.ledgersyncappv2.com/start/ai/new). There is no sandbox step and no developer form. * The first charge is one month after the card is added. * We review each firm once, within 2 business days, and email the answer. A sign-up we do not approve, or one that closes before its review, is not charged. * The firm can add LedgerSync to Claude or ChatGPT while it waits; it starts working as soon as the firm is approved. * On the live connector, Claude and ChatGPT now explain in plain words what a new firm still needs to do, with a link to the right page. * Developers building on the API keep the usual path: a sandbox request, then the Live application and payment. See [Use LedgerSync in Claude or ChatGPT](/ai/start-here). ## Use LedgerSync in Claude and ChatGPT Add the LedgerSync connector to Claude or ChatGPT and ask in plain words: find a client, send them a secure link to connect a bank, read balances, transactions and statements, see which connections need attention, and refresh a connection. Bank sign-ins stay on LedgerSync's own pages, never in the chat. * **Live:** `https://api.ledgersyncappv2.com/mcp`. **Sandbox:** `https://api-sandbox.ledgersyncappv2.com/mcp`. * Sign in with your developer portal account. Live needs the same approval and payment as live API keys; existing LedgerSync app customers are linked, not charged again. * The portal's new **Connected apps** page lists the AI apps connected to your account and disconnects one at once. See [Use LedgerSync in Claude or ChatGPT](/ai/start-here). ## Refreshes of MX and FDE connections now finish `POST /v3/connections/{id}/refresh` on an MX or FDE connection now ends the way it does on Finicity: the operation turns `succeeded` or `failed` once every account on the connection has reported, and `account.refresh.completed` or `account.refresh.failed` fires for each account. The one exception is an FDE refresh refused straight away (below), which fails the operation without a webhook. * **MX and FDE refresh operations reach a real verdict.** They used to record no accounts to wait on, so every one ended `failed` with `refresh_timed_out` after 90 minutes, whatever happened at the bank. * **MX refresh webhooks are sent for client connections.** `account.refresh.completed` and `account.refresh.failed` were never sent for an MX connection that belongs to a client, which is every MX connection made through the API. They are now. * **An MX refresh that finds nothing new still finishes.** MX sends LedgerSync no notice when a sync brings no new or changed transactions, so LedgerSync now asks MX once the sync is over and reports it. Expect the verdict about three minutes after the call. * **An FDE refresh the bank answers with a security question fails at once** with `mfa_required`, instead of waiting out the timeout. Send the user a fix link from [`POST /v3/connections/{id}/reauthorize`](/guides/connection-lifecycle#handling-failed-and-disconnected). Any other refusal fails at once with `source_internal_error`, with the FDE status in `source_diagnostic_code` (for example `FDE-UPDATED_ERROR`). No webhook fires for either; read the operation. * **FDE connections report `last_refreshed_at`.** It was always `null` on FDE connections. Their `created_at` and `updated_at`, which follow it, fill in too. * **Sandbox refresh operations finish too.** On sandbox they never matched their results and always timed out. * **An earlier sync can no longer answer your refresh.** A sync result that reached LedgerSync before your call, but was still being processed, could close your refresh with that older data. Only a result that arrives after your call answers it now. **Correction to the 2026-08-25 entry.** It said MX and FDE connections "already reported completion correctly". They did not: their refresh operations always ended `refresh_timed_out`, as described above. See [Keeping data fresh](/guides/data-freshness#what-the-operation-tells-you). ## Statement downloads work again From 2026-09-24 until the fix on 2026-09-25, `GET /v3/statements/{id}/download` answered `500` instead of the PDF. It returns the file again. If a scheduled download failed in that window, run it again. ## Connection status and webhooks now agree `GET /v3/connections/{id}` and the `connection.*` webhooks now report the same status for the same state. * **MX connections read their real status.** The read endpoint used to report any unhealthy MX connection as `disconnected`. It now follows MX's own member status, the same one the webhooks report: MX `PREVENTED` (too many failed sign-ins) and `DENIED` read `requires_action` with `credentials_invalid`, MX `DISCONNECTED` and `CLOSED` read `disconnected`, and a healthy or still-syncing member reads `active`. Expect some MX connections to move from `disconnected` to their real status. * **The reason matches the status.** A `connection.requires_action` for MX `PREVENTED` used to carry `institution_unavailable` with `is_user_actionable: false`. It now carries `credentials_invalid`, actionable. An MX-locked account is the one deliberate exception: `requires_action` with `account_locked_by_institution` and `is_user_actionable: false`, because the user acts at the bank. See [Errors](/guides/errors) for the MX codes behind each error. * **Connection events name the client.** `data.connection.client_id` is now present on status-change events whenever the owner can be resolved, and the key is omitted, never `null`, when it cannot. * **Deleting a connection sends `connection.disconnected`,** as the testing guide already described. * **Status changes found by the scheduled daily refresh now send webhooks.** A connection that recovers on its own after `connection.failed` sends `connection.active`. One exception remains: a Finicity MFA challenge on an existing connection reads `requires_action` on `GET` without an event. * **A new MX connection is named by a webhook.** When a first MX link connects, or the bank rejects it, a `connection.*` event now carries the canonical `con_MX_...` id. Before, only the initiate `connection.requires_action` under the placeholder id fired, so an MX link that connected without an MFA step, or failed outright, had to be found by listing the client's connections. `disconnected` no longer means "the user revoked access": it means the source stopped aggregating the connection, you deleted it, or (for Finicity and portal connections) LedgerSync's own refresh of it failed. See [Connection lifecycle](/guides/connection-lifecycle#the-status-flow). ## Card terms, account masks and retracted transactions * **`liabilities` appears only on liability accounts,** such as credit cards, lines of credit, loans and mortgages. It no longer appears on checking, savings or investment accounts, even when the bank reports a figure there. Identify cards by `type: credit_card`; loans and mortgages carry the object too. * **MX now reports the statement balance.** It was dropped before, so every MX `statement_balance` was null. It fills in as each MX account next syncs. * **Card terms refresh on the scheduled daily refresh,** not only when a refresh is triggered. * **`mask` is now filled for MX, portal (FDE) and uploaded-statement accounts,** as at most the last 4 digits of the account number. It used to be null for all three. * **`GET /v3/transactions/{id}` returns `404` for a Finicity transaction the source retracted.** The list never returned those; the single read now agrees. If you stored such an id, treat the 404 as "removed". **Corrections to the 2026-09-04 entry.** The object's presence is not the card signal (loans and mortgages carry it). Finicity does not report all five fields everywhere, and MX does report the statement balance. The real coverage, measured per source and field, is in [Credit-card terms](/guides/connection-lifecycle#credit-card-terms). ## Webhook subscriptions now report delivery health `last_success_at` and `last_failure_at` were in the `WebhookSubscription` schema but documented as reserved, and never appeared in a response. They are now populated and returned on every `GET`, `LIST` and `PATCH`: ```json theme={null} { "id": "whk_7c1e4a9b83d24f6ab05e2f8c91d3a640", "url": "https://yourapp.com/webhooks/ledgersync", "status": "active", "last_success_at": "2026-09-14T11:43:02Z", "last_failure_at": "2026-09-12T21:08:05Z" } ``` Read them together: **`last_failure_at` newer than `last_success_at` means deliveries are failing right now**, and that is the condition worth alerting on. A subscription stays `active` however badly delivery is going, because we keep retrying rather than disabling your endpoint, so `status` alone was never enough to tell you your webhooks had stopped arriving. Failures are stamped on every failed attempt, including retries still to come, and a later success never clears `last_failure_at`, so an endpoint that keeps flapping stays visible. Both fields are **omitted** rather than sent as `null` when there is nothing to report, so check for the key rather than comparing to `null`. Existing subscriptions were **backfilled** from delivery history, so the timestamps are meaningful immediately rather than only reflecting deliveries from today onward. See [Check delivery health](/guides/webhooks#check-delivery-health). ## Credit-card terms on the Account Credit limit, statement balance, minimum payment and payment due date were not available on the API. They are now, on a new **`liabilities`** object: ```json theme={null} { "id": "acc_FINICITY_84213", "type": "credit_card", "current_balance": -14381.66, "liabilities": { "credit_limit": 65000.00, "available_credit": 50596.54, "statement_balance": -14381.66, "minimum_payment": 143.00, "payment_due_date": "2026-09-24" } } ``` The object is **absent unless we know something**, so it is null for every account that is not a credit card or line of credit, and null for cards at banks that publish no terms. Check for the object before reading fields inside it, and treat an absent field as "the bank did not say", not zero. Bank connections report all five fields where the bank publishes them. MX reports every field except the statement balance, which it does not track. Portal connections and uploaded statements report none. **`statement_balance` is signed like `current_balance`:** what the client owes is negative, so the two agree on the same account. That sign is normalized rather than reported, because banks disagree with each other about it. One consequence: a card that was in credit at statement close is reported as owed. `credit_limit`, `available_credit` and `minimum_payment` are always positive. `payment_due_date` is only as current as the last refresh, so a date in the past means the cycle rolled over before our last pull, not that a payment is overdue. See [Credit-card terms](/guides/connection-lifecycle#credit-card-terms). ## Fetching a single transaction works on every source, and accounts now say how far back they go **`GET /v3/transactions/{id}` resolves ids from all four sources.** It only ever worked for Finicity and uploaded-statement transactions. An MX id came back `404`, and a portal-connection (FDE) id failed outright, even though both were listed by `GET /accounts/{id}/transactions` moments earlier. An id we hand you now resolves. **FDE transactions carry `account_id`.** The field is documented as required and was arriving `null` on that source, so there was no way to tell which account a transaction belonged to without tracking the request you made to get it. **New `oldest_transaction_date` on the Account.** It answers "how much history do I have here", per account: ```json theme={null} { "id": "acc_FINICITY_84213", "type": "checking", "oldest_transaction_date": "2026-05-28" } ``` It is measured from the transactions we hold rather than copied from what the upstream provider reports about itself, so it is exactly the minimum `date` you get if you page that account's whole history. It is `null` when we hold no transactions for the account yet, and it moves backwards if a backfill lands earlier data, so read it rather than caching it. See [How far back does the history go?](/guides/data-freshness#how-far-back-does-the-history-go). The field is optional and nullable, so it is additive for existing clients. ## Account `type` is now guaranteed, and balances are signed consistently Three things about the Account object were true in the reference but not in the responses. They are true now, and a contract test keeps them that way. **`type` is always one of the six documented values, and never null.** It used to be a pass-through of whatever the underlying aggregator called the account, so a Finicity credit card arrived as `creditcard`, an uploaded-statement one as `credit card`, and only MX ever sent the documented `credit_card`. Accounts from portal-based connections sent no `type` at all. If you branch on `type === "credit_card"` you will now match every credit card, on every source. **New `subtype` field.** The coarse enum discards real detail, so the source's own label now travels alongside it: `money_market`, `mortgage`, `line_of_credit`, `ira`, `plan_401k`, `bank`, and so on. It is an open vocabulary rather than an enum, so new values are never a breaking change. Branch on `type`; use `subtype` for display or for finer routing you control. If you were relying on the old raw values, **`subtype` is what you were reading before**, so switching your comparison from `type` to `subtype` gets you exactly the previous behaviour. **`current_balance` is signed from the account holder's point of view.** Positive is value the client holds, negative is value they owe, on every source. Previously the sign followed whichever aggregator supplied the account, so the same credit card could be positive or negative depending on how it happened to be connected. Summing `current_balance` across a client's accounts now gives net worth directly. A real credit balance on an overpaid card is still positive, and a checking account in overdraft is still negative, so the sign stays meaningful in both directions. `available_balance` is unchanged: on a card it is available credit, not a debt, and it is never sign-flipped. The `account.refresh.completed` webhook is signed the same way, so the value you read from `GET /accounts` and the value you receive on the event agree for the same account. **`available_balance` capability now reports honestly.** It was declared `never` for every source while MX accounts were returning a real value. MX is now `sometimes`, which is accurate: MX reports it for many checking accounts and few credit cards. Every other source is genuinely `never`. Also fixed: the transactions endpoint told you to check a `status` field that does not exist. The field is the `pending` boolean. ## Refresh now pulls transactions, and its operation actually finishes `POST /v3/connections/{id}/refresh` on a Finicity connection re-aggregated balances only. Transactions were not pulled, `last_refreshed_at` never moved, and no `account.refresh.completed` webhook fired - so the operation it handed you stayed `queued` forever and polling it never returned a terminal state. Fixed. The call also answers in milliseconds now instead of blocking. * **Transactions are pulled** and `last_refreshed_at` advances, so re-reading `GET /v3/accounts` after the webhook shows what you would expect. * **The operation reaches a terminal state.** A refresh is one aggregation at the bank but reports per sub-account, so the operation turns `succeeded` or `failed` once every account on the connection has reported - and resolves on its own if the events never arrive, so polling always terminates. * **Partial failures are reported as failures**, not as partial successes. If the aggregation ran but one account did not come back, you get `refresh_partially_failed` with a per-account breakdown in `error.accounts[]`, so branching on `status` alone cannot quietly ship a connection that is missing an account's transactions. * **New error codes:** `refresh_partially_failed`, `refresh_timed_out`, `refresh_outcome_not_observable`, `no_refreshable_accounts`. * **Refresh now has a daily budget** separate from the request-rate limit, since each one is a real aggregation at the institution. Over it, the call returns `429 rate_limit_exceeded` without touching the bank. * `estimated_seconds` on the `202` was raised from `10` to `120` to match how long a refresh actually takes. It is a best-effort floor, not a deadline. MX and FDE connections are unchanged - they already reported completion correctly and keep the behaviour they had. See [Keeping data fresh](/guides/data-freshness#what-the-operation-tells-you) and the [connection refresh error codes](/guides/errors#connection-refresh). ## Reauthorize (fix) a broken connection in place New `POST /v3/connections/{connection_id}/reauthorize` repairs a connection that needs credentials or consent renewed, aiming to preserve existing IDs. * Returns a `reauthorize` action with a hosted `reauth_url`; redirect the end user there to re-enter credentials or re-consent at the institution, then wait for `connection.active` (or `connection.failed`). * ID preservation depends on the provider and whether the repaired login and accounts match the stored connection. Starting another Finicity Full Connect session can also reuse an existing bank login; use that flow to [add accounts](/guides/adding-accounts), and verify canonical IDs afterward. * Available for `FINICITY`, `MX`, and `FDE`. Uploaded-statement (`PDF`) sources are read-only and have no connection to reauthorize. See [Connection lifecycle](/guides/connection-lifecycle#handling-failed-and-disconnected). ## Check images are now readable from the API The `check_images` capability has had a read path since day one on the data side, but no route to fetch it. Three endpoints now serve it, nested under the owning account: ``` GET /v3/accounts/{account_id}/checks GET /v3/accounts/{account_id}/checks/{check_id} GET /v3/accounts/{account_id}/checks/{check_id}/download ``` * Requires the new **`read:checks`** scope. Existing keys don't have it — tick it on a new key in the portal, or `PATCH /v3/api-keys/{id}` to widen the key you already have without rotating its secret. * **FDE-only.** Non-FDE accounts return an empty list rather than an error, so you can call this uniformly across sources. * **There is no back image** — LedgerSync captures the front of a check only, so there is no `side` field. * Bank-reported fields and `ocr.*` are kept separate, so you can always tell what the bank said from what OCR read off the paper. * The sandbox **Ledgersync Bank** (`ins_a7397a8d0656e1b7`) returns real check images, so the whole flow is testable end-to-end. See [Check images](/guides/checks). ## On-demand refresh now works for MX connections `POST /v3/connections/{id}/refresh` now supports MX (`con_MX_*`) the same way it already supported Finicity and FDE: it returns `202 Accepted` with an `operation_id` and triggers a fresh aggregation. (Previously MX returned `502 refresh_not_supported`.) * Completion is asynchronous — listen for the `account.refresh.completed` webhook (or `account.refresh.failed` on terminal failure), then re-read `GET /v3/accounts` for the updated balances and transactions. * All three connectable sources (Finicity, MX, FDE) now answer `/refresh` uniformly, so you no longer need to special-case MX. ## PDF: a read-only fourth data source Accounts created from **uploaded bank statements** now surface through the API. Their accounts and transactions come back with `source: "PDF"` (ids like `acc_PDF_42`, `txn_PDF_8837`). * Read-only: `PDF` isn't linked through the widget and never appears under `GET /v3/connections` — a `PDF` account's `connection_id` is `null`. * Exposed on `GET /v3/accounts` and `GET /v3/accounts/{id}/transactions`. Statements aren't served for `PDF` accounts. * Everything else — pagination, filtering, the response shape — reads exactly like an aggregator source. See [the data model](/introduction#the-data-model). ## Cursor pagination on all list endpoints Every list endpoint now returns an opaque `next_cursor` and a `has_more` flag. Pass `next_cursor` back as the `cursor` query param and loop until `has_more` is `false`. * Keyset (seek) based, so it stays correct even as rows are added or removed between pages — no skipped or double-counted items. * `limit` defaults to `100` (max `500`; higher values are capped, not rejected). * Cursors are opaque and query-scoped — reusing one against a different account, filter, or date window returns `400 invalid_request`. Full guide: [Pagination](/guides/pagination). ## Extract transactions from a bank-statement PDF New `POST /v3/statements/extract` — upload a bank-statement PDF and get back the OCR-extracted transactions, powered by the same engine behind the LedgerSync app's Bank Statement Converter. * Customer-level and standalone: no `client_id`, and the upload never attaches to a connection, account, or stored statement. * Async: returns `202` with an `operation_id`; poll `GET /v3/operations/{id}` (most statements finish in under a minute). The result carries `kind: statement_extraction`. There's no webhook for this one — polling is the completion signal. * PDF only, up to 30 MB (larger uploads are rejected with `413`). Requires the `write:statements` scope. Per-customer concurrency and daily budgets apply on top of the standard rate tiers — exceeding them returns `429`. # Adding a new account Source: https://docs.ledgersyncappv2.com/guides/adding-accounts Your client opened a new account at a bank they already linked. What to call to get it syncing, per source, and how to detect that it arrived. A client links Chase, and three months later they open a second checking account under the same Chase login. An Account exists in LedgerSync once the source returns it and LedgerSync saves it. The call to make depends on the connection's **source**. You can read the source off the connection id: `con_MX_1224`, `con_FDE_882`, `con_FINICITY_41294`. See [The two id formats](/guides/connection-lifecycle#the-two-id-formats). ## The short answer | Connection | Who finds the new account | What you call | | - | - | - | | `con_MX_*` | **MX.** Every aggregation re-reads the member's full account list and adds accounts LedgerSync has not seen before. | Nothing, if you can wait for the scheduled pass. `POST /connections/{id}/refresh` to pull it in now. | | `con_FDE_*` | **LedgerSync.** Each extraction run re-reads the sub-accounts behind the connection and inserts codes it has not seen before. | `POST /connections/{id}/refresh`. Extraction also re-runs on its own, but not on a cadence you should plan around, so call refresh when you need the account now. | | `con_FINICITY_*` | The client shares the additional account in **Mastercard Full Connect**, then LedgerSync saves it. The v3 Finicity refresh only updates accounts already attached to the connection. | `POST /v3/clients/{client_id}/connections` for the **existing client**, then open the operation's `result.connection.action.widget_url`. | | PDF accounts (`acc_PDF_*`) | Not applicable. Uploaded-statement data has no Connection at all: an `acc_PDF_*` account carries `connection_id: null`, and no `con_PDF_*` id exists to refresh or reauthorize. | Nothing. | On a Finicity connection, the v3 `POST /connections/{id}/refresh` updates the accounts LedgerSync already holds. It does not import missing accounts or ask the client to share additional ones. Repeated refresh calls do not perform account selection. ## Why Finicity is different A refresh cannot grant access to an account the client has not shared. MX and FDE refresh paths also import newly returned accounts. The v3 Finicity refresh path updates stored accounts; use Full Connect when the client wants to share another account. Mastercard distinguishes [Full Connect, Lite, and Fix](https://developer.mastercard.com/open-finance-us/documentation/connect/generate-2-connect-url-apis/). Full Connect includes account management. Connect Fix handles connection repair, such as credentials or MFA that need attention. ## Finicity: open Full Connect for the existing client Call `POST /v3/clients/{client_id}/connections` using the client who owns the existing connection and the bank's v3 catalog `institution_id`. Routing selects the source; when it selects Finicity, this opens Full Connect using the client's existing Finicity customer. **Starting this session does not force a new stored connection.** Choose the existing bank in the widget and use its add-account option, such as **"Add another Chase account"**. When Mastercard returns the same institution login, LedgerSync reuses the existing `con_FINICITY_*` connection. Existing accounts with the same provider account IDs keep their LedgerSync account rows and IDs; a newly shared account receives a new `acc_FINICITY_*` ID. Earlier instructions on this page incorrectly recommended `/reauthorize` for adding accounts to a healthy Finicity connection. That endpoint uses Connect Fix. It can return a valid URL and then show **"No action required"** because there is nothing to repair. HTTP `200` does not mean account selection will appear. Use Full Connect for account addition. Use the same `client_id`; do not create another Client. Supply the bank's `ins_...` identifier from `GET /v3/institutions`, not the existing `con_...` identifier. ```bash theme={null} curl -X POST https://api.ledgersyncappv2.com/v3/clients/cli_01HXYZ.../connections \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"institution_id":"ins_01HZX9CHASE"}' ``` Replace the example IDs with your client's and institution's actual IDs. The response is `202 Accepted` with an `operation_id`. There is no `source` or existing `connection_id` field to send in this request. An optional allowlisted `redirect_url` returns the browser to your app; see [Redirect flow](/guides/connect-a-bank#redirect-flow). Read `GET /v3/operations/{operation_id}` until it succeeds or fails. On success, open `result.connection.action.widget_url` from the Finicity result. Use `result.connection.source` to check which source routing selected. Operation `succeeded` means the widget URL is ready. It does not mean the client completed account selection or that new accounts have been saved. The returned `con_` is a session placeholder, not a new canonical bank connection ID. Keep your existing `con_FINICITY_*` ID; see [The two id formats](/guides/connection-lifecycle#the-two-id-formats). Read `result.connection.action.expires_at` for the widget's expiry. The separate repair link's lifetime does not apply to this URL. The widget URL grants access to the hosted session. Share it through a trusted channel and keep it out of application logs. In Full Connect, select the existing bank and its add-account option. For example, the existing Chase account list includes **"Add another Chase account"**. Complete any bank authentication or consent steps and share the additional account. The screens and available accounts depend on the institution; an OAuth bank may handle selection on its own site. After the client completes the hosted session, allow time for LedgerSync to save the accounts, then re-read `GET /v3/accounts?client_id=...` and compare account IDs with your pre-session snapshot, as below. Do not wait for the operation's placeholder ID to change. Connection reuse depends on the returned institution login. Linking a different bank login, or routing through a different source, can produce another connection and new account IDs. List accounts for the whole client and inspect their `connection_id` rather than treating the initiate endpoint's name or placeholder as proof that a second connection was created. **Start a session when the client asks to add an account.** Wait for them to complete it instead of repeatedly minting URLs. Completing a session triggers bank aggregation and may fetch statements, so it should not run on a timer. ### When to use reauthorize Use `POST /v3/connections/{connection_id}/reauthorize` when an existing connection needs repair. It takes no body; send `Content-Length: 0`. The response contains `action.reauth_url` and `action.expires_at`. That repair link can be reopened until it expires. Treat it as a bearer link and share it only through a trusted channel. See [Handling failed and disconnected](/guides/connection-lifecycle#handling-failed-and-disconnected) for the response example. For some institutions, including American Express, a Finicity repair can route through MX. Check the repair response's `source`: accounts linked through MX have new connection and account IDs, while the original Finicity connection keeps its old ones. List by `client_id` across sources in that case. This repair behavior is separate from the Full Connect account-addition flow above. ## MX and FDE: a refresh is enough On MX, a refresh triggers a member aggregation, and the aggregation result re-reads the member's whole account list and inserts anything unknown. On FDE, a refresh re-runs extraction and inserts sub-accounts it has not seen. In both cases the new account lands on the **same** connection, so your `con_` id is unchanged and only the new account carries a new `acc_` id. ```bash theme={null} curl -X POST https://api.ledgersyncappv2.com/v3/connections/con_MX_1224/refresh \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Length: 0" ``` You get `202 Accepted` and an `operation_id`. The pull is asynchronous, and `account.refresh.completed` fires when it lands. See [Keeping data fresh](/guides/data-freshness). Do **not** send an MX or FDE client through reauthorize to add an account. It puts them through a re-login for something a plain refresh does on its own, with no client interaction at all. On MX the scheduled aggregation would have picked it up even without the refresh. ## Detect the new account: re-list and diff There is **no v3 account-added webhook**. `connection.active` is not a reliable account-addition signal: it may be absent or emitted again without proving the account set changed. `account.refresh.completed` tells you a refresh finished. Compare account lists to determine whether an account arrived. Before you refresh or open the widget, record the set of `id` values from `GET /v3/accounts?client_id=...`. Page through with `next_cursor` until `has_more` is `false` (see [Pagination](/guides/pagination)). ```bash theme={null} curl "https://api.ledgersyncappv2.com/v3/accounts?client_id=cli_01HXYZ..." \ -H "Authorization: Bearer sk_live_..." ``` Read every page again. Listing by `client_id` alone also catches accounts on a new connection if the bank login or source changed. Use `connection_id` only when you deliberately want to limit the result to that connection. Any `acc_` ID in the new list that is not in your snapshot is a newly observed LedgerSync account. Persist it, then read its transactions and statements as they become available. Repeating the ID comparison does not add the same record twice. A new ID on another connection can represent a bank account you already know, so do not assume it is a newly opened bank account. When to run the second read: * **MX and FDE** have a cue. Re-list when `account.refresh.completed` arrives for that connection. * **Finicity** has no dedicated account-added event. Re-list after the client completes the widget or returns to your app, allowing time for account ingestion. If needed, repeat account reads with backoff for a bounded period; do not start another hosted session as a polling mechanism. ## What does not work * **Using Finicity `/reauthorize` as an account picker.** Connect Fix can end with "No action required" on a healthy connection. * **Refreshing a Finicity connection to discover missing accounts.** The v3 refresh updates accounts already attached to the connection. * **Treating operation success or `connection.active` as proof of addition.** Read and compare accounts after the client completes the session. * **Treating every initiate call as a new stored connection.** The Full Connect flow reuses a matching existing bank login. Choosing a different login can create another connection, so inspect the returned account identities before importing overlapping transaction history. Statuses, reauthorize vs re-initiate, and the two id formats. What a refresh does, and what it does not do, per source. The full event catalog, and what it deliberately does not contain. The hosted link and the initiate to active walkthrough. # Connect with copy and paste: API Setup Guide Source: https://docs.ledgersyncappv2.com/guides/build-with-ai Let Claude or ChatGPT build your connection to real bank accounts. Follow the steps and copy one message. Get your LedgerSync key, copy one message into Claude or ChatGPT, and let it build your bank connection. You can connect your own bank or send a secure link to a client. ```mermaid theme={null} graph LR A[Get your
live key] --> B[Copy one message
into your AI] B --> C[Open a secure link
and choose your bank] C --> D[See transactions
in your software] ``` ## 1. Get your live key Do these steps in order. If you have already completed a step, move to the next one. Click or tap any screenshot to enlarge it. Create an account at [portal.ledgersyncappv2.com](https://portal.ledgersyncappv2.com) using your work email address. Complete the account application when prompted. Our team reviews your application. Watch your email for approval or a request for more information. If a few days pass with no word, email [support@ledgersync.com](mailto:support@ledgersync.com). Once approved, sign in to the portal and open **Build → Overview** in the left menu. Select **Live** at the top right, then click **Request access**. If you already started, click **Continue application**. Full portal screen with Overview selected in the left sidebar, Live at the top right, and Request access highlighted Follow the application steps and add a payment method when prompted. Finish this step so your live application can be reviewed. Our team reviews your request for real bank connections. Watch your email and provide any extra information requested. Check that **Live** is still selected at the top right. Open **Build → API keys** in the left menu. Full portal screen showing API keys highlighted under Build in the left sidebar and the API keys page on the right Under **Create new key**, set **Environment** to **Live**, then click **Create key**. Full API keys screen with its sidebar and header visible, Environment set to Live, and Create key highlighted Copy your key and keep it somewhere private, such as your password manager. It is shown **only once**. If you lose it, create a new one. ## 2. Connect your bank with AI Open a new chat with Claude, ChatGPT, or the AI tool you use to build your app. Open the box below and use its copy button. Paste it into your new chat, but do not press send yet. You do not need to read the text inside the box; it tells the AI what to build. ```markdown connect-my-bank.md theme={null} # LedgerSync API: build my live bank integration Follow this document exactly. Do not invent endpoints, fields, query parameters, or error codes. I am not a programmer. Assume I cannot answer a technical question without help. If you need something only I can give you, ask me for one thing at a time, in plain words, and explain what it is and where I would find it. If I say I do not know, do not stop and do not guess: tell me the options, pick a sensible default, and carry on. You need three things from me. Ask for them one at a time: - My live API key, starting with `sk_live_`. Help me put it directly in my project's secret or environment settings if your tool supports that. - Where my app runs, so you know where to put the code. If I do not know, ask me who looks after my website and tell me to forward your answer to them. - A public web address for receiving updates. If I do not have one, do not stall: set one up yourself if you can, or explain in one sentence what it is and help me get a permanent HTTPS address for the app. Never just ask me for a URL and wait. `localhost` will never work. Keep my live key in server-side secret or environment settings. Never print it back to me, log it, or write it into code you show me. Keep secret files out of version control. Never ask for my bank password. If you can fetch a web page yourself and need more detail than this document carries, read https://docs.ledgersyncappv2.com/llms-full.txt ## What this API does LedgerSync links my users' bank accounts and returns their accounts, balances, transactions and statements through one REST API. Bank passwords are typed into a LedgerSync-hosted page and never reach my servers. ## Base URL and auth Use https://api.ledgersyncappv2.com/v3 from the start, with my live key. Send `Authorization: Bearer ` on every request. JSON over HTTPS. Use a plain HTTP client for this integration. Every response has an `X-LS-Trace-Id` header. Log it. ## How the objects fit together Client (`cli_` + 24 hex) is my record of one end user. -> Connection (`con__`, e.g. `con_FINICITY_41294`), one per linked bank. -> Account (`acc__`) -> Transaction (`txn__`) and Statement (`stmt__`) SOURCE is FINICITY, MX, FDE or PDF, and LedgerSync picks it, never me. ## Set up live updates first Before anyone opens a bank connection link, deploy the HTTPS receiver with the signature checks below, then create the live subscription. To receive webhooks, create a subscription once with `POST /webhooks/subscriptions` with `{"url": "...", "event_types": ["*"]}`. The response carries a `signing_secret` ONCE and no endpoint returns it later. Save it directly in the deployed receiver's server-side secret or environment settings. If you cannot configure those settings, tell me how to save the secret from the response there without pasting it into chat. Never repeat it in your reply, put it in example code, or log it. Restart or redeploy the receiver if needed so it loads the new secret. Confirm the deployed receiver has signature verification enabled before anyone opens a bank link. A local `.env` file alone does not configure a hosted app. If the app needs to send the user back to it after bank sign-in, configure its HTTPS return address. First `GET /settings/redirect-urls`, then add the address to `allowed_redirect_urls` without removing existing entries. `POST /settings/redirect-urls` with `{"allowed_redirect_urls": ["..."]}` sets the complete list. Pass the address as `redirect_url` when creating the connect session. If no return address is needed, omit that field. ## Build this flow 1. `POST /clients` with `{"name": "..."}`. Store the returned `cli_...` id. 2. `POST /clients/{client_id}/connect-session` with `{}` (or with the optional `redirect_url` configured above). Returns `{"url": "...", "expires_at": "..."}`. 3. Send that url to my end user. They open it, find their own bank, sign in, and choose accounts. I build no bank picker and never see their password. 4. Wait for the `connection.active` webhook. Read `data.connection.id` from it. That is the real connection id. Store it against my user. 5. `GET /accounts?client_id=cli_...&connection_id=con_FINICITY_41294` 6. `GET /accounts/{account_id}/transactions?client_id=cli_...&from=2026-01-01` ## Reading data `GET /accounts?client_id=...` and optionally `&connection_id=...` `GET /accounts/{account_id}/transactions?client_id=...&from=YYYY-MM-DD&to=YYYY-MM-DD` `from` includes that day, `to` excludes it. Newest first. Pending rows are always included, so branch on the `pending` field. `GET /accounts/{account_id}/statements?client_id=...` `GET /statements/{statement_id}/download?client_id=...` returns PDF bytes. `client_id` is REQUIRED on every one of those. Leaving it off is a 400. Transaction fields, and this is the complete list: `id`, `account_id`, `external_id`, `amount`, `iso_currency_code`, `date`, `description`, `merchant_name`, `category`, `pending`. `amount` is negative for money out and positive for money in. Account `type` is checking, savings, credit_card, loan, investment or other. `current_balance` is from the account holder's point of view, so a credit card with a balance owed comes back negative. Lists page with `?cursor=...&limit=...` and return `{"data": [...], "next_cursor": "...", "has_more": true}`. Keep passing `next_cursor` back until `has_more` is false. A cursor only works for the exact query that produced it. Errors look like this. Branch on `error.code` and nothing else: {"error": {"code": "not_found", "message": "...", "type": "not_found", "category": "RESOURCE_NOT_FOUND", "trace_id": "..."}} On a 429, honor the `Retry-After` header and back off. ## Verifying webhooks Check every delivery before trusting it: signed = X-LS-Webhook-Timestamp + "." + the raw request body expected = hex(HMAC_SHA256(key = signing_secret, message = signed)) compare expected with the X-LS-Webhook-Signature header, constant-time The key is the whole `whsec_...` string as UTF-8. Do not decode it. Capture the raw body bytes before any JSON parsing touches them. Reject anything whose timestamp is more than 5 minutes old. Reply 2xx within about 10 seconds and queue anything slower: we drop the connection after that and treat it as a failure. We retry for 24 hours, so the same event can arrive twice. Ignore duplicates by `event_id`. ## Rules that will break my integration if you get them wrong 1. Only ever store a connection id shaped `con__`. A `con_` followed by a plain uuid is a temporary placeholder and returns 400 everywhere. The real id comes from the `connection.active` webhook, or from `GET /clients/{client_id}/connections`. Re-checking the operation never turns the placeholder into the real one. 2. `client_id` goes on every accounts, transactions and statements read. 3. Branch on `error.code`. Never on the HTTP status or the message text. 4. Sign `timestamp + "." + body`, never the body on its own. 5. Put an `Idempotency-Key` header on every POST, and reuse the exact same value for every retry of that one request. Retrying without the header, or with a new key, creates a duplicate. A 409 carrying `Retry-After` means the first attempt is still running: wait, then send the same key again. 6. `external_id` and `metadata` on a Client come back empty on every read, so never build a lookup on them. Use the `cli_` id. 7. A statement `download_url` is a partial address. Join it to the API host, not to the `/v3` URL, and send my key with it. 8. The first webhook for a new connection is `connection.requires_action`. There is no `connection.initiated`. 9. There is no event for a newly opened account. Re-read `GET /accounts` and compare ids. 10. Never put my key in anything that runs in a browser or a phone app. ## Only if I ask you for my own bank picker `GET /institutions?q=chase` finds an `ins_...` id, then `POST /clients/{client_id}/connections` with `{"institution_id": "..."}` returns a `202`. Read `GET /operations/{operation_id}` and open `result.connection.action.widget_url` for the user. The real connection id still only arrives on the webhook, exactly as in rule 1. ## Finish with a real bank connection Give me a way to create a secure bank link for each client. Tell me where to click in my app, then have me open a link and connect my own bank. Show me where the returned accounts and transactions appear in my app. If something fails, check your work and explain the next action in plain words. Do not create duplicate clients or subscriptions when resuming an existing integration; inspect its saved configuration first. ``` Add this sentence to the end of the same message. Fill in the blank with the name of your app or where you want to see the transactions: *"I want my clients' bank transactions to show up in \_\_\_\_\_\_. Give me a way to create a bank link for each client myself."* Now press send.

KEEP USING THIS SAME CHAT

Keep all your replies here so the AI remembers your setup. It will ask for one thing at a time and explain where to find it. If you do not know an answer, say so. When it asks for your key, follow its instructions for saving it privately in your app. Never share your bank password in the chat.
If your AI tool can install the code, let it finish and show you where to create a bank link. If someone else looks after your software, ask the AI: *"Write a short message I can forward to the person who looks after my software, explaining what to install."* Send them that message and a link to this page. Continue when they tell you it is ready. Ask the AI where to create your bank connection link. Open the link, type your bank's name, and select it from the results. Full bank connection screen showing the page heading, search field, and a highlighted Chase result Follow the bank's sign-in steps and choose the accounts you want to connect. Enter your bank password only in the bank connection window. Return to your app. Your connected accounts and transactions should appear there once the bank finishes sending them. Ask the AI to show you where to look. For a client's bank, send the link to that client so they can sign in themselves. You never need their bank password. Nothing came back? Go to [If you need help](#3-if-you-need-help).
## 3. If you need help

KEEP USING THIS SAME CHAT

Describe what happened and paste the message below into the chat you used above. Let the AI check its work. If that conversation is gone, start a new one. Paste the setup message from [section 2](#2-connect-your-bank-with-ai), explain that your app is already built, and ask it to inspect the existing setup before changing anything. Then paste the message below. ```markdown fix-my-connection.md theme={null} Check your own code against this list, one line at a time, and fix anything that does not match. - The API host is https://api.ledgersyncappv2.com/v3, with my live key stored only on the server. Never print the key or signing secret. - The live update receiver is reachable over HTTPS and its subscription points to that address. - Every connection id you saved looks like con_SOURCE_NUMBER, never con_ followed by a plain uuid. - client_id is on every accounts, transactions and statements request. - You branch on error.code, not on the HTTP status or the message text. - The webhook signature is built from timestamp + "." + the raw body, not the body on its own, and you reject timestamps over 5 minutes old. - Every POST carries an Idempotency-Key, and a retry of that request reuses the same value instead of generating a new one. - download_url is joined to the API host, not the /v3 URL, and sent with my key. - Nothing looks up a Client by external_id or metadata, because both come back empty. It uses the cli_ id. Tell me in plain words what you changed. ``` Still stuck? Email [support@ledgersync.com](mailto:support@ledgersync.com) with what you were doing and we will help. Do not include your key or bank password. *** Building this by hand instead? The [Quickstart](/guides/quickstart) covers the developer steps. # Check images Source: https://docs.ledgersyncappv2.com/guides/checks List an account's paper checks and download their images. When a bank exposes scanned images of the paper checks written against an account, LedgerSync captures them and serves them alongside that account's transactions. Checks hang off an **account**, so every route below is nested under one: ``` GET /v3/accounts/{account_id}/checks # list GET /v3/accounts/{account_id}/checks/{check_id} # one check GET /v3/accounts/{account_id}/checks/{check_id}/download # the image bytes ``` Check images are **FDE-only** — no other source delivers them. Finicity, MX and PDF accounts have no checks, and asking for theirs returns an empty list rather than an error (see [Sources without checks](#sources-without-checks)). **There is no back image.** LedgerSync captures the *front* of a check only, so a check is exactly one image and there is no `side` field to pass or branch on. ## Before you start Your API key needs the **`read:checks`** scope. A key minted before check support shipped won't have it, and every check route will answer `403 insufficient_scope`. Tick the scope on a new key in the developer portal, or widen the key you already have — the calling key needs `write:api-keys`, and `{id}` is the key's `id` from `GET /v3/api-keys` (not the `sk_test_...` secret): ```bash Add read:checks to an existing key theme={null} curl -X PATCH "https://api-sandbox.ledgersyncappv2.com/v3/api-keys/{id}" \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{"scopes":["read:accounts","read:checks"]}' ``` `PATCH` **replaces** the whole scope list — send every scope the key should keep, not just the new one. The secret itself never changes, so nothing you've already deployed stops working. ## List an account's checks Reads are Client-scoped, so pass `client_id` every time. ```bash List checks theme={null} curl "https://api-sandbox.ledgersyncappv2.com/v3/accounts/acc_FDE_77/checks?client_id=cli_..." \ -H "Authorization: Bearer sk_test_..." ``` ```json Response theme={null} { "data": [ { "id": "chk_FDE_742778", "account_id": "acc_FDE_77", "source": "FDE", "check_number": "1234", "amount": 424.87, "pay_to": null, "description": null, "transaction_date": "2026-07-16", "download_url": "/v3/accounts/acc_FDE_77/checks/chk_FDE_742778/download?client_id=cli_...", "ocr": { "pay_to": "Parkway Business Solutions", "amount": 424.87, "check_number": null, "bank_name": null, "check_date": "2026-07-14", "memo": "App integration consulting" } } ], "next_cursor": null, "has_more": false } ``` Check ids are `chk_FDE_`, source-tagged like every other v3 id. The list paginates with `limit` and `cursor` exactly like the rest of the API — see [Pagination](/guides/pagination). ## Download the image `download_url` on each check is a ready-to-call relative URL — prefer it over building the path yourself: ```bash Download theme={null} curl "https://api-sandbox.ledgersyncappv2.com/v3/accounts/acc_FDE_77/checks/chk_FDE_742778/download?client_id=cli_..." \ -H "Authorization: Bearer sk_test_..." \ -o check.png ``` The response is the raw image, served through the API and auth-scoped to your key — you never handle a storage URL. `Content-Type` is determined by inspecting the image's own magic bytes (`image/png`, `image/jpeg` or `image/gif`; `application/octet-stream` if the bytes match none of those), and `Content-Disposition` names the file `.` to match. Read the format from the `Content-Type` header, not from a filename — stored objects are not reliably named after their true format, which is exactly why this endpoint sniffs the bytes for you. ## Bank-reported vs OCR fields Every check carries two independent readings of the same piece of paper, and **they are deliberately kept apart**: | Field group | Where it comes from | | - | - | | `check_number`, `amount`, `pay_to`, `description` | What the **bank** reported for the check. | | `transaction_date` | The date of the bank transaction the check belongs to. | | `ocr.*` | What **OCR read off the image itself**. | The two routinely disagree, and individual `ocr` fields are often `null` where recognition failed — as `check_number` is above. OCR is best-effort on a photograph of handwriting; the bank's own record is the authoritative one. Treat `ocr` as a hint (for search, prefill, or reconciliation review), never as ground truth for money movement. The whole `ocr` object is omitted when nothing at all was recognised. ## Sources without checks Only FDE accounts can have check images, and the API distinguishes "this source has none" from "you asked wrong": | Request | Result | | - | - | | `GET /v3/accounts/acc_FINICITY_1/checks` | `200` with an empty `data` array — a non-FDE account simply has no checks. | | `GET .../checks/chk_MX_5` | `400 invalid_request` — a `chk_` id that isn't FDE-source can never resolve. | | An FDE account belonging to another Client | `404 not_found` — checks are reachable only under an account you own. | The non-FDE short-circuit is evaluated before ownership, so listing checks on a *non-FDE* account you don't own returns `200` with an empty array rather than `404`. It carries no data either way. ## Try it in sandbox The sandbox **Ledgersync Bank** (`ins_a7397a8d0656e1b7`) returns real check images, so you can exercise all three routes end-to-end without a live bank: Follow [FDE — Ledgersync Bank](/guides/testing#fde-—-ledgersync-bank). It accepts any username and password. Extraction is asynchronous. Checks land once the connection completes — listen for the `connection.active` webhook rather than polling immediately. `GET /v3/accounts?client_id=...&connection_id=con_FDE_...` gives you the `acc_FDE_...` id; pass it to the list route above. Every parameter and response field for the three check routes. # Connect a bank Source: https://docs.ledgersyncappv2.com/guides/connect-a-bank Send a member a link, let them pick and connect their own bank, and receive their data. No institution picker to build. There are two ways to connect a user's bank for the first time. Both finish with the same `connection.active` webhook and the same data behind it. Choose based on how much UI you want to own. Adding an account to an existing Finicity bank uses **Full Connect** for the same client. Call `POST /v3/clients/{client_id}/connections`, open the operation's widget URL, and use the existing bank's add-account option. A matching institution login reuses the stored connection. Follow [Adding a new account](/guides/adding-accounts) for this flow; it uses account-list comparison to detect additions and does not rely on another `connection.active` event. We host everything: bank search, login, MFA. You create a link and send it. **No `institution_id` needed up front.** You render the bank search from `GET /institutions`, then start the connection with the chosen `institution_id`. ## The hosted flow You create a **connect session** for a client and get back a URL. Email it to your member. They open it, find their bank, log in on our page, and you get a webhook the moment it's live. ```mermaid theme={null} sequenceDiagram participant App as Your app participant LS as LedgerSync participant Member participant Bank App->>LS: POST /clients/{id}/connect-session LS-->>App: { url } — a hosted link App->>Member: email the link Member->>LS: opens link, searches & picks their bank Member->>Bank: enters credentials on LedgerSync's page Bank-->>LS: authenticated LS-->>App: connection.active webhook App->>LS: GET /accounts?client_id=&connection_id= ``` Bank credentials are entered **only** on the LedgerSync-hosted page. They never touch your servers, and never come through the API as raw values. ## Three calls, start to finish A Client is just your record of "this is Alice." ```bash theme={null} curl https://api-sandbox.ledgersyncappv2.com/v3/clients \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","name":"Alice","phone":"5551234567"}' ``` Add `"phone"` to store a contact number. To have LedgerSync **text** the member a login invitation as soon as the Client is created, also send `"sms_invitation": true` — a non-blank `phone` is required, or the call returns `400`. ```bash theme={null} curl -X POST \ https://api-sandbox.ledgersyncappv2.com/v3/clients/cli_123/connect-session \ -H "Authorization: Bearer sk_test_..." ``` ```json Response theme={null} { "url": "https://api-sandbox.ledgersyncappv2.com/v3/connect-session?token=...", "expires_at": "2026-07-10T12:00:00Z" } ``` Email `url` to your member. That is the only link they need. Register a webhook once, and we tell you the instant the connection is live. ```json Webhook theme={null} { "type": "connection.active", "data": { "connection": { "id": "con_FINICITY_41294", "client_id": "cli_123", "source": "FINICITY", "status": "active" } } } ``` Now read accounts, transactions, and statements for `con_FINICITY_41294`. **A link you already emailed cannot be revoked through the API today.** The reference documents `DELETE /clients/{id}/connect-session/{sid}`, but the create call returns only `url` and `expires_at`, and there is no endpoint that lists sessions, so there is no supported way to obtain the `sid` it needs. Treat `expires_at` as the only expiry, and keep the window short if that matters to you. ## Prefer to build your own picker? If you already have onboarding UI, skip the hosted page. Call `GET /institutions?q=chase` to render the bank search inside your app, then `POST /clients/{id}/connections` with the chosen `institution_id`. You still get the same widget hand-off for credentials and the same `connection.active` webhook, you just own the search box. Search only returns institutions LedgerSync can currently get data from: at least one of `transactions`, `statements` or `check_images` is `true`. An institution with none of them (for example a bank its provider has taken offline) is left out of search, the hosted page's bank search and the LedgerSync MCP's bank search. It comes back on its own once the nightly catalog refresh sees a provider support it again. `GET /institutions/{id}` still resolves every `ins_...` id. ## Redirect flow When a member finishes on the aggregator widget (FINICITY / MX), the browser needs somewhere to land back in *your* app. Pass a `redirect_url` when you start the connection and we bounce the member back to it with a short-lived `ls_token`. Exchange that token server-side for the canonical connection id. ```mermaid theme={null} sequenceDiagram participant App as Your app participant LS as LedgerSync participant Member Member->>LS: finishes on the widget / hosted page LS-->>Member: 302 to your redirect_url?ls_token=... Member->>App: browser lands on your callback App->>LS: POST /connections/redirect-token/verify { ls_token } LS-->>App: { redirect_url, connection_id, client_id } ``` Allowlist every URL you'll redirect to. The match is **exact** — scheme, host, port, and path must line up with what your app actually uses — and URLs are **https-only** and **per-environment** (a sandbox URL does not carry over to live). Each `POST` overwrites the full set. ```bash theme={null} curl -X POST \ https://api-sandbox.ledgersyncappv2.com/v3/settings/redirect-urls \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{"allowed_redirect_urls":["https://app.acme.com/ledgersync/callback"]}' ``` ```json Response theme={null} { "environment": "sandbox", "allowed_redirect_urls": ["https://app.acme.com/ledgersync/callback"] } ``` Include `redirect_url` in the initiate body. It must exactly match an allowlisted entry or the call is rejected with `400 invalid_request`. ```bash theme={null} curl -X POST \ https://api-sandbox.ledgersyncappv2.com/v3/clients/cli_123/connections \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{"institution_id":"ins_01HZX9CHASE","redirect_url":"https://app.acme.com/ledgersync/callback"}' ``` After the widget completes, the browser is redirected to your `redirect_url` with an `ls_token` query parameter: ``` https://app.acme.com/ledgersync/callback?ls_token=eyJhbGciOiJIUzI1NiJ9... ``` Your callback POSTs the token back to verify it and recover the connection. No API key is required on this call — the token's signature is the trust boundary. ```bash theme={null} curl -X POST \ https://api-sandbox.ledgersyncappv2.com/v3/connections/redirect-token/verify \ -H "Content-Type: application/json" \ -d '{"ls_token":"eyJhbGciOiJIUzI1NiJ9..."}' ``` ```json Response theme={null} { "redirect_url": "https://app.acme.com/ledgersync/callback", "connection_id": "con_FINICITY_41294", "client_id": "cli_123" } ``` `connection_id` is the canonical `con_SOURCE_id` you use everywhere else (accounts, transactions, statements). `ls_token` is a **single-use, signed** token with a short expiry. Verify it **once, server-side** — a second `verify` for the same token returns `400 invalid_request`. Treat landing on your callback as a UI hand-off only; the `connection.active` webhook remains the source of truth that data is ready. The redirect handshake is a browser convenience, not a substitute for webhooks. If a member closes the tab before the redirect fires, you still get `connection.active` — reconcile on the webhook, not on the callback landing. ## Embedding However you launch the connect flow, the hand-off is the same: you open a LedgerSync-hosted URL, the member enters credentials only on our page, and you find out it worked. What differs is *how* you open that URL. ### Redirect or mobile webview (recommended) The default, and the only mode you need for the hosted `connect-session` flow: open the URL as a full-page redirect (web) or in a system/webview (mobile). Don't try to frame it. When you set a `redirect_url`, the member lands back on it after a successful connect with a single-use `ls_token` appended: ``` https://yourapp.com/return?ls_token=eyJhbGci... ``` Verify it server-side with `POST /v3/connections/redirect-token/verify` to read the resulting `connection_id`. `ls_token` arrives **only** on your `redirect_url` query string — never through `postMessage`. Treat it as a one-time credential: verify it once, server-side, then discard it. ### Iframing the MX wrapper page The MX Connect wrapper page (the `widget_url` returned for MX connections) is the **one** hosted page you may iframe. On success it notifies the parent window: ```js theme={null} window.parent.postMessage( { ls: true, type: "ls/mx/memberConnected", connection_id: "con_MX_..." }, "*" ); ``` To receive it, verify the origin and constrain your own page's CSP to our host: ```js theme={null} window.addEventListener("message", (event) => { if (event.origin !== "https://api.ledgersyncappv2.com") return; // ALWAYS check if (!event.data || event.data.ls !== true) return; if (event.data.type === "ls/mx/memberConnected") { // event.data.connection_id is live — now fetch its data } }); ``` ``` Content-Security-Policy: frame-src https://api.ledgersyncappv2.com ``` In sandbox the wrapper is served from `https://api-sandbox.ledgersyncappv2.com` — use that host for both the `event.origin` check and your `frame-src`. The `postMessage` is a **completion signal only** — it carries `connection_id`, never `ls_token`. It is posted with a `"*"` target origin, so the origin check in your listener is mandatory. If you set a `redirect_url`, the wrapper also redirects its own window (never `window.top`) with `ls_token`, exactly like the redirect flow above; the `postMessage` and the redirect are not mutually exclusive. ### Iframe attributes and OAuth The wrapper needs **no Permissions-Policy `allow` attributes** — it uses no camera, microphone, or geolocation. A plain iframe works; `widget_url` is the URL `POST /clients/{id}/connections` hands back: ```html theme={null} ``` It's a first-party, frame-guarded page, so you don't need a `sandbox` attribute. If your CSP forces one, it must permit scripts, forms, same-origin, **and popups** — the widget runs JS, submits the credential form, needs its own origin, and opens OAuth in a new tab: ```html theme={null} ``` **OAuth banks can't complete inside a frame.** Institutions that use OAuth (many large banks) block being iframed, so the wrapper opens the bank's OAuth page in a **new tab** (`window.open`) and the member finishes there. So allow popups if you sandbox, and don't assume the whole flow stays in your frame. Credential-based banks complete fully in-frame. Either way you get the same `ls/mx/memberConnected` `postMessage` (and the `ls_token` redirect, if you set one) once the connection succeeds. Detecting completion **without a redirect** is exactly what the `postMessage` is for: after the origin check, listen for `type === "ls/mx/memberConnected"` and use `event.data.connection_id`. You never need a `redirect_url` to know the member finished. ### What you cannot iframe | Page | Iframe? | Why | | - | - | - | | MX Connect wrapper (`widget_url`) | Yes | Passive; posts a completion signal, navigates its own window | | Hosted pick-and-connect (`/v3/connect-session`) | **No** | Served with `X-Frame-Options: DENY` and CSP `frame-ancestors 'none'` — it's a credential-adjacent action surface | | Finicity Connect (`widget_url`) | Yes | Same passive redirect contract as the MX wrapper | For the hosted `connect-session` page, use a redirect or open it in a new tab — framing it will be blocked by the browser. # Connection lifecycle Source: https://docs.ledgersyncappv2.com/guides/connection-lifecycle How a Connection moves from initiated to active, what each status means, and exactly what to do at every step. 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](/guides/connect-a-bank) for the end-to-end setup, and [Webhooks](/guides/webhooks) for the payloads referenced here. ## The status flow ```mermaid theme={null} stateDiagram-v2 [*] --> initiated: POST /connections initiated --> requires_action: widget_url ready requires_action --> active: user finishes linking requires_action --> failed: bank or aggregator failure active --> requires_action: credentials, MFA or reauthorization needed active --> disconnected: link removed at the source, or deleted active --> failed: bank or aggregator failure failed --> active: recovers on a later refresh failed --> requires_action: user re-links disconnected --> requires_action: user re-links active --> [*] ``` A connection has five statuses: | Status | Meaning | What you do | | - | - | - | | `initiated` | You've kicked off the link; the widget URL isn't ready yet. Brief and transient. | Nothing — poll the operation; you'll see `requires_action` almost immediately. | | `requires_action` | Someone needs to do something: finish the widget, re-enter credentials, answer an MFA challenge, or unlock the account at the bank. | Keep the widget open, or re-open the same `widget_url`. No auto-timeout. | | `active` | Linked and healthy. Accounts, transactions, and statements are flowing. | Read data. Watch capability and refresh events. If the client opened a new account at this bank, see [Adding a new account](/guides/adding-accounts). | | `failed` | The connection cannot be used, and re-entering credentials will not fix it on its own: the bank is unavailable, the bank is refusing automated access, or the aggregator reported a failure. | Show a re-link CTA. **Reauthorize in place** to keep your ids, or re-initiate. A later retry often succeeds with no user action at all. | | `disconnected` | The connection no longer refreshes: the source stopped aggregating it (for example MX reports the member disconnected or closed), you deleted it with `DELETE /v3/connections/{id}`, or, for Finicity and portal connections, LedgerSync's own refresh of it failed. | Show a re-link CTA. **Reauthorize in place** to keep your ids, or re-initiate. | **`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. `POST /v3/clients/{id}/connections` with the institution id returns `202 Accepted` and an `operation_id`. ```bash Initiate theme={null} curl -X POST https://api-sandbox.ledgersyncappv2.com/v3/clients/cli_01HXYZ.../connections \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{"institution_id":"ins_3c97c5da90335a04"}' ``` ```json 202 Accepted theme={null} { "operation_id": "op_3d9c7f1e05b84a26bb4e8107c2fa9d53", "status": "queued", "poll_url": "/v3/operations/op_3d9c7f1e05b84a26bb4e8107c2fa9d53", "estimated_seconds": 5 } ``` There is no `source` field. v3 routes to Finicity, MX, or FDE server-side from the institution catalog. `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. ```json Operation succeeded theme={null} { "id": "op_3d9c7f1e05b84a26bb4e8107c2fa9d53", "type": "connection.initiate", "status": "succeeded", "result": { "kind": "connection_with_action", "connection": { "id": "con_9f1c2b7a-3e4d-4a11-8c2f-77e9b0d15a42", "client_id": "cli_9f2a4c1b8e07d3a5f6b20c14", "source": "FINICITY", "status": "requires_action", "action": { "kind": "widget_url", "widget_url": "https://connect.ledgersyncappv2.com/w/...", "expires_at": "2026-09-03T15:22:08Z" } } } } ``` `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_` placeholder. Do not wait on this endpoint for the id to change; it never does. 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](/guides/connect-a-bank) for widget details. 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__` (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](/guides/build-with-ai#4-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: | `action.kind` | Fields | Status | | - | - | - | | `widget_url` | `widget_url`, `expires_at` | **The only kind emitted on a `requires_action` connection.** Open the URL in a browser, iframe, or webview. | | `reauthorize` | `reauth_url`, `expires_at` | Returned on demand by [`POST /connections/{id}/reauthorize`](#handling-failed-and-disconnected) to repair an existing connection. Not gated on status, but Finicity Connect Fix may report "No action required" on a healthy connection. Use [Full Connect to add Finicity accounts](/guides/adding-accounts#finicity-open-full-connect-for-the-existing-client). Not emitted on a `requires_action` connection's own `action`. | | `mfa_challenge` | `challenge_id`, `questions[]` | Reserved for a future release — not emitted today. There is intentionally no inline MFA-answer flow. | 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. ### Reauthorize an existing connection (recommended for repair) `POST /v3/connections/{connection_id}/reauthorize` repairs the **existing** connection. It returns a `reauthorize` action with a LedgerSync-hosted `reauth_url`: ```json theme={null} { "connection_id": "con_FINICITY_41294", "source": "FINICITY", "action": { "kind": "reauthorize", "reauth_url": "https://connect.ledgersyncappv2.com/...", "expires_at": "2026-07-27T12:00:00Z" } } ``` 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](/guides/adding-accounts#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](/guides/connect-a-bank) — `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](/guides/adding-accounts) 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. 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](/guides/checks) 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. 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. ```bash Read after a refresh theme={null} curl "https://api-sandbox.ledgersyncappv2.com/v3/accounts?client_id=cli_01HXYZ...&connection_id=con_FINICITY_41294" \ -H "Authorization: Bearer sk_test_..." ``` 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: | `type` | What it is | | - | - | | `checking` | Everyday transaction / demand-deposit account. | | `savings` | Savings, money-market, CD. | | `credit_card` | Revolving credit card. | | `loan` | Term loan, mortgage, auto loan, line of credit. | | `investment` | Brokerage, IRA, 401(k), and other holdings accounts. | | `other` | Anything the source didn't classify into the above. | ### 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: | Side | Types | | - | - | | Asset | `checking`, `savings`, `investment` | | Liability | `credit_card`, `loan` | | Ambiguous | `other` | 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: ```json theme={null} { "id": "acc_FINICITY_84213", "type": "credit_card", "current_balance": -14381.66, "last_refreshed_at": "2026-09-04T06:12:03Z", "liabilities": { "credit_limit": 65000.00, "available_credit": 50596.54, "statement_balance": -14381.66, "minimum_payment": 143.00, "payment_due_date": "2026-09-24" } } ``` **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: | Source | What it reports | | - | - | | Finicity | All five fields, where the bank publishes them. Minimum payment 91%, due date 91%, statement balance 90%, available credit 77%, credit limit 76%. | | MX | All five fields, where the bank publishes them. Minimum payment 66%, due date 64%, available credit 64%, credit limit 61%. The statement balance is reported since 2026-09-23 and is not measured yet. | | Portal connections (FDE) | None, so `liabilities` is always null | | Uploaded statements | None, so `liabilities` is always null | 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` | `type` | | - | - | | `money_market`, `certificate_of_deposit` | `savings` | | `mortgage`, `line_of_credit` | `loan` | | `ira`, `roth`, `rollover`, `plan_401k`, `plan_529`, `investment_tax_deferred` | `investment` | | `bank` | `checking` | | `other_liability`, `rewards`, `cash`, `prepaid` | `other` | | `unknown` | `other` | `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](#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. The full initiate → widget → active walkthrough. Every lifecycle event, its payload, and how to verify signatures. Sandbox banks, MFA/OAuth variants, and credentials. The error envelope and how to branch on `code`. # Keeping data fresh Source: https://docs.ledgersyncappv2.com/guides/data-freshness How current the balances and transactions from GET /accounts are, how to check, and how to force a refresh when you need fresher-than-daily data. Each Account exposes a `current_balance`, plus its transactions through `GET /accounts/{id}/transactions`. This guide explains how fresh that data is, how to tell, and how to pull fresher data on demand. For what `current_balance` means and how it is signed, see [Asset vs liability](/guides/connection-lifecycle#asset-vs-liability). `available_balance` is reported by MX only, and only for some accounts: it is common on checking and rare on credit cards. Read `realized_capabilities.available_balance` rather than assuming it is present. ## How fresh is `GET /accounts`? The balances you read are a **cached snapshot from the last successful refresh** — not a live call to the bank on every request. LedgerSync refreshes every **active** connection automatically about **once a day**, so under normal conditions the numbers are less than 24 hours old. Every Account carries a **`last_refreshed_at`** timestamp — the moment its data was last refreshed. Read it to decide whether you already have fresh-enough data or need to force a refresh. For most use cases you do **not** need to call `/refresh`. The daily auto-refresh keeps active connections current, and `GET /accounts` always returns the latest snapshot. Reach for `/refresh` only when you need fresher-than-daily data. ## How far back does the history go? Freshness is one axis; depth is the other. Every Account carries an **`oldest_transaction_date`**: the oldest transaction we actually hold for it. It is measured from the transactions themselves, not copied from what the bank or the aggregator says it has. So it is exactly the minimum `date` you get if you page that account's full history, and the two can never disagree. ```json theme={null} { "id": "acc_FINICITY_84213", "name": "Business Checking", "type": "checking", "last_refreshed_at": "2026-09-04T06:12:03Z", "oldest_transaction_date": "2026-05-28" } ``` ### What we request at connect | Source | History requested on first connect | | - | - | | Finicity | The last **90 days** | | MX | The last **730 days**. MX returns what it holds, which is often less | | Portal connections (FDE) | Not windowed. You get what the extraction run returns | | Uploaded statements | Exactly the statements that were uploaded | After that first pull, history grows **forward** with each refresh. It does not reach further back, so an account linked a year ago holds about a year. What actually arrives inside the requested window is up to the institution, and it is not something we can tell you before the connection exists. That is what the field is for: read `oldest_transaction_date` per account instead of assuming a window. It is `null` when we hold no transactions for that account yet, which is normal between linking a connection and its first transaction pull finishing. `oldest_transaction_date` can move **backwards**. It is a fact about what we hold right now, and a backfill landing earlier transactions lowers it. Re-read it rather than caching it as a fixed property of the account. Like `Transaction.date`, it is a calendar day (`YYYY-MM-DD`) with no time component, and it is truncated by the same code, so comparing the two is safe. ## Forcing a refresh When you need up-to-the-moment data (a user tapped "refresh", you're reconciling something time-sensitive), call `POST /connections/{id}/refresh`: ```bash theme={null} curl -X POST https://api.ledgersyncappv2.com/v3/connections/con_FINICITY_41294/refresh \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Length: 0" ``` It returns **`202 Accepted`** with an `operation_id` and starts the refresh **asynchronously**, so balances are **not** updated by the time the call returns. All three connectable sources (Finicity, MX, and FDE) answer this call the same way: `202` now, the outcome later on a webhook. A refresh keeps the accounts you already have current. It is **not** uniformly a discovery call. On MX and FDE it also picks up an account the client opened after linking; on Finicity it never does, because a Finicity refresh only touches the accounts already attached to the connection. See [Adding a new account](/guides/adding-accounts). You get `202` and an `operation_id`. It fires when the fresh data is in (or `account.refresh.failed` on a terminal failure). Listen for the webhook — don't poll `GET /accounts` in a loop. The snapshot now reflects the fresh pull and `last_refreshed_at` has advanced. See [Connection lifecycle](/guides/connection-lifecycle) and [Webhooks](/guides/webhooks) for the `account.refresh.completed` / `account.refresh.failed` payload shapes, and the **Refresh a connection** endpoint in the API Reference for the full contract. ### What the operation tells you The webhook is the signal to build on, but the `operation_id` is a real fallback when you can't run a webhook handler: `GET /v3/operations/{operation_id}` reaches a terminal `succeeded` or `failed`, and always terminates even if the events never arrive. A refresh is **one aggregation at the bank but reports per sub-account**, so the operation stays `queued` until every account on the connection has reported. That matters for how you read the verdict: * **`succeeded`** - every account refreshed. `result.accounts[]` lists them, and `result.accounts_refreshed` counts them. * **`failed` with `refresh_partially_failed`** - the aggregation ran, but at least one account didn't come back. This is deliberately a failure rather than a partial success, so branching on `status` alone can't quietly ship a connection that's missing an account's transactions. `error.accounts[]` breaks it down per account. * **`failed` with `refresh_timed_out`** - nothing reported in time. The refresh may still have run; check `last_refreshed_at`. * **`failed` straight away, FDE only** - the bank answered the refresh with a security question (`mfa_required`: send the user a fix link from `POST /connections/{id}/reauthorize`), or refused it (`source_internal_error`, with the FDE status in `source_diagnostic_code`). No run will report, so no webhook fires and nothing more arrives: read the operation. Expect a couple of minutes end to end, not seconds: the bank aggregation alone typically runs about a minute, the per-account transaction pull follows it, and the event carrying the result adds its own hop. The `estimated_seconds` on the `202` is a best-effort floor, not a deadline. An MX refresh that finds nothing new takes about three minutes to report: MX sends no notice for a sync that brought no new or changed transactions, so LedgerSync confirms with MX that the sync is over, allowing a short grace period for a late notice, before it reports the refresh: `succeeded` when the connection is healthy, `failed` when MX shows an error. See the [connection refresh error codes](/guides/errors#connection-refresh) for the full list. MX aggregates on its own schedule, several times a day, and refreshes through this same async webhook flow. A refresh you request triggers an MX aggregation, and the result arrives on the `account.refresh.completed` webhook, so the *shape* of the call matches Finicity and FDE. What is not the same is what comes back. An MX aggregation re-reads the member's full account list, so it also picks up accounts the client opened after linking. FDE does the same on its extraction run. A Finicity refresh does not, and no amount of refreshing will surface an account the client never shared. See [Adding a new account](/guides/adding-accounts). ## Refreshing many connections at once You can refresh all of a customer's banks in one batch - each connection is a separate `POST /connections/{id}/refresh` call, and that's well within the request-rate limit (live keys allow 60 requests/minute). Refresh also carries a **daily budget of its own**, separate from that request rate, because every refresh is a real aggregation at the institution rather than just another API call. Exceed it and the call returns `429 rate_limit_exceeded` without queueing anything or touching the bank. * **Fire the batch, then wait on the `account.refresh.completed` webhooks** — don't poll operations or `/accounts` in a tight loop. * **Watch the `X-RateLimit-*` headers** and back off before you hit zero — see [Rate limits](/guides/going-live#rate-limits). * Each refresh triggers a real aggregation at the bank, so space large batches out rather than firing hundreds at once. * **Don't put `/refresh` on a timer.** Active connections already refresh once a day on their own, and our upstream provider's terms don't permit client apps automating calls to the refresh services. Refresh when something asked for it - a user tapped refresh, or you're reconciling something time-sensitive. A refresh is asynchronous and eventually-consistent. Don't block a user flow waiting for `/refresh` to finish — show the cached balance immediately (with `last_refreshed_at`), and update your UI when the webhook arrives. # Errors Source: https://docs.ledgersyncappv2.com/guides/errors The error envelope, the full error-code catalog, and exactly what to do for each one. Every error we return has the same shape and a stable, machine-readable `code`. Branch on the `code`, show your user something friendly, log the trace id, and consult the catalog below for the exact action. That's the whole game. ## The error envelope Every error response, validation, auth, not found, rate limit, or a bank-side failure, carries the same JSON body under `error`: ```json theme={null} { "error": { "code": "not_found", "message": "No client matches id 'cli_deadbeef'.", "doc_url": "https://portal.ledgersyncappv2.com/errors/not_found", "type": "not_found", "category": "RESOURCE_NOT_FOUND", "is_user_actionable": false, "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736" } } ``` `code`, `message`, `doc_url`, `type`, `category` and `is_user_actionable` are on every error. The rest appear only when they apply. | Field | What it's for | | - | - | | `code` | Stable, machine-readable. **Branch on this.** | | `message` | Human-readable explanation. Good for logs; safe for developers, not always for end users. | | `type` | Stripe-style class derived from HTTP status: `auth_error`, `invalid_request`, `not_found`, `idempotency_error`, `rate_limit_error`, `api_error`. Coarse grouping only. | | `category` | Plaid-style class by origin: `AUTH_ERROR`, `INSTITUTION_ERROR`, `CAPABILITY_UNAVAILABLE`, `CONNECTION_ERROR`, `RATE_LIMIT`, `INVALID_REQUEST`, `RESOURCE_NOT_FOUND`, `PLATFORM_ERROR`. Orthogonal to `type`; still branch on `code`, not this. | | `is_user_actionable` | `true` when the **end user** (not you) can resolve it, e.g. by re-authenticating in the widget. | | `doc_url` | Deep link to the page for this specific code. | | `trace_id` | The id of this exact request in our logs. Quote it in support tickets. | | `param` | On every `400` or `413` that is about one request field, the field that tripped the check: `validation_failed` and `invalid_request` alike (dot-separated path for nested fields, e.g. `client.email`, with array positions in brackets, e.g. `items[1].amount`). The same name also appears inside `message`, as `Field 'cursor': ...`, so `message` remains a valid fallback. Absent on errors that are not about a field (auth, not found, conflicts, upstream faults). When the body itself could not be parsed there is no one field to blame: most endpoints omit `param`, and the few that parse the body themselves report the literal `body`. A key inside a free-form object such as `metadata` is your own, not part of our schema, so `param` names the field that contains it rather than the key. Before 2026-09-11 only schema `validation_failed` carried it; `invalid_request` named the field inside `message` only. | | `errors[]` | Schema validation only: on `validation_failed` raised by request-body schema checks, one `{ param, message, code }` entry per failing field, so a body with several bad fields reports all of them at once. Not present on `invalid_request`. | | `source_diagnostic_code` | On bank-side errors, the raw upstream code (`FIN-103`, `MX-DENIED`, `FDE-INTERNAL`). For **support triage, not control flow**. | Branch on `code`, never on `message` and never on `type`. We reword messages over time, and `type` is deliberately coarse, several very different `code`s share `type: api_error`. The `code` is the contract. ## HTTP status, at a glance | Status | Meaning | Typical fix | | - | - | - | | `400` | Validation / malformed request | Read `error.param` (the offending field) and `error.message` (why), fix the field, retry. Don't retry unchanged. | | `401` | Auth: missing, malformed, or wrong key/signature | Send `Authorization: Bearer sk_test_...`; check the key matches the environment. | | `403` | Authenticated but not allowed (scope, unapproved access, billing) | Fix the key's scope or finish the portal step named in the message. | | `404` | Not found or not visible to your key | Confirm the id and its environment. | | `405` | Right route, wrong HTTP method | Use the method the API reference documents. | | `409` | Conflict: a reused `Idempotency-Key`, or a stale `row_version` | For a stale `row_version`, re-fetch then retry. For an idempotency conflict see the note below — when `Retry-After` is present, retry with the **same** key. | | `422` | Accepted but couldn't be processed (statement extraction) | Read the operation's `error.message`; fix the input. | | `429` | Rate limit against **your** quota | Back off with jitter; honor `Retry-After`. | | `5xx` | Our side or the bank's side | Retry with backoff; escalate with the trace id. On a `POST`, retry with the **same** `Idempotency-Key` — see the note below. | ## Error-code catalog Every code the API can return. The catalog is also browsable at [portal.ledgersyncappv2.com/errors](https://portal.ledgersyncappv2.com/errors), and each `error.doc_url` deep-links to one entry. ### Authentication & authorization | Code | HTTP | When it fires | What to do | | - | - | - | - | | `missing_auth_headers` | 401 | No `Authorization` header. | Add `Authorization: Bearer sk_...`. | | `unknown_api_key` | 401 | Bearer token matches no key (typo, wrong env, revoked). | Check the value and that the key matches the base URL. | | `revoked_api_key` | 401 | Key was valid but has been revoked/rotated. | Mint a new key in the portal, update config. | | `invalid_signature` | 401 | HMAC signature didn't match the computed one. | Sign over the **raw** bytes you send; confirm the current secret. Prefer our signing helper. | | `invalid_timestamp` | 401 | `X-LS-Timestamp` is more than 5 min off our clock. | Fix the machine's clock (NTP), resend. | | `replayed_nonce` | 401 | `X-LS-Nonce` was already used. | Generate a fresh 16+ byte random nonce per request. | | `insufficient_scope` | 403 | Key authenticated but lacks the scope for this operation. | Grant the scope named in `message`, or mint a key with it. | | `access_request_not_approved` | 403 | Minting a key for an env whose access request isn't `APPROVED`. | Finish/resubmit the access request for that env in the portal. | | `billing_not_configured` | 403 | Live request with no billing subscription linked. | Add a payment method in the portal (Activate Live → Payment). | | `access_not_active` | 403 | LedgerSync MCP only. The signed-in user has no working LedgerSync account yet: never applied, the sign-up is not finished, the first application is still under review or waiting for an answer, or it was not approved, closed or turned off; or the connector is not switched on for the account. For a firm that signs in with its LedgerSync app login: the signed-in user is not the firm owner, the login or the firm's account is locked, LedgerSync is not active for the firm, or connecting AI assistants is turned off for the firm. | Follow the `next_step` the AI reads out. Firms sign in on the LedgerSync sign-in page with their LedgerSync app login: [connect again](/ai/troubleshooting#connect-again) and sign in. Developers apply in the portal or wait for the review. Otherwise email support. | | `connector_app_pending_approval` | 403 | LedgerSync MCP only. The AI app is not supported yet. Claude and ChatGPT are approved automatically; other apps are blocked. | Use Claude or ChatGPT, or email support. | | `connector_access_revoked` | 403 | LedgerSync MCP only. The AI app was disconnected from this account. Also sent when the firm owner changed the LedgerSync password or turned off two-step sign-in after the AI app connected. | [Connect again](/ai/troubleshooting#connect-again) and sign in. | | `firm_under_review` | 403 | LedgerSync MCP only. A new firm is still being reviewed by our team. It can add clients; bank connections open after the review. | Add clients now and connect banks after the review. | The [LedgerSync MCP](/ai/developers) also answers with `access_request_not_approved` when the user already has a LedgerSync account and their application for that environment is under review, needs more information or has not finished its payment step, or when their access for that environment was turned off (a first application still under review answers `access_not_active`), and with `billing_not_configured` when live access was approved but no card is on file (the payment step was not completed). Its errors reach the AI app as a tool error carrying the same `code`; access and rate-limit errors also carry a `next_step` sentence the AI reads out to the user. So do the [desktop app linking](#desktop-app-linking-ledgersync-mcp-only) errors. ### Request problems | Code | HTTP | When it fires | What to do | | - | - | - | - | | `validation_failed` | 400 | A field failed schema validation, or a hand-rolled check classified as schema-level (an unknown webhook `event_types` entry, an unsupported `source`). `error.param` names the field; `error.errors[]` additionally lists every failing field on request-body schema checks. | Fix the named field(s), resubmit. | | `invalid_request` | 400 | Rejected for a non-schema reason (bad state transition, unsupported source, malformed cursor, an id with the wrong prefix, a field of the wrong type). `error.param` names the offending field, and `message` repeats it as `Field 'cursor': ...`. If the body is not valid JSON at all, no single field can be named: `param` is either absent or the literal `body`. | Fix the field named in `param`, resubmit. | | `method_not_allowed` | 405 | Right route, wrong HTTP method. | Use the documented method; regenerate clients from the spec. | | `idempotency_conflict` | 409 | Either you reused an `Idempotency-Key` for a different body/path, or the original request carrying that key is still running (that case carries `Retry-After`). | With `Retry-After`: wait, then retry with the **same** key. Without it: send the original body unchanged to replay, or use a fresh key for a genuinely new request. | | `version_conflict` | 409 | Write carried a stale `row_version` (someone else moved the row). | Re-fetch to get the current `row_version` and state, then retry if still needed. | | `read_only_client` | 403 | The target Client is read-only — an aliased/linked pre-existing v2 client. Writes and connection mutations are rejected. | Only mutate native `cli_` clients; treat aliased linked clients as read-only. | | `billing_managed_externally` | 409 | A billing action was attempted on a customer whose billing is managed outside the API. | Manage that customer's billing through the configured external flow, not the API. | | `not_found` | 404 | No resource matches the id, or it belongs to another customer. | Verify the id (stored the `cli_...`, not your `external_id`?). | | `statement_extraction_failed` | 422 | A `statement.extract` operation finished `failed` (bad scan, not a statement, corrupt pages). Arrives **inside the polled operation's `error`**, not as a direct HTTP response. | Fix the document (clearer scan, all pages, real bank PDF) and re-`POST /v3/statements/extract`. | **Retry a `POST` only with the same `Idempotency-Key`.** Send the header on every `POST /v3/clients`, `POST /v3/clients/{id}/connections` and `POST /v3/connections/{id}/refresh`, and reuse the same value for every retry of that one logical request. We replay the original response, including a `4xx` or `5xx`, for 24 hours, and a replay carries `Idempotent-Replayed: true`. Retrying **without** the header, or with a fresh key, is a brand-new request: it creates a second client or a second bank-connection session. A `409` is not a reason to mint a new key — if it carries `Retry-After`, the first attempt is still running, so wait and send the same key again. ### Rate limit | Code | HTTP | When it fires | What to do | | - | - | - | - | | `rate_limit_exceeded` | 429 | More requests than the per-key limit. `X-RateLimit-Reset` / `Retry-After` describe the window. Also returned by `POST /connections/{id}/refresh` when the customer's **daily refresh budget** is spent - a separate budget from the request-rate limit, because every refresh is a real aggregation at the bank. | Back off and retry after `Retry-After`. For refresh specifically, spread refreshes out rather than retrying straight away; active connections already refresh once a day on their own. | ### Connection refresh Outcomes of `POST /connections/{id}/refresh`. Like `statement_extraction_failed`, these arrive **inside the polled operation's `error`** rather than as the HTTP response - the call itself answers `202` and the verdict lands later. A refresh is one aggregation at the bank but reports **per sub-account**, so the operation only turns terminal once every account on the connection has reported. When it does, `error.accounts[]` lists each account with its own `status` (`succeeded` / `failed` / `pending`) and, where it failed, its own nested `error`. | Code | HTTP | When it fires | What to do | | - | - | - | - | | `refresh_partially_failed` | 502 | The aggregation ran, but at least one account on the connection did not come back. `error.accounts[]` says which. | Read `accounts[]`. Treat the succeeded accounts as fresh, and handle each failed one by its nested `error.code` like any other source error. | | `refresh_timed_out` | 502 | No account reported an outcome inside the window. **Not** a claim that the refresh failed - it may well have run. | Check the connection's `last_refreshed_at` to see whether data actually moved, and trust the `account.refresh.completed` webhook, which is the authoritative signal. | | `refresh_outcome_not_observable` | 502 | The refresh was dispatched, but this customer is not configured to emit per-account events, so the outcome cannot be confirmed through the API. | Check `last_refreshed_at` for whether data moved, and contact support so event delivery can be enabled. Retrying will not clear it. | | `no_refreshable_accounts` | 422 | The connection holds no account a refresh could touch, so nothing was queued and no event will ever arrive. Resolved immediately rather than left to time out. | Reconnect the connection to restore account access, or delete it if the end user no longer shares these accounts. | `refresh_timed_out` and `refresh_outcome_not_observable` look alike and mean different things. The first is "the bank or our pipeline was slow", and retrying can help. The second is a configuration gap on our side that retrying will never clear - chasing the institution over it would send you somewhere there is nothing to find. ### Server | Code | HTTP | When it fires | What to do | | - | - | - | - | | `internal_error` | 500 | Something broke on our side (usually transient). | Retry once with backoff, reusing the same `Idempotency-Key` on a `POST`; escalate with the `trace_id`. | | `upstream_unavailable` | 502 | A service v3 depends on (the main LedgerSync backend behind the bridge) is unreachable. Distinct from `source_internal_error`, our platform leg, not the aggregator. | Retry with exponential backoff; escalate with the `trace_id` if it persists. | ### Connection & source errors (the bank side) These come back on connection operations, most often **inside the polled operation's `error`**, with the raw upstream code in `error.source_diagnostic_code`. All carry `type: api_error` and HTTP `502`, so **you must branch on `code`, not on `type`**. They group cleanly by the action they require: The wire `error.category` is too coarse to branch on. `CAPABILITY_UNAVAILABLE` covers `agreement_required`, `statements_not_supported`, **and** `fde_unsupported`; `CONNECTION_ERROR` covers both `mfa_required` and `reauthorization_required`. Always switch on `error.code`. **Send the end user back through the widget (re-authenticate):** | Code | When it fires | | - | - | | `credentials_invalid` | Bank rejected the login (changed password, re-enrolled creds, too many failed sign-in attempts). `FIN-103/185/948`, `MX-DENIED/PREVENTED`. | | `mfa_required` | Bank is challenging for a second factor, or the last MFA answer was wrong. `FIN-187`, MX `CHALLENGED/REJECTED`, FDE `USER_ACTION`. | | `mfa_expired` | User didn't answer the MFA challenge in time. MX `EXPIRED`. | | `reauthorization_required` | Saved session invalid; bank wants re-consent, or the user has to act on the bank's site first. MX `IMPAIRED/IMPORTED/IMPEDED/DISABLED`, and MX `DISCONNECTED/CLOSED` on a `disconnected` connection. | | `agreement_required` | Bank needs a new agreement/consent accepted. `FIN-38006`. | **Don't re-auth, retry with backoff (credentials are fine):** | Code | When it fires | | - | - | | `institution_unavailable` | Bank is down/impaired/transient. Finicity `931/946/990-998`, MX `DEGRADED/FAILED`, FDE `INSTITUTION_DOWN`. Show "your bank is having issues," retry minutes-to-hours later. | | `source_rate_limited` | Aggregator/bank throttled back-to-back pulls. `FIN-947`. Back off; don't tighten your loop. | | `source_internal_error` | Aggregator returned a generic internal failure. Untranslated `FIN-*`, `*-INTERNAL`, `MX-UNKNOWN/DISCONTINUED` and any MX status LedgerSync does not recognize. Small retry budget (2–3), then escalate. | **Don't retry, the capability isn't there:** | Code | When it fires | | - | - | | `statements_not_supported` | Institution doesn't expose statements. `FIN-6004`. Hide "view statements"; transactions/balances still work. | | `fde_unsupported` | FDE-backed connection doesn't expose this capability. Gate the feature by connection source. | **User must act at the bank (not in your widget):** | Code | When it fires | | - | - | | `account_locked_by_institution` | Bank locked the account (too many attempts / fraud hold). `FIN-109`, `MX-LOCKED`. Tell the user to unlock with their bank; re-auth won't help. On an MX connection the status reads `requires_action` with `is_user_actionable: false`. | **The firm's LedgerSync desktop app is needed (FDE banks only, the bank was not called):** Some banks LedgerSync reaches through FDE need the LedgerSync desktop app on the firm's computer for refreshes. Those institutions and connections carry `requires_desktop_app: true`. Both codes below are `CONNECTION_ERROR` with `is_user_actionable: true`. A refresh stopped by either one fails at once and does not count against the daily refresh budget. | Code | When it fires | | - | - | | `desktop_app_required` | The bank needs the desktop app for refreshes, and the app is not connected right now. `FDE-LSDA_UNAVAILABLE`. A refresh fails at once, and the connection reads `requires_action` with this code. The account holder has nothing to re-enter: the firm opens the desktop app on the computer it is set up on, then refreshes again. | | `desktop_app_route_choice` | The refresh is waiting for someone to choose whether LedgerSync reaches the bank through the desktop app. `FDE-FORM_PROXY_SELECTION`. Arrives inside the refresh operation's `error`. Send the account holder the connection's fix link from [`POST /v3/connections/{id}/reauthorize`](/guides/connection-lifecycle#handling-failed-and-disconnected); they choose on that LedgerSync page, then refresh again. | ### Desktop app linking (LedgerSync MCP only) These come only from the [LedgerSync MCP](/ai/developers)'s `ledgersync_pair_desktop_app` tool, which links the LedgerSync desktop app with the code the app shows. Each one reaches the AI app as a tool error with a `next_step` the AI reads out. None of them repeats the code. | Code | HTTP | When it fires | What to do | | - | - | - | - | | `pairing_code_invalid` | 400 | The code is not 7 letters or digits, or it is unknown, expired or already used. | Get a new code in the desktop app (**Settings**, **Connect with: AI chat**) and try again with it. | | `desktop_app_session_closed` | 409 | The code is real, but the desktop app that showed it is not open anymore. | Open the desktop app, get a new code and try again. | | `desktop_service_unavailable` | 502 | The desktop app service failed, timed out or could not be reached. Nothing the user did is wrong. | Try again in a few minutes. If it keeps failing, email support. | | `pairing_rate_limited` | 429 | Too many tries to link the desktop app for this account: 10 an hour and 20 a day (UTC), counted whether the code works or not. The message says how many minutes to wait. | Wait that long, then try again with a new code. | ## A handling pattern The same steps work in any language: check the status, parse the envelope, branch on `code`, log the trace id. ```python errors.py theme={null} import requests resp = requests.post( "https://api-sandbox.ledgersyncappv2.com/v3/clients/cli_01HXYZ/connections", headers={"Authorization": "Bearer sk_test_..."}, json={"institution_id": "ins_3c97c5da90335a04"}, ) trace_id = resp.headers.get("X-LS-Trace-Id") if resp.ok: operation = resp.json() else: err = resp.json()["error"] log.warning("LedgerSync code=%s status=%s trace=%s", err["code"], resp.status_code, trace_id) code = err["code"] if code in ("credentials_invalid", "mfa_required", "mfa_expired", "reauthorization_required", "agreement_required"): send_user_back_through_widget(...) # re-auth elif code in ("institution_unavailable", "source_rate_limited", "source_internal_error", "upstream_unavailable"): retry_with_backoff(...) # credentials are fine elif code == "not_found": ... # fix the id elif resp.status_code == 401: ... # bad key — don't retry elif resp.status_code == 429: backoff_and_retry(...) # see Rate limiting ``` ```javascript errors.js theme={null} const resp = await fetch( "https://api-sandbox.ledgersyncappv2.com/v3/clients/cli_01HXYZ/connections", { method: "POST", headers: { Authorization: "Bearer sk_test_...", "Content-Type": "application/json", }, body: JSON.stringify({ institution_id: "ins_3c97c5da90335a04" }), } ); const traceId = resp.headers.get("X-LS-Trace-Id"); if (!resp.ok) { const { error } = await resp.json(); console.warn(`LedgerSync code=${error.code} status=${resp.status} trace=${traceId}`); const reauth = ["credentials_invalid", "mfa_required", "mfa_expired", "reauthorization_required", "agreement_required"]; const retry = ["institution_unavailable", "source_rate_limited", "source_internal_error", "upstream_unavailable"]; if (reauth.includes(error.code)) sendUserBackThroughWidget(); else if (retry.includes(error.code)) retryWithBackoff(); else if (error.code === "not_found") { /* fix the id */ } else if (resp.status === 401) { /* bad key — don't retry */ } else if (resp.status === 429) backoffAndRetry(); } ``` ## Rate limiting A `429` (`rate_limit_exceeded`) just means slow down. Use **exponential backoff with jitter**, and honor `Retry-After` when present. ```python backoff.py theme={null} import time, random def call_with_backoff(do_request, max_attempts=5): for attempt in range(max_attempts): resp = do_request() if resp.status_code != 429: return resp wait = float(resp.headers.get("Retry-After", (2 ** attempt))) + random.random() time.sleep(wait) return resp # give up; log the trace id and escalate ``` ```javascript backoff.js theme={null} async function callWithBackoff(doRequest, maxAttempts = 5) { let resp; for (let attempt = 0; attempt < maxAttempts; attempt++) { resp = await doRequest(); if (resp.status !== 429) return resp; const retryAfter = Number(resp.headers.get("Retry-After")) || 2 ** attempt; await new Promise((r) => setTimeout(r, retryAfter * 1000 + Math.random() * 1000)); } return resp; // give up; log the trace id and escalate } ``` The same backoff is the right response to `5xx` and the retryable bank-side codes above. Only non-`429` `4xx` errors mean "don't retry until you change something." ## The trace id Every response, success or error, includes an `X-LS-Trace-Id` header (also mirrored as `error.trace_id` in error bodies): ``` X-LS-Trace-Id: 7b3f9c2a1e4d40a8b6c0f1e2d3a4b5c6 ``` Log it on **every** request, even successful ones. When something looks wrong downstream ("this account has no transactions"), the trace id from the original read is what we need. ## Getting help When you file a ticket, include: the `X-LS-Trace-Id` (or `error.trace_id`), the `error.code`, the endpoint, and whether you were in sandbox or live. That's everything we need to find your exact request. Reproduce every error path on purpose with sandbox test banks. Most bank-side codes surface as a `connection.failed` transition. # Going live Source: https://docs.ledgersyncappv2.com/guides/going-live The checklist to run before you flip the same flow at api.ledgersyncappv2.com with a live key. Your sandbox integration and your live integration are the *same code* against two base URLs. Going live is mostly about hardening the webhook handler and swapping the key. Walk this checklist before your first real user. **Live base URL:** `https://api.ledgersyncappv2.com/v3`  ·  **Key prefix:** `sk_live_...`. Sandbox keys (`sk_test_...`) do **not** authenticate against the live base URL, and vice-versa. ## Before your first live user Same flow as sandbox, but choose **live** as the environment. Copy the `sk_live_...` value once, it's shown only at creation, and store it in your server's secret store. See [Authentication](/authentication). The `signing_secret` from your webhook subscription is shown **exactly once**. Persist it to a real secrets manager (GCP Secret Manager, AWS Secrets Manager, 1Password Connect, whatever you already run) before you close the tab. You need it to verify every delivery. We retry failed deliveries with exponential backoff for **24 hours**, so the same event can land more than once. Keep a short-lived set of seen `event_id`s and short-circuit duplicates. Your handler must be idempotent. Acknowledge each delivery within **30 seconds**. If your work is slow (DB writes, notifications, downstream calls), enqueue it and return `200` immediately, anything else looks like a failure and triggers a retry. Compare the HMAC with `hmac.compare_digest` (Python) / `crypto.timingSafeEqual` (Node) / your language's equivalent, never a plain `==`, which leaks timing. Reject any delivery whose `X-LS-Webhook-Timestamp` is more than **5 minutes** old. Full recipe in [Webhooks](/guides/webhooks). Fire `POST /v3/webhooks/subscriptions/{id}/test` any time. It's signed with the subscription's real `signing_secret` and carries the same headers as production, so your verification path runs end-to-end. It bypasses the `event_types` filter, so you don't need to subscribe to `webhook.test` for it to land. A test fire proves your endpoint works today; it says nothing about tomorrow. `GET /v3/webhooks/subscriptions` returns `last_success_at` and `last_failure_at` on every subscription. Alert when `last_failure_at` is newer than `last_success_at`. Note that a subscription stays `active` no matter how badly delivery is going, so `status` alone will never tell you your webhooks have stopped arriving. Details in [Check delivery health](/guides/webhooks#check-delivery-health). Banks reject credentials, MFA tokens expire, users revoke access. Subscribe to both events and surface a clear **re-link** call to action in your UI when either fires. See [Connection lifecycle](/guides/connection-lifecycle). Every response, including errors, carries an `X-LS-Trace-Id`. Log it. When you open a support ticket, quote it and we jump straight to your request. The single most common go-live bug is a webhook handler that isn't idempotent or is too slow to ack. Get dedupe-on-`event_id` and return-2xx-fast right in sandbox with [`webhook.test`](/guides/testing) before you ship. ## What changes from sandbox | | Sandbox | Live | | - | - | - | | Base URL | `api-sandbox.ledgersyncappv2.com/v3` | `api.ledgersyncappv2.com/v3` | | Key prefix | `sk_test_...` | `sk_live_...` | | Banks | Aggregator sandbox banks + FDE Ledgersync Bank | Real financial institutions | | Everything else | — | Identical | Same endpoints, same webhook shapes, same routing. If it worked against the [FinBank smoke test](/guides/testing), it works live. ## Rate limits Every API key is rate-limited per environment. The **live** tier trades a lower sustained rate for a much larger daily budget and a short burst allowance; the **sandbox** tier lets you hammer it faster while you build but caps the daily total. | Limit | Sandbox | Live | | - | - | - | | Requests / minute | 120 | 60 | | Requests / day | 10,000 | 50,000 | | Burst | none | 2× over 60s | Every response, success or error, carries these headers: | Header | Meaning | | - | - | | `X-RateLimit-Limit` | Requests allowed in the per-minute window | | `X-RateLimit-Remaining` | Lowest remaining count across all active windows | | `X-RateLimit-Reset` | Unix seconds when the per-minute window resets | | `X-RateLimit-Policy` | Every enforced window, e.g. `60;w=60, 50000;w=86400` | Read `X-RateLimit-Remaining` and back off *before* you hit zero rather than waiting for the `429`. Because live gets a 2× burst, a single spike can pass while the minute window is technically full, then the next request trips. ### When you get throttled Over the limit, the API returns `429` with the standard error envelope and a `Retry-After` header (seconds): ```json theme={null} { "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded", "message": "Rate limit exceeded" } } ``` ``` HTTP/1.1 429 Too Many Requests Retry-After: 60 X-RateLimit-Policy: 60;w=60, 50000;w=86400 ``` Honor `Retry-After` exactly: on a per-minute trip it's the seconds to the next minute boundary; on a daily-budget trip it's the seconds until the day rolls over, which can be hours. ### Service-layer caps Some limits are enforced deeper in the stack and are **not** reflected in the `X-RateLimit-*` headers. You'll still see a `429` (`rate_limit_exceeded`) when you cross them: | Cap | Sandbox | Live | | - | - | - | | Concurrent open connection flows | 10 | 50 | | Webhook events / day | 5,000 | 25,000 | | Statement extractions / day | 50 | 500 | | Concurrent statement extractions | 2 | 3 | Statement extractions are capped separately because each one burns a real OCR-provider call, even on sandbox keys. Queue extraction jobs and cap your own concurrency rather than firing them all at once. Don't hardcode these numbers into retry logic. Drive backoff off `X-RateLimit-Remaining` and `Retry-After` so your integration keeps working if a tier changes. # Pagination Source: https://docs.ledgersyncappv2.com/guides/pagination Every list endpoint returns a page plus an opaque cursor. Loop until has_more is false to read the full set. List endpoints return a **page** of results, not the whole set. Each response carries an opaque `next_cursor` and a `has_more` flag. To read everything, pass `next_cursor` back as the `cursor` query param and repeat until `has_more` is `false`. This applies to every list endpoint: `GET /clients`, `GET /clients/{id}/connections`, `GET /accounts`, `GET /accounts/{id}/transactions`, `GET /accounts/{id}/statements`, `GET /accounts/{id}/checks`, and `GET /webhooks/subscriptions`. ## The response envelope ```json theme={null} { "data": [ /* this page's items */ ], "next_cursor": "cur_1.dHhu...", "has_more": true } ``` | Field | Meaning | | - | - | | `data` | The items on this page. | | `next_cursor` | Opaque token. Pass it as `cursor` to fetch the next page. `null` (or absent) when `has_more` is `false`. | | `has_more` | `true` if more items exist beyond this page. | ## Request params | Param | Default | Notes | | - | - | - | | `limit` | `100` | Items per page, 1–500. Values above 500 are capped, not rejected. | | `cursor` | — | The `next_cursor` from the previous response. Omit for the first page. | The cursor is **opaque** — don't parse or construct it. It is also scoped to the exact query it was minted for: reusing a cursor from one account (or a different date window / filter) against another returns `400 invalid_request`. ## Loop until done ```bash theme={null} LS_KEY=sk_test_... LS_BASE=https://api-sandbox.ledgersyncappv2.com/v3 ACCT=acc_FINICITY_41294 CLIENT=cli_9f2a cursor="" while : ; do resp=$(curl -s "$LS_BASE/accounts/$ACCT/transactions?client_id=$CLIENT&limit=200&cursor=$cursor" \ -H "Authorization: Bearer $LS_KEY") echo "$resp" | jq '.data[]' [ "$(echo "$resp" | jq -r '.has_more')" = "true" ] || break cursor=$(echo "$resp" | jq -r '.next_cursor') done ``` ```python Python theme={null} import requests base = "https://api-sandbox.ledgersyncappv2.com/v3" headers = {"Authorization": "Bearer sk_test_..."} params = {"client_id": "cli_9f2a", "limit": 200} rows, cursor = [], None while True: if cursor: params["cursor"] = cursor page = requests.get(f"{base}/accounts/acc_FINICITY_41294/transactions", headers=headers, params=params).json() rows.extend(page["data"]) if not page["has_more"]: break cursor = page["next_cursor"] ``` ## Ordering and stability Transactions are returned **most-recently-recorded first** (by internal sequence), which is stable and complete for paging through the whole set. It is not a strict chronological sort — to bound results by transaction date, use the `from` and `to` query params (they combine with pagination). A transaction can change after you first see it (for example, a `pending` charge later posts). If you re-page a set while it's being refreshed, a changed row may appear again on a later pull. Always **dedupe on the item `id`** — it's stable and unique, and cheap insurance for any sync loop. The cursor uses keyset (seek) pagination, so it stays correct even as rows are added or removed between pages — you won't skip or double-count the remaining items. # Quickstart Source: https://docs.ledgersyncappv2.com/guides/quickstart Link a bank and pull transactions in about 15 minutes — from your first sandbox key to a live connection. This is the end-to-end path: mint a key, register a webhook, create a Client, find a bank, initiate a connection, hand off the widget, and read back accounts and transactions. Every call here uses the **sandbox** base URL and a test bank, so you can run the whole thing without touching real credentials. **Base URL (sandbox):** `https://api-sandbox.ledgersyncappv2.com/v3`  ·  **Auth:** `Authorization: Bearer sk_test_...`  ·  Every response carries an `X-LS-Trace-Id` header — grab it if you ever open a support ticket. Rather have an assistant write this for you? [Connect with copy and paste: API Setup Guide](/guides/build-with-ai) has a brief you can paste straight into Claude or ChatGPT, plus the manual steps it cannot do on your behalf. ## Prefer Postman? Import our collection and run the whole flow without leaving Postman. The **Quickstart** folder chains every step below and captures ids for you, so you can run it top-to-bottom. In Postman, **Import → Link** and paste the hosted URL (or download it and **Import → File**): ``` https://portal.ledgersyncappv2.com/ledgersync-v3.postman_collection.json ``` It ships folders for every resource — Clients, Connections, Accounts, Transactions, Statements, Operations, Webhooks, Institutions, and more — plus the chained **Quickstart** folder. On the collection, set `api_key` to your key and point `base_url` at the environment you're testing: | Variable | Sandbox | Live | | - | - | - | | `api_key` | your `sk_test_...` | your `sk_live_...` | | `base_url` | `https://api-sandbox.ledgersyncappv2.com/v3` | `https://api.ledgersyncappv2.com/v3` | Run it top-to-bottom — it creates a Client, discovers an institution, initiates a connection, and captures each returned id into the collection variables for the next request. Prefer a raw, always-current endpoint list? Postman can import our OpenAPI spec directly: **Import → Link** → `https://api.ledgersyncappv2.com/v3/openapi.yaml`. ## The mental model Four objects, one line of descent. A **Client** is your record of one end-user. Each Client owns one or more **Connections** (one per linked bank). Each Connection exposes **Accounts**, and each Account has **Transactions** and **Statements**. ```mermaid theme={null} graph LR A[Client
cli_9f2a4c1b...] --> B[Connection
con_FINICITY_41294] B --> C[Account
acc_FINICITY_...] C --> D[Transactions
txn_FINICITY_...] C --> E[Statements] ``` Two things to internalize now, because they save debugging later: * **You never see bank credentials.** The user types them into a LedgerSync-hosted widget. You just open a URL. * **The data source is chosen for you.** LedgerSync routes each institution to Finicity, MX, or FDE server-side. There is no `source` parameter — the id you pass (`ins_...`) already encodes everything. Broadest US coverage. OAuth where the bank supports it. Alternate aggregator. Catches banks Finicity misses. LedgerSync's proprietary extraction for banks neither aggregator covers. In the portal, open **Build → API keys** and create a sandbox key. It starts with `sk_test_` — live keys start with `sk_live_`. Keep test and live strictly separate; they hit different base URLs and different data. Set it in your shell so the snippets below just work: ```bash theme={null} export LS_KEY="sk_test_your_key_here" export LS_BASE="https://api-sandbox.ledgersyncappv2.com/v3" ``` Confirm it authenticates: ```bash theme={null} curl "$LS_BASE/institutions?q=chase" \ -H "Authorization: Bearer $LS_KEY" ``` A `401` means a bad or missing key. See [Authentication](/authentication) for the full contract. Connections finish **asynchronously** — the user could take thirty seconds or ten minutes inside the widget. Rather than poll forever, subscribe to webhooks and react when `connection.active` arrives. The easiest path is the **portal Webhooks page**: add your endpoint, pick events, and see deliveries and retries visually. Each subscription also reports its own delivery health over the API, so you can monitor it from your code too. See [Check delivery health](/guides/webhooks#check-delivery-health). To do it over the API, subscribe to all eight event types: ```bash theme={null} curl -X POST "$LS_BASE/webhooks/subscriptions" \ -H "Authorization: Bearer $LS_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.example.com/webhooks/ledgersync", "event_types": [ "connection.requires_action", "connection.active", "connection.failed", "connection.disconnected", "connection.capability_changed", "account.refresh.completed", "account.refresh.failed", "statement.available" ] }' ``` The response includes a `signing_secret`: ```json theme={null} { "id": "whk_7c1e4a9b83d24f6ab05e2f8c91d3a640", "url": "https://yourapp.example.com/webhooks/ledgersync", "event_types": ["connection.requires_action", "connection.active", "..."], "status": "active", "created_at": "2026-09-03T14:22:08Z", "signing_secret": "whsec_9f2a...c1" } ``` The `signing_secret` is shown **once**. Store it now — you need it to verify every incoming delivery. If you lose it, rotate the subscription. Subscription ids start with `whk_` followed by 32 hex characters. Anything else is rejected as unparseable and comes back `404`. Verify signatures constant-time and dedupe on `event_id`. The full recipe (headers, HMAC, retries, the 30-second ack window) is in the [Webhooks guide](/guides/webhooks). Want to see a payload land right away? Fire a test: ```bash theme={null} curl -X POST "$LS_BASE/webhooks/subscriptions/whk_7c1e4a9b83d24f6ab05e2f8c91d3a640/test" \ -H "Authorization: Bearer $LS_KEY" \ -H "Content-Length: 0" ``` That returns `{ "event_id": "evt_6d84cf35-b2ee-4734-b408-4e6f10fedad8" }` and nothing to poll — watch your endpoint for the `webhook.test` delivery. The id you get back is exactly what the delivery carries, both in its `X-LS-Webhook-Event-Id` header and in the envelope's `event_id`, so you can match them up as-is. A Client represents one end-user. ```bash theme={null} curl -X POST "$LS_BASE/clients" \ -H "Authorization: Bearer $LS_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "user_4820", "name": "Acme Bookkeeping LLC", "phone": "5551234567" }' ``` ```json theme={null} { "id": "cli_9f2a4c1b8e07d3a5f6b20c14", "external_id": "user_4820", "name": "Acme Bookkeeping LLC", "phone": "5551234567" } ``` Hold onto `cli_9f2a4c1b8e07d3a5f6b20c14` — every read later is scoped to it. `phone` is optional; add `"sms_invitation": true` alongside a non-blank `phone` to have LedgerSync text the client a login invitation on creation. **`external_id` and `metadata` do not persist yet.** They are accepted and echoed back on create, as above, but `GET /clients/{id}` and `GET /clients` return them as `null`, and `PATCH` silently drops them. Store your own mapping from your user id to the `cli_...` id on your side, and key everything off the `cli_...` id. Do not build a lookup that reads `external_id` back from us. Search the catalog to get the `institution_id` you'll connect to. Each row carries a `capabilities` block so you know upfront what a bank supports. Institutions that offer no transactions, statements or check images are left out of search. ```bash theme={null} curl "$LS_BASE/institutions?q=FinBank" \ -H "Authorization: Bearer $LS_KEY" ``` ```json theme={null} { "data": [ { "id": "ins_3c97c5da90335a04", "name": "FinBank", "capabilities": { "oauth": false, "transactions": true, "statements": true, "check_images": false } } ] } ``` Pick the row whose `name` matches what you searched. `?q=FinBank` can return several FinBank variants — grab the one named exactly **FinBank** for the no-MFA happy path. Create a Connection under the Client with just the `institution_id`. No source, no credentials. ```bash theme={null} curl -X POST "$LS_BASE/clients/cli_9f2a4c1b8e07d3a5f6b20c14/connections" \ -H "Authorization: Bearer $LS_KEY" \ -H "Content-Type: application/json" \ -d '{ "institution_id": "ins_3c97c5da90335a04" }' ``` You get back `202 Accepted` and an `operation_id`. The connection is being set up in the background. ```json theme={null} { "operation_id": "op_3d9c7f1e05b84a26bb4e8107c2fa9d53", "status": "queued", "poll_url": "/v3/operations/op_3d9c7f1e05b84a26bb4e8107c2fa9d53", "estimated_seconds": 5 } ``` Read the operation to collect the widget URL: ```bash theme={null} curl "$LS_BASE/operations/op_3d9c7f1e05b84a26bb4e8107c2fa9d53" \ -H "Authorization: Bearer $LS_KEY" ``` ```json theme={null} { "id": "op_3d9c7f1e05b84a26bb4e8107c2fa9d53", "type": "connection.initiate", "status": "succeeded", "result": { "kind": "connection_with_action", "connection": { "id": "con_5f8c1e2a-7b3d-4a9e-9c11-2f6d0a4b8e21", "client_id": "cli_9f2a4c1b8e07d3a5f6b20c14", "source": "FINICITY", "status": "requires_action", "action": { "kind": "widget_url", "widget_url": "https://connect.ledgersyncappv2.com/w/eyJhbGci...", "expires_at": "2026-09-03T15:22:08Z" } } } } ``` **`status: "succeeded"` here means the widget URL was issued, not that the bank is linked.** The operation's `result` is a snapshot written once, at that moment, and it is never rewritten — so the `con_` **placeholder** above stays the placeholder no matter how many times you re-poll. Take `action.widget_url` from this response and nothing else. Judge any connection id by its shape: `con__` is canonical and safe to store, `con_` plus a UUID is not and returns `400 missing source separator` on `/accounts`, `/transactions`, and `/statements`. The canonical id arrives from the `connection.active` webhook, or from `GET /clients/{client_id}/connections`. Open `widget_url` in the user's browser — redirect, iframe, or webview, whatever fits your app. The user searches for their bank, signs in (credentials or the bank's own OAuth), picks accounts, and closes it. **Credentials go to the bank, never through you.** A small nuance by source: Finicity and MX show an aggregator widget with a bank search; FDE shows a LedgerSync-hosted connect page with the bank already chosen (no search, since `institution_id` fixed it). **Don't want to build a picker at all?** Use the hosted link instead: `POST /v3/clients/{id}/connect-session` returns `{ url, expires_at }` — a LedgerSync-hosted page you email the member. They search, pick, and connect their own bank. See [Connect a bank](/guides/connect-a-bank) for both flows side by side. A hosted link cannot be revoked through the API today: the create call returns only `url` and `expires_at`, there is no endpoint that lists sessions, and `DELETE /v3/clients/{id}/connect-session/{sid}` needs an `sid` you are never given. Treat `expires_at` as the only expiry. When the user finishes, LedgerSync fires `connection.active` to your webhook. This is your signal that data is flowing and the canonical id is ready. ```json theme={null} { "event_id": "evt_6d0b5a72-91c4-4e83-a5f1-70b2c8e4d913", "type": "connection.active", "created_at": "2026-09-03T14:26:41Z", "api_version": "2026-05-22", "livemode": false, "data": { "connection": { "id": "con_FINICITY_41294", "client_id": "cli_9f2a4c1b8e07d3a5f6b20c14", "source": "FINICITY", "status": "active" } } } ``` Persist `con_FINICITY_41294` against your user. That's the id you'll use for every read. (The other statuses — `requires_action`, `failed`, `disconnected` — are covered in the [Connection lifecycle](/guides/connection-lifecycle) guide.) The payload carries no `institution_id`. If you need the institution for a connection, read it from `GET /v3/connections/{id}`, which returns an `institution` object. Reads are **Client-scoped** — pass `client_id` on every one. Start with the accounts on this connection: ```bash theme={null} curl "$LS_BASE/accounts?client_id=cli_9f2a4c1b8e07d3a5f6b20c14&connection_id=con_FINICITY_41294" \ -H "Authorization: Bearer $LS_KEY" ``` ```json theme={null} { "data": [ { "id": "acc_FINICITY_889201", "name": "FinBank Checking", "type": "checking", "subtype": "checking", "current_balance": 4210.55, "iso_currency_code": "USD" } ] } ``` Then pull transactions for an account, filtered by date: ```bash theme={null} curl "$LS_BASE/accounts/acc_FINICITY_889201/transactions?client_id=cli_9f2a4c1b8e07d3a5f6b20c14&from=2026-01-01" \ -H "Authorization: Bearer $LS_KEY" ``` ```json theme={null} { "data": [ { "id": "txn_FINICITY_5521398", "account_id": "acc_FINICITY_889201", "date": "2026-01-14", "amount": -42.17, "description": "COFFEE HOUSE #221", "pending": false } ] } ``` That's the full loop — Client → Connection → Account → Transactions. The ids encode their source (`acc_FINICITY_...`, `txn_MX_...`, `txn_FDE_...`), but the shapes are identical no matter which aggregator served them. ## The transaction object Every row in a `/transactions` response has the same shape, whatever aggregator served it. The fields: | Field | Type | Notes | | - | - | - | | `id` | string | Stable v3 id, `txn__`. Use it as your idempotency key — see dedupe below. | | `account_id` | string | The owning account, `acc__`. | | `external_id` | string \| null | The upstream aggregator's own transaction id (Finicity, MX, …), when it exposes one. | | `amount` | number | Signed decimal in the account's currency. Sign convention below. | | `iso_currency_code` | string | ISO-4217, e.g. `USD`. Defaults to `USD` when the source omits it. | | `date` | string (`YYYY-MM-DD`) | Posted date — when the transaction cleared. | | `description` | string | Raw bank description, e.g. `STARBUCKS #1234 SEATTLE WA`. | | `merchant_name` | string \| null | Cleaned merchant, when the source provides one. | | `category` | string \| null | LedgerSync's normalized category (best-effort across sources). | | `pending` | boolean | `true` while the charge is still pending, `false` once posted. | That's the complete set. There is no `subcategory`, `type`, or `location` field. ### Amount sign `amount` is **signed to one convention across every source**. Most aggregators already deliver a signed value and LedgerSync passes it straight through; where a source instead reports an unsigned magnitude with the direction in a separate field (MX does this), LedgerSync applies the sign for you. Either way you get: * **Money out (charges, purchases, withdrawals) is negative.** A \$28.34 card purchase comes through as `-28.34`. * **Money in (deposits, payroll, credits) is positive.** A \$1,500 payroll deposit comes through as `+1500.00`. ```json theme={null} { "data": [ { "id": "txn_FINICITY_1", "amount": -28.34, "description": "STARBUCKS #1234 SEATTLE WA", "category": "Food and Drink", "pending": false }, { "id": "txn_FINICITY_2", "amount": 1500.00, "description": "PAYROLL DEPOSIT", "category": "Income", "pending": false } ] } ``` The sign is a LedgerSync guarantee, not a per-source quirk — you do not need to branch on which aggregator backs a connection. `acc_FINICITY_*`, `acc_MX_*`, `acc_FDE_*` and `acc_PDF_*` all report money out as negative and money in as positive. Before you post amounts into a ledger it is still worth a quick read against your own sandbox data with FinBank — the fixtures above are exactly what the sandbox returns, so you can confirm the convention end to end for your integration. **Dedupe on `id`.** The `txn__` id is stable across refreshes, so use it as your upsert/idempotency key. Re-pulling a date range that overlaps a previous pull returns the same ids — key on `id` and you'll never double-count a transaction. ## Testing with FinBank **The no-MFA happy path.** Search `?q=FinBank` and pick the row named exactly **FinBank**. In the widget, sign in with **Banking Userid `demo`** / **Banking Password `go`**. It flips to `active` immediately — no MFA, no OAuth round-trip — and auto-populates accounts, transactions, and statements. To exercise FDE instead, use **"Ledgersync Bank"** (`ins_a7397a8d0656e1b7`). The full bank list, plus MFA and OAuth variants and their credentials, lives in the portal Testing playbook. ## Handling errors Every error returns the same envelope — branch on `code`, not on the HTTP status alone: ```json theme={null} { "error": { "code": "not_found", "message": "No institution matches ins_deadbeef.", "type": "not_found", "doc_url": "https://portal.ledgersyncappv2.com/errors/not_found", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736" } } ``` | HTTP | Meaning | | - | - | | 400 | Validation — something in your request body or params is off | | 401 | Auth — bad or missing key | | 404 | Not found | | 429 | Rate limited — back off and retry | | 5xx | Server error — retry, and quote `X-LS-Trace-Id` if it persists | Details and every code in the [Errors guide](/guides/errors). ## Where to go next The widget flow and the hosted connect-session link, in depth. Verify signatures, dedupe on event\_id, handle retries. requires\_action, active, failed, disconnected — and capability changes. Every endpoint, parameter, and response shape. # Testing in sandbox Source: https://docs.ledgersyncappv2.com/guides/testing The full sandbox test-bank reference: FinBank, MX, and FDE — real credentials, MFA recipes, account shapes, and the webhooks each fires. Sandbox is a full, live copy of the v3 API. Same endpoints, same webhooks, same routing logic, pointed at the aggregators' own sandbox banks (Finicity FinBank, MX Bank) plus the FDE **Ledgersync Bank**, not synthetic fixtures. You link a real test bank, sign in with test credentials, and watch real accounts, transactions, and statements flow back exactly as they will in production. Sandbox uses `https://api-sandbox.ledgersyncappv2.com/v3` and an `sk_test_...` key. Live traffic never touches sandbox and vice-versa. See [Authentication](/authentication) for how to get and send your key. ## What sandbox gives you * **The real routing engine.** When you initiate a connection, v3 picks the source (Finicity, MX, or FDE) from the institution catalog, server-side, just like production. You never pass a `source`. * **Real widgets.** Finicity and MX open their own aggregator sandbox widgets. FDE opens the LedgerSync-hosted connect page. You still never see or transmit credentials. * **Real webhooks.** Every state transition publishes a real Kafka event and fires the matching webhook, HMAC-signed exactly like production. ## Sandbox institution IDs Pass one of these as `institution_id` on `POST /v3/clients/{id}/connections`. v3 resolves the source from the catalog row, you don't pass a `source` field. `ins_3c97c5da90335a04` Finicity's sandbox bank. Widget renders on `connect2.finicity.com` (Finicity institutionId 8906). `ins_73df5077e90a1b63` MX's sandbox bank (`mxbank`). Hands off to MX Connect's sandbox on `int-widgets.moneydesktop.com`. Unlimited test aggregations. `ins_a7397a8d0656e1b7` FDE's credential-based extractor. Returns a LedgerSync-hosted connect page (not an aggregator widget); accepts any login, offers one account. The only sandbox bank that returns **check images**. **Prove your integration in 6 calls:** (1) mint an `sk_test_` key, (2) register a webhook endpoint, (3) `POST /v3/clients` to create a test end-user, (4) `POST /v3/clients/{id}/connections` with `institution_id=ins_3c97c5da90335a04`, open the widget, sign in `customer1` / `go`, (5) confirm `connection.active` lands, then `GET /v3/accounts` (Checking 1111 + Savings 2222), (6) repeat step 4 with `ins_73df5077e90a1b63` and `mxuser` / `correct` to prove the MX path. ## The 60-second smoke test (FinBank) FinBank is Finicity's sandbox bank. It flips to `active` immediately, with no MFA, and auto-populates accounts, transactions, and statements. It is the fastest way to prove your integration works end-to-end. Search the catalog and pick the row named **exactly** `FinBank` — the sandbox has several `FinBank *` variants (see the table below) and only the plain one is the no-friction path. ```bash Search theme={null} curl "https://api-sandbox.ledgersyncappv2.com/v3/institutions?q=FinBank" \ -H "Authorization: Bearer sk_test_..." ``` Grab the `id` (`ins_3c97c5da90335a04`) from the row whose name is `FinBank`. Point it at a client you've created (see [Connect a bank](/guides/connect-a-bank) for creating clients). ```bash Initiate theme={null} curl -X POST "https://api-sandbox.ledgersyncappv2.com/v3/clients/cli_.../connections" \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{"institution_id":"ins_3c97c5da90335a04"}' ``` You get `202 Accepted` with an `operation_id`. Poll it until `status` is `succeeded`, then read `result.connection.action.widget_url`. Open `widget_url` in a browser, pick FinBank, and sign in with the sandbox credentials: | Field | Value | | - | - | | Banking Userid | `demo` | | Banking Password | `go` | No MFA, it links right away. (`customer1` / `go` also works and returns known balances, see the table below.) The connection flips to `active` and `connection.active` fires on your webhook. The payload carries the **canonical** connection id (`con_FINICITY_`, e.g. `con_FINICITY_41294`). Store that, the placeholder `con_` you saw during pending only works for the widget step. Accounts, transactions, and statements are already populated. Reads are Client-scoped, so pass `client_id` every time. ```bash Accounts theme={null} curl "https://api-sandbox.ledgersyncappv2.com/v3/accounts?client_id=cli_...&connection_id=con_FINICITY_41294" \ -H "Authorization: Bearer sk_test_..." ``` Transaction ids follow the same shape: `txn_FINICITY_...`, `txn_MX_...`, or `txn_FDE_...`. If you saw `connection.active`, a canonical `con_FINICITY_...` id, and non-empty accounts, your happy path is wired correctly. Now exercise the edge cases. ## Finicity — FinBank credentials Public sandbox logins published by Mastercard Open Banking. Sign in on the widget URL for `ins_3c97c5da90335a04`. The **username picks the scenario**; the password is `go` unless noted. | Scenario | Username | Password | MFA | Expected result | | - | - | - | - | - | | Happy path (verified) | `customer1` | `go` | none | Checking 1111 ≈ $605.06 / 8 txns + Savings 2222 ≈ $605.06 / 8 txns (Walmart fixtures). | | Happy path (legacy demo) | `demo` | `go` | none | Checking + Savings, small synthetic history. | | Wider shape | `customer2` | `go` | none | Checking + Savings + Credit Card — multi-type serialization. | | Text MFA | `tfa_text` | `go` | text prompt · answer `go` | Drives `connection.requires_action` → `connection.active`. | | OTP text MFA | `customer3` | `go` | OTP challenge · answer `999999` | Meant to test the failure path with a wrong code. As of September 2026 no challenge appears: the login links directly, so the wrong-code path cannot be reproduced. | | Image MFA | `tfa_image` | `go` | image challenge · answer `go` | Exercises the image-MFA UI path. | | Captcha | `tfa_captcha` | `go` | captcha · answer any non-empty | Any non-empty value passes. | | Invalid credentials | `customer1` | `bad` | n/a | The widget rejects the sign-in and keeps the user on the form (as of September 2026 it shows "Technical issue (130)"). No connection is created and no webhook fires. | | Locked institution | `locked` | `go` | n/a | Links normally in the current sandbox: the connection goes `active` with no error, so no lock is simulated. | ### FinBank Profiles A–H — account-shape coverage For the `FinBank Profiles - A` / `- B` institutions, the **password picks a profile** (use the same value as the OAuth username too). Exercises every serializer path; investment coverage is intentionally thin, link a real brokerage for that. | Password (and OAuth username) | Account shapes returned | | - | - | | `profile_02` | Savings, IRA, 401k, Credit Card | | `profile_03` | Checking, Savings, Credit Card | | `profile_04` | Checking + Money Market + Mortgage | | `profile_05` | Checking, Savings, Credit Card, Auto Loan | | `profile_06` | Checking, Savings, Brokerage, Credit Card | | `profile_07` | Checking + Student Loan + Line of Credit | | `profile_08` | Multiple Checking accounts | | `profile_09` | Multiple Credit Cards | ## Other FinBank variants — edge-case banks The catalog also surfaces several letter- and label-suffixed FinBank entries. Each one is Mastercard Open Banking's dedicated sandbox bank for a specific edge case, pick a variant when you want to harden against that case, not for a smoke test. Search for each by name and initiate exactly as above. | Catalog name | Purpose | | - | - | | **FinBank Profiles - A** | Account-shape coverage. The password picks a profile (`profile_02`…`profile_09`) that returns a specific mix of Checking, Savings, IRA, 401k, Credit Card, Brokerage, Mortgage, Auto Loan, etc. | | **FinBank Profiles - B** | Second profile-driven institution — same `profile_*` password mechanism as Profiles - A, distinct catalog row. | | **FinBank M** | MFA-flavoured variant (the "M" suffix). Use alongside the text / image / OTP MFA usernames to exercise the `connection.requires_action` → resume path. | | **FinBank O** | Alternate-aggregation variant. Use when you need a non-default Finicity routing for parsing or fixture-level regression coverage. | | **FinBank C** | Captcha-flavoured variant. Pair with the `tfa_captcha` username to drive the captcha-MFA path in the widget. | | **FinBank P** | Personal-banking variant. Use it to keep a consumer-shaped connection isolated from your business test connections. | | **FinBank OAuth** | OAuth sign-in flow. The widget redirects to a Finicity-hosted bank sign-in instead of taking credentials inline. The catalog row has `oauth_supported = true`. | | **FinBank Decertified** | Decertified-institution failure path, meant to drive `connection.failed` with a decertification-style `last_error`. As of September 2026 it links normally instead: `demo` / `go` goes `active` and sends `connection.active`. | | **FinBank Billable** | Billable-statements variant. Verify your code handles the statements path end-to-end, including whatever your billing/usage accounting needs to count. | ## MX sandbox bank (mxbank) MX is the alternate aggregator, it catches institutions Finicity misses, and v3 routes to it automatically when the catalog says so. Sign in on the widget URL for `ins_73df5077e90a1b63`. The username is always `mxuser`; the **password is the dial** that picks the scenario. Ids come back source-tagged: `con_MX_` (e.g. `con_MX_1224`), `acc_MX_...`, `txn_MX_...`. | Scenario | Username | Password | Result | | - | - | - | - | | Happy path (verified) | `mxuser` | `correct` | Checking \$495,565.18 / 49 txns, Savings, Credit Card, Loan, Mortgage, Investment. | | Any non-keyword password | `mxuser` | `` | Connects normally. Use any string other than the keywords below. | | Text MFA | `mxuser` | `challenge` | Standard text MFA · answer `correct` (any other answer fails). | | Pick-one MFA | `mxuser` | `options` | `OPTIONS` selection challenge · answer `correct`. | | Image MFA | `mxuser` | `image` | `IMAGE` selection challenge · answer `correct`. | | Bad request | `mxuser` | `BAD_REQUEST` | 400 missing fields. `connection_status = FAILED`. | | Invalid credentials | `mxuser` | `UNAUTHORIZED` | 401 invalid credentials. `connection_status = DENIED`. | | Invalid login | `mxuser` | `INVALID` | 401 login/password invalid. `connection_status = DENIED`. | | Locked user | `mxuser` | `LOCKED` | 401 user is locked. `connection_status = LOCKED`. | | Disabled user | `mxuser` | `DISABLED` | 401 locked-account variant. `connection_status = DENIED`. | | Server error | `mxuser` | `SERVER_ERROR` | 500 institution-side error. `connection_status = FAILED`. As of September 2026 the widget shows "Unable to connect" and no connection is created. | | Service unavailable | `mxuser` | `UNAVAILABLE` | 503 service unavailable. `connection_status = FAILED`. As of September 2026 the widget stays on "Loading" and no connection is created. | What v3 reports for each MX `connection_status` above: `DENIED` reads `requires_action` with `last_error.code` `credentials_invalid`; `LOCKED` reads `requires_action` with `account_locked_by_institution` and `is_user_actionable: false` (the user acts at the bank, not in the widget); `FAILED` maps to `failed` with `institution_unavailable`, but neither `SERVER_ERROR` nor `UNAVAILABLE` creates a connection in the current sandbox, so that case cannot be seen here. These are the statuses `GET /v3/connections/{id}` reports. A first link that ends in one of them is announced by a `connection.*` webhook carrying the canonical `con_MX_...` id, shortly after the user submits the widget (6 to 20 seconds in September 2026 tests). Before September 23, 2026 only the initiate event under the placeholder id fired, so a first link that did not pass through MFA was not named by a webhook until its status next changed. ### Card terms and masks in sandbox The sandbox banks publish very little credit data, so an empty `liabilities` on a sandbox card is expected, not a bug: * **FinBank credit cards carry no card terms.** Use the FinBank **line of credit** to see a populated object: it carries `available_credit`, `statement_balance`, `minimum_payment` and `payment_due_date` (no `credit_limit`). The FinBank auto loan and mortgage carry a `minimum_payment` only. * **The MX Bank credit card** carries `available_credit` (3000) and a fixed `payment_due_date` of 2021-05-07; it reports no `credit_limit` or `minimum_payment`. The MX Bank loan and mortgage carry a fixed 2021-05-12 due date. A due date in the past is the bank's last published cycle, not an overdue payment. * **Checking, savings and investment accounts never carry `liabilities`**, even though MX Bank reports a due date on them. `mask` is the last 4 digits of the account number on every source: FinBank reports `7777` for the credit card, `6666` for the line of credit, `1111` for checking and `2222` for savings; MX Bank accounts report the last 4 of their masked account numbers. **MX OAuth constraint:** only one OAuth member is allowed per user for `mx_bank_oauth` / `mxcu_oauth`. Tear down the previous OAuth connection before re-linking. ## FDE — Ledgersync Bank FDE is LedgerSync's proprietary, credential-based extraction path, used for banks neither aggregator covers. It connects through a **LedgerSync-hosted page** (not an aggregator widget). The sandbox Ledgersync Bank (`ins_a7397a8d0656e1b7`) accepts **any** username and password: ```bash Initiate an FDE connection theme={null} curl -X POST "https://api-sandbox.ledgersyncappv2.com/v3/clients/cli_.../connections" \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{"institution_id":"ins_a7397a8d0656e1b7"}' ``` Open the returned `widget_url` and: Type any username / password → **Submit**. An account-selection form appears (one account is offered) → **Submit** to keep it. FDE downloads the statements and check images, then fires `connection.active`. The connection id is `con_FDE_...` and transactions come back as `txn_FDE_...`. Because the bank is already fixed by `institution_id`, there is no bank-search step, and you never handle the credentials yourself. ### Check images Ledgersync Bank is the **only sandbox bank that produces check images** — the Finicity and MX sandbox banks have none, so it's the one place to exercise the check routes end-to-end. Once `connection.active` lands, read them off the account: ```bash List the checks FDE extracted theme={null} curl "https://api-sandbox.ledgersyncappv2.com/v3/accounts/acc_FDE_.../checks?client_id=cli_..." \ -H "Authorization: Bearer sk_test_..." ``` Extraction is asynchronous — checks appear once the connection reaches `active`, not at the moment the widget closes. Wait for the webhook rather than polling straight after submitting credentials. See [Check images](/guides/checks) for the full contract: the three routes, the `read:checks` scope, and why bank-reported and OCR fields are kept apart. ## Which webhook fires when Every state transition publishes a real Kafka event and fires the matching webhook, HMAC-signed exactly like production. | Event | When it fires | How to trigger it | | - | - | - | | `connection.requires_action` | As soon as the widget URL is ready — the first event of every new link. | Signals the user must act; the URL is **not** in this payload. Take it from the initiate operation's `result.connection.action.widget_url`. (`POST /v3/connections/{connection_id}/reauthorize` is for a connection you already hold a canonical id for, so it is not an option on a first link.) | | `connection.active` | User finishes the widget with valid credentials. | Finicity `customer1` / `go` or MX `mxuser` / `correct`. Accounts now visible via `GET /v3/accounts`. | | `connection.failed` | The bank or the aggregator failed in a way the user cannot fix: institution-side outage, the bank refusing automated access, or an upstream pipeline error. A locked Finicity account on an existing connection is `failed` too (the sandbox `locked` login does not simulate one). Rejected credentials and an MX-locked account are `requires_action`, not `failed`. | None of the sandbox logins tested in September 2026 produce it: MX `mxuser` / `SERVER_ERROR` and `UNAVAILABLE` end in the widget without creating a connection, and FinBank `locked` and FinBank Decertified link normally. It fires on a real bank or aggregator failure on an existing connection. `last_error` carries a stable code. | | `connection.disconnected` | The source stopped aggregating the connection, or you deleted it. | `DELETE /v3/connections/{id}`. | These are the connection-state events you'll drive in sandbox. The full catalog (`connection.capability_changed`, `account.refresh.completed`, `account.refresh.failed`) and every payload shape is in [Webhooks](/guides/webhooks). ## Testing your webhook handler You don't have to link a bank to prove your endpoint works. Fire a test event: ```bash Test-fire a subscription theme={null} curl -X POST "https://api-sandbox.ledgersyncappv2.com/v3/webhooks/subscriptions/sub_.../test" \ -H "Authorization: Bearer sk_test_..." ``` The test delivery is signed with your **real** signing secret and bypasses the `event_types` filter, so it reaches your handler even for events you didn't subscribe to. It arrives as a `webhook.test` event, a safe way to confirm signature verification, your 2xx-within-30s response, and your `event_id` dedupe logic before real events flow. Verify the signature the same way in sandbox as in production: constant-time HMAC-SHA256 over the raw body, and reject deliveries whose timestamp is more than 5 minutes old. See [Webhooks](/guides/webhooks) for the full recipe. ## Common-error recipes Deliberately trigger each failure mode to harden your handler: * **Invalid credentials** — initiate FinBank, sign in `customer1` / `bad`. The widget rejects the sign-in and keeps the user on the form. No connection is created and no webhook fires, so there is nothing to handle until the user signs in successfully. (FinBank Decertified, once used for a decertification `last_error`, links normally as of September 2026.) * **MFA wrong answer** — initiate Finicity, sign in `customer3` / `go`, submit any OTP other than `999999`. As of September 2026 no MFA challenge appears for this login, so this path cannot be reproduced. * **MX locked user** — initiate MX, sign in `mxuser` / `LOCKED`. `connection_status = LOCKED`; the connection is created and reads `requires_action` with `last_error.code = account_locked_by_institution` and `is_user_actionable: false`. A second `connection.requires_action` follows within seconds, this one under the canonical `con_MX_...` id and carrying that `last_error`. Store that id. * **Upstream 503** — initiate MX, sign in `mxuser` / `UNAVAILABLE`. Meant to simulate an institution-side 503 (`institution_unavailable`, retryable), but as of September 2026 the MX widget stays on "Loading" and no connection is created. * **`requires_action` that lingers** — start a connection and don't finish the widget. It stays `requires_action` (no auto timeout-to-failed today) until the user re-opens the still-valid `widget_url` or you re-initiate. * **Refresh round-trip** — link `customer1` / `go`, `POST /v3/connections/{id}/refresh`, re-read `GET /v3/accounts/{id}/transactions` for the diff. * **Disconnect** — `DELETE /v3/connections/{id}`. `connection.disconnected` fires; further reads return 404. * **Error envelopes** — call a read with a bogus `client_id` or a missing key to see the `{ "error": { "code", "message", "type", "doc_url", "trace_id" } }` shape. Branch on `code` and quote `X-LS-Trace-Id`. See [Errors](/guides/errors). Sandbox keys can't hit live data and live keys can't hit sandbox banks — see [`insufficient_scope`](/guides/errors) if you mix them up. Finicity test customers are auto-pruned after \~30 days of inactivity; MX has no per-credential cooldown. The full initiate → widget → active flow, end to end. Every status a connection moves through and what each means. Subscribe, verify signatures, dedupe, and handle retries. The error envelope, the full code catalog, and how to branch on `code`. # Webhooks Source: https://docs.ledgersyncappv2.com/guides/webhooks Get notified the moment a bank connection goes live, fails, or changes — instead of polling for it. Bank linking is asynchronous. After you hand a user off to the widget, they might sign in and pick accounts in ten seconds — or wander off and finish twenty minutes later on their phone. Webhooks let LedgerSync tell you the instant something happens, so you never have to sit in a polling loop. The single most important event is `connection.active`: it fires when a user finishes linking and data starts flowing. That payload carries the **canonical** connection id (like `con_FINICITY_41294`) — the only id that works on `/accounts`, `/transactions`, and `/statements`. Subscribe to webhooks and you get it delivered for free. Webhooks are the recommended way to learn about connection state. You *can* poll `GET /v3/operations/{id}` during the initial link (see [Connect a bank](/guides/connect-a-bank)), but for everything after — refreshes, disconnects, capability changes — webhooks are the only push channel. ## Subscribe The easiest path is the **Webhooks page in the portal**: paste your endpoint URL, tick the events you want, and copy the signing secret. It's the recommended day-to-day way to manage subscriptions, rotate secrets, and inspect recent deliveries. If you'd rather monitor delivery health from your own code, every subscription also reports it over the API. See [Check delivery health](#check-delivery-health). To do it programmatically, `POST /v3/webhooks/subscriptions`: ```bash Create a subscription theme={null} curl -X POST https://api-sandbox.ledgersyncappv2.com/v3/webhooks/subscriptions \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.com/webhooks/ledgersync", "event_types": [ "connection.active", "connection.failed", "connection.disconnected", "account.refresh.completed" ] }' ``` ```json Response theme={null} { "id": "whk_7c1e4a9b83d24f6ab05e2f8c91d3a640", "url": "https://yourapp.com/webhooks/ledgersync", "event_types": [ "connection.active", "connection.failed", "connection.disconnected", "account.refresh.completed" ], "status": "active", "created_at": "2026-09-03T14:22:08Z", "signing_secret": "whsec_9f2c...shown-once" } ``` The `signing_secret` is shown **once**, at creation. Store it somewhere safe immediately — you'll need it to verify every delivery. If you lose it, rotate the secret from the portal Webhooks page. ## Event catalog There are exactly eight real events, plus a `webhook.test` event you can fire on demand. Subscribe only to the ones you handle. | Event | Fires when | | - | - | | `connection.requires_action` | Something the **user** can fix: waiting on them in the widget (sign-in, MFA, account pick), or their credentials need re-entering, or the bank locked the account. | | `connection.active` | The user finished linking, or a connection recovered from `requires_action` or `failed`. Data is flowing. Carries the **canonical** connection id. | | `connection.failed` | Something the user **cannot** fix: the bank is unavailable, the bank is refusing automated access, or the aggregator reported a failure. See [`requires_action` vs `failed`](/guides/connection-lifecycle#the-status-flow) before prompting for credentials. | | `connection.disconnected` | The source stopped aggregating the connection (for example MX reports the member disconnected or closed), or you deleted it with `DELETE /v3/connections/{id}`. Re-link with reauthorize, or re-initiate. | | `connection.capability_changed` | A single capability on an active connection flipped working ↔ not-working. | | `account.refresh.completed` | A background data refresh for an account finished successfully. | | `account.refresh.failed` | A background data refresh for an account failed. | | `statement.available` | A bank statement PDF was stored for an account for the first time and is ready to download. | | `webhook.test` | A manual test delivery you triggered. Safe to ignore in business logic. | See [Connection lifecycle](/guides/connection-lifecycle) for how `initiated → requires_action → active` (and the failure paths) fit together as a state machine. **Connection events are not only for changes you start.** A status change LedgerSync detects on its own sends the matching `connection.*` event: the bank asking for new credentials, an outage, a recovery. That includes changes found by the scheduled daily refresh, so a connection that recovers after `connection.failed` sends `connection.active`. The status in each event is the one `GET /v3/connections/{id}` returns for the same state. One exception today: a Finicity MFA challenge on an existing connection reads `requires_action` on `GET` without sending an event. **`client_id` on connection events.** `data.connection.client_id` names the client that owns the connection, as the `client_id` you use with the API. It is present whenever LedgerSync can resolve the owner at the time of the event. When it cannot, the key is **omitted**, never `null`, so check for the key. There is no `connection.initiated` **event**. The `initiated` connection *status* exists in the state machine, but the first webhook every new link produces is `connection.requires_action`. Subscriptions listing an unknown event type are rejected with `validation_failed`. There is also **no v3 account-added event**. `connection.active` is not a reliable account-addition signal: it may be absent or emitted again without proving the account set changed. `account.refresh.completed` tells you a refresh finished. Compare paginated account lists before and after the flow to determine which LedgerSync account IDs are new to your integration. To find new account records, re-list all pages of `GET /v3/accounts?client_id=...` and diff on account ID. Add `connection_id` only when you deliberately want to exclude accounts on other connections, including a new connection created by a changed bank login or source. See [Adding a new account](/guides/adding-accounts). ## Delivery shape Every delivery is an HTTP `POST` to your URL with a JSON body and these `X-LS-Webhook-*` headers: | Header | Meaning | | - | - | | `X-LS-Webhook-Event-Id` | Stable unique id for this event. **Dedupe on it** (same value as `event_id` in the body). | | `X-LS-Webhook-Event-Type` | The event type, e.g. `connection.active`. Lets you route before parsing. | | `X-LS-Webhook-Timestamp` | Unix seconds when we produced the event. Part of the signed payload and used for replay protection. | | `X-LS-Webhook-Signature` | `hex(HMAC-SHA256(signing_secret, timestamp + "." + raw_body))`. Verify before trusting anything. | | `X-LS-Webhook-Signature-Prev` | Present **only** for 24h after you rotate the signing secret — the same signature under your *previous* secret. Accept either while you roll your verifier so a rotation never drops a delivery. | Every body shares the same base fields — `event_id`, `type`, `created_at`, `api_version`, `livemode` — plus a `data` object whose shape depends on the event: ```json Example delivery body theme={null} { "event_id": "evt_6d84cf35-b2ee-4734-b408-4e6f10fedad8", "type": "connection.active", "created_at": "2026-07-07T14:22:05Z", "api_version": "2026-05-22", "livemode": false, "data": { "connection": { "id": "con_FINICITY_41294", "client_id": "cli_01HXYZ...", "source": "FINICITY", "status": "active" } } } ``` ## Verify the signature Never trust a delivery you haven't verified. The **signed payload** is the timestamp header, a literal `.`, then the raw body. (Generated code gets this backwards often, signing the body alone. If an assistant wrote your verifier, see [If something is not working](/guides/build-with-ai#4-if-something-is-not-working).) ``` signed_payload = X-LS-Webhook-Timestamp + "." + raw_body expected = hex( HMAC-SHA256( signing_secret, signed_payload ) ) ``` Compare `expected` to `X-LS-Webhook-Signature` in constant time. Also reject anything whose `X-LS-Webhook-Timestamp` is more than five minutes old — that stops replay attacks. Sign the **raw bytes** of the body, exactly as received, prefixed with `timestamp + "."`. If your framework parses JSON and re-serializes it, the bytes change and the signature won't match. Capture the raw body before any JSON middleware touches it. ```python Python (Flask) theme={null} import hmac, hashlib, time from flask import Flask, request, abort SIGNING_SECRET = b"whsec_9f2c..." MAX_AGE_SECONDS = 5 * 60 app = Flask(__name__) @app.post("/webhooks/ledgersync") def handle(): raw = request.get_data() # raw bytes, before JSON parsing timestamp = request.headers.get("X-LS-Webhook-Timestamp", "") signature = request.headers.get("X-LS-Webhook-Signature", "") # Reject stale deliveries (replay protection) if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > MAX_AGE_SECONDS: abort(400) # Signed payload = timestamp + "." + raw body signed = (timestamp + ".").encode("utf-8") + raw expected = hmac.new(SIGNING_SECRET, signed, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): abort(401) event = request.get_json() # ... dedupe on event["event_id"], enqueue, return fast ... return "", 200 ``` ```javascript Node (Express) theme={null} const crypto = require("crypto"); const express = require("express"); const SIGNING_SECRET = "whsec_9f2c..."; const MAX_AGE_SECONDS = 5 * 60; const app = express(); // Capture the RAW body — do not let express.json() reserialize it. app.post( "/webhooks/ledgersync", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body; // Buffer of raw bytes const timestamp = req.get("X-LS-Webhook-Timestamp") || ""; const signature = req.get("X-LS-Webhook-Signature") || ""; // Reject stale deliveries (replay protection) const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!/^\d+$/.test(timestamp) || age > MAX_AGE_SECONDS) { return res.sendStatus(400); } // Signed payload = timestamp + "." + raw body const signed = Buffer.concat([Buffer.from(timestamp + "."), raw]); const expected = crypto .createHmac("sha256", SIGNING_SECRET) .update(signed) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signature); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.sendStatus(401); } const event = JSON.parse(raw.toString("utf8")); // ... dedupe on event.event_id, enqueue, return fast ... res.sendStatus(200); } ); ``` **During a secret rotation**, deliveries also carry `X-LS-Webhook-Signature-Prev` for 24h — the same payload signed with your previous secret. Accept a match on *either* header while you roll the new secret across your instances, and a rotation never drops an event. Use a constant-time comparison — `hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node. A plain `==` leaks timing information an attacker can use to forge signatures. ## Retries and idempotency If your endpoint doesn't return a `2xx` quickly, we retry with **exponential backoff for up to 24 hours**. That means: * **Return `2xx` within 30 seconds.** Do the minimum — verify, enqueue, respond. Push slow work (DB writes, downstream calls) onto a background queue. A slow handler looks like a failure and gets retried. * **Deliveries can repeat.** A retry after a network blip — or a delivery you already processed but responded to slowly — means the same event can arrive more than once. **Dedupe on `event_id`.** Treat it as an idempotency key: if you've seen it, ack and move on. * **Treat `event_id` as an opaque string.** Compare the exact value you received; don't parse it, and don't validate a prefix or a length. Most events carry an `evt_` prefix, while `account.refresh.completed`, `account.refresh.failed` and `statement.available` currently arrive as a bare UUID. An event's id never changes between retries, so exact-string comparison is always the right check. ```python Idempotent handling theme={null} if seen_before(event["event_id"]): return "", 200 # already processed — ack and skip mark_seen(event["event_id"]) enqueue(event) # do the real work off the request path return "", 200 ``` Order is not guaranteed under retries. Design handlers to be self-contained: react to the state in `data`, don't assume the previous event already landed. ## Ordering Webhooks are **not** delivered in order. There is no sequence counter on the envelope, and retries run on their own schedule — a delivery that failed and is being retried can arrive *after* an event that was produced later. Concretely: * A `connection.failed` can land before the `connection.requires_action` that preceded it. * A slow-retried `account.refresh.completed` can arrive after a newer refresh for the same account. * A back-pull of older statements means `statement.available` can deliver an earlier `statement_date` after a later one. Order statements by `statement_date`, not by arrival. * Two events produced milliseconds apart can arrive in either order. So don't build a state machine that assumes the previous event already landed. **Treat every webhook as a signal, not a source of truth:** it tells you *something changed on this connection* — then you re-fetch the current state to act on it. ```text The pattern theme={null} 1. Verify the signature. 2. Dedupe on event_id (X-LS-Webhook-Event-Id) — skip if seen. 3. GET /v3/connections/{id} to read the CURRENT status. 4. Reconcile your local state to what the API returns. ``` Because step 3 always reads current truth, out-of-order arrival is harmless: even if `connection.failed` shows up before `connection.requires_action`, the `GET /v3/connections/{id}` you run for each one returns the connection's real, latest status — so you converge on the right state regardless of arrival order. Every envelope carries `created_at` — the time the event was **produced**. If you need a tiebreaker (e.g. to ignore a stale event you've already superseded), compare `created_at`, not the `X-LS-Webhook-Timestamp` header. The header is regenerated on **each delivery attempt** (it's the send time, used for replay protection), so a retried event's header timestamp reflects when it was *re-sent*, not when it happened. `created_at` is stable across retries. Never treat a webhook payload's `status` as authoritative when order matters. The `data` in a late-arriving retry reflects the state **at the time that event was produced**, which may already be stale. When you need to act, re-fetch with `GET /v3/connections/{id}`. ## Test-fire a delivery Once a subscription exists, send yourself a real signed delivery: ```bash Fire a test event theme={null} curl -X POST \ https://api-sandbox.ledgersyncappv2.com/v3/webhooks/subscriptions/whk_7c1e4a9b83d24f6ab05e2f8c91d3a640/test \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Length: 0" ``` You get back `202` with `{ "event_id": "evt_6d84cf35-b2ee-4734-b408-4e6f10fedad8" }`. There is no `operation_id` and nothing to poll — watch your endpoint for the `webhook.test` delivery. **That id is the delivery's own id.** The identical string arrives in the `X-LS-Webhook-Event-Id` header and as the envelope's `event_id`, so you can match the call to the delivery, or look it up in your dedupe store, with no reformatting. A `202` means queued, not delivered: if your endpoint is unreachable the delivery is retried on the normal schedule, and nothing about the `202` changes. (Send the explicit `Content-Length: 0`; the load balancer in front of the API rejects body-less POSTs without it.) The test delivery is signed with your **real** signing secret, so it exercises your verification code end-to-end. It also **bypasses the `event_types` filter**, so you can trigger it regardless of what you subscribed to. Use it to confirm your endpoint is reachable, your signature check passes, and your dedupe logic works before you rely on live events. ## Check delivery health A test fire tells you your endpoint works *right now*. To tell whether deliveries have actually been arriving *since*, read the two timestamps every subscription carries: ```bash Read delivery health theme={null} curl https://api-sandbox.ledgersyncappv2.com/v3/webhooks/subscriptions/whk_7c1e4a9b83d24f6ab05e2f8c91d3a640 \ -H "Authorization: Bearer sk_test_..." ``` ```json Response theme={null} { "id": "whk_7c1e4a9b83d24f6ab05e2f8c91d3a640", "url": "https://yourapp.com/webhooks/ledgersync", "event_types": ["connection.active", "connection.failed"], "status": "active", "created_at": "2026-09-03T14:22:08Z", "last_success_at": "2026-09-14T11:43:02Z", "last_failure_at": "2026-09-12T21:08:05Z" } ``` | Field | Means | | - | - | | `last_success_at` | The last time we got a `2xx` from your endpoint. | | `last_failure_at` | The last time a delivery failed: a non-`2xx`, a timeout, or a connection error. | Read them together, not separately: * **`last_failure_at` newer than `last_success_at`** means deliveries are failing right now. This is the one to alert on. * **`last_success_at` newer** means the endpoint recovered. An older `last_failure_at` is just history; we never clear it on a later success, deliberately, because an endpoint that keeps flapping is worth being able to see. * **Both absent** means nothing has ever been delivered to this subscription. Expected for one you just created. A subscription stays `active` no matter how badly delivery is going, because we keep retrying rather than disabling your endpoint out from under you. So `status` on its own will never tell you your webhooks are broken. **`last_failure_at` is the field to watch.** Both fields are **omitted entirely** rather than sent as `null` when there is nothing to report, so check whether the key is present rather than comparing to `null`. Failures are stamped on every failed attempt, including retries that will be tried again, so `last_failure_at` moves during a retry storm instead of waiting for the 24-hour budget to run out. Test fires count toward `last_success_at`: they are real signed deliveries to the same URL. ## `connection.capability_changed` in depth An active connection isn't all-or-nothing. A bank might keep serving transactions while its statements feed breaks for a week. `connection.capability_changed` tells you when one **individual capability** flips between working and not-working — so you can, say, warn a user that statements are temporarily unavailable without tearing down the whole connection. **Capabilities that can change:** | Capability | Covers | | - | - | | `transactions` | Transaction data feed. | | `balance` | Current balance. | | `available_balance` | Available (spendable) balance. | | `statements` | Statement documents. | | `check_images` | Check image retrieval. FDE-only — read them with [the check routes](/guides/checks). | **Capability statuses:** `succeeded`, `failed`, `skipped`, `unsupported`, `pending`. ### Hysteresis: no flapping We don't fire on every hiccup. A capability only transitions after a run of consistent observations: * **`failed`** after **3 consecutive** failed observations. * **`succeeded`** after **2 consecutive** succeeded observations. * **Sentinel statuses** (`unsupported`, `skipped`, `pending`) are *not* debounced — they flip **immediately** and arrive with `consecutive_observations: 1`. This debounce keeps a single transient error from spamming you. The event fires **only on a real transition** — not on every refresh that happens to agree with the current state. ### Payload ```json connection.capability_changed theme={null} { "event_id": "evt_7929029e-bc8f-49c2-a8ee-9c8cdcd3196e", "type": "connection.capability_changed", "created_at": "2026-07-07T14:22:05Z", "api_version": "2026-05-22", "livemode": false, "data": { "connection": { "id": "con_FINICITY_41294", "client_id": "cli_01HXYZ...", "source": "FINICITY" }, "change": { "capability": "statements", "previous_status": "succeeded", "current_status": "failed", "consecutive_observations": 3, "last_error": { "code": "statements_not_supported", "message": "This institution doesn't expose statements.", "type": "api_error", "category": "CAPABILITY_UNAVAILABLE" } } } } ``` The per-capability detail lives under `data.change`. `previous_status` and `current_status` tell you the direction of the flip; `consecutive_observations` is how many in a row triggered it (3 for a fail, 2 for a recovery); `last_error` is populated only when `current_status` is `failed`. ## Other payload shapes Most events carry `data.connection` (shown above). Three carry a different `data`. **`connection.requires_action`** carries the same `data.connection` shape as every other connection event: ```json connection.requires_action theme={null} { "event_id": "evt_...", "type": "connection.requires_action", "created_at": "2026-07-07T14:22:05Z", "api_version": "2026-05-22", "livemode": false, "data": { "connection": { "id": "con_...", "client_id": "cli_...", "source": "FINICITY", "status": "requires_action" } } } ``` **No webhook ever carries a URL to send the user to.** This payload tells you *that* the user must act, not *how* to get them back in. Call `POST /v3/connections/{connection_id}/reauthorize` to mint a `reauth_url` for an existing connection; for a brand-new one, the widget URL comes from the initiate operation's `result.connection.action.widget_url`. Code that reads `data.action` off this event gets `undefined`. **`account.refresh.completed`** carries `data.account`, not `data.connection`: ```json account.refresh.completed theme={null} { "event_id": "evt_...", "type": "account.refresh.completed", "created_at": "2026-07-07T14:22:05Z", "api_version": "2026-05-22", "livemode": false, "data": { "account": { "id": "acc_FINICITY_889201", "connection_id": "con_FINICITY_41294", "source": "FINICITY", "refreshed_at": "2026-07-07T14:22:05Z", "new_transaction_count": 12, "current_balance": { "amount": 4210.55, "iso_currency_code": "USD" }, "available_balance": { "amount": 3980.10, "iso_currency_code": "USD" } } } } ``` Treat `new_transaction_count > 0` as your cue to re-read `/accounts/{id}/transactions` for that connection. Optional fields on this payload are **omitted entirely rather than sent as `null`** — check for a key's presence, don't compare it to `null`. That applies to `current_balance`, `available_balance`, and `realized_capabilities` alike. Which of them show up depends on the source: | Field | Present on | | - | - | | `current_balance` | Finicity and MX. Never on FDE. | | `available_balance` | MX only. | | `realized_capabilities` | All three sources. | `current_balance` is normalized exactly as it is on `GET /accounts`: positive is value the client holds, negative is value they owe. The two surfaces agree, so a credit card read from the API and the same card seen on this event carry the same signed figure. It is wrapped as `{ "amount": ..., "iso_currency_code": ... }` here, where `GET /accounts` returns a bare decimal. `available_balance` is available credit rather than a debt, so it is never sign-flipped on either surface. `client_id` on this event is the same `cli_` id you get back from `GET /v3/clients` — use it directly on any client-scoped endpoint. The key is omitted entirely when the owning client cannot be resolved, so treat it as optional rather than assuming it is always present. **`account.refresh.failed`** replaces the balances with a `failed_at` and an `error` (same shape as HTTP error responses, including `code`, `type`, and `category`): ```json account.refresh.failed theme={null} { "type": "account.refresh.failed", "data": { "account": { "id": "acc_FINICITY_889201", "connection_id": "con_FINICITY_41294", "source": "FINICITY", "failed_at": "2026-07-07T14:22:05Z", "error": { "code": "institution_unavailable", "type": "api_error", "category": "INSTITUTION_ERROR" } } } } ``` **`statement.available`** carries `data.statement`. It fires **once**, the first time a statement PDF is stored for a connected account: ```json statement.available theme={null} { "event_id": "evt_...", "type": "statement.available", "created_at": "2026-07-27T14:22:05Z", "api_version": "2026-05-22", "livemode": false, "data": { "statement": { "id": "stmt_FINICITY_9911", "account_id": "acc_FINICITY_110040", "connection_id": "con_FINICITY_41300", "client_id": "cli_01K9YTQ8V3E7WJ4M2N6RB0XZAD", "source": "FINICITY", "statement_date": "2026-06-30", "download_url": "/v3/statements/stmt_FINICITY_9911/download?client_id=cli_01K9YTQ8V3E7WJ4M2N6RB0XZAD" } } } ``` `download_url` already includes the `/v3` prefix, so join it to the API **host** (`https://api-sandbox.ledgersyncappv2.com`), not to the `/v3`-inclusive base URL you use elsewhere — concatenating it onto the base URL yields `/v3/v3/...` and 404s. It returns the PDF bytes and needs the `read:statements` scope. Fetch it on receipt rather than storing it: it is derived from the ids, not a signed link. This is a **save-time** event. Balances and transaction counts are extracted later by OCR and are deliberately absent from the payload. If you need the extracted figures, poll the statement after receiving this event. Two fields are optional and omitted rather than sent as `null`: * `statement_date` is absent when the date could not be determined from the document. * `client_id` is absent when the owning client cannot be resolved — and `download_url` is omitted with it, because that route is client-scoped. A re-pull of a statement you already received does **not** fire this event again. It fires only on first storage. ## Next steps How connections move through initiated, active, failed, and disconnected. The full linking flow, from institution search to a canonical connection id. The error envelope, status codes, and how to branch on `code`. Sandbox banks and credentials to drive every event end-to-end. # Welcome to LedgerSync Source: https://docs.ledgersyncappv2.com/introduction One uniform REST API for bank accounts, balances, transactions, and statements — across multiple data sources, with no plumbing to build yourself. Ask Claude or ChatGPT about your clients' bank accounts in plain words. Setup takes about 5 minutes. LedgerSync gives you **one API** for your users' bank data. You link an account once, and we return accounts, balances, transactions, and statements as clean JSON — no matter which bank or which underlying data source is behind it. Under the hood we pull from three connectable sources and route between them for you: Broadest US coverage, with bank OAuth where the institution supports it. An alternate aggregator that catches banks Finicity misses. LedgerSync's proprietary credential-based extraction, for banks neither aggregator covers. You never pick a source. When you initiate a connection, v3 chooses the right one **server-side** from our institution catalog. There is no `source` parameter — the id you get back (like `con_FINICITY_41294` or `con_MX_1224`) tells you which one answered. **A fourth, read-only source: `PDF`.** When a user's account was set up from **uploaded bank statements**, its accounts and transactions come back with `source: "PDF"` (ids like `acc_PDF_42`, `txn_PDF_8837`). You don't connect `PDF` through the widget, and it never appears under `GET /v3/connections` — a `PDF` account's `connection_id` is `null`. Statements aren't served for `PDF` accounts. Everything else (accounts, transactions, pagination) reads exactly like an aggregator source. ## The data model Everything hangs off four nested objects. Learn these once and the rest of the API reads naturally. ```mermaid theme={null} graph LR A[Client
cli_...] --> B[Connection
con_...] B --> C[Account
acc_...] C --> D[Transaction
txn_...] C --> E[Statement] ``` | Object | What it is | | - | - | | **Client** | Your record of one end-user. You set `external_id`; our id looks like `cli_01HXYZ...`. | | **Connection** | One linked bank for a Client. A Client can have many. Aggregator sources only — uploaded-statement (`PDF`) data has no Connection. | | **Account** | A checking, savings, card, or loan account. Exposed by a Connection, or — for uploaded-statement (`PDF`) data — directly under the Client with `connection_id: null`. | | **Transaction / Statement** | The actual data on an Account. (Statements aren't served for `PDF` accounts.) | Reads are **Client-scoped** — pass `client_id` on every read call. For example: `GET /v3/accounts?client_id=cli_...&connection_id=con_FINICITY_41294`. ## How linking works, in one breath Your end-user, keyed by your own `external_id`. `POST /v3/clients/{id}/connections` with an `institution_id`. You get a `202` and an `operation_id` to poll. Open the returned `widget_url` in the user's browser. They pick their bank, sign in, and choose accounts. You never see their credentials. Once the connection goes `active`, store the canonical `con_...` id and read accounts, transactions, and statements. The full walkthrough lives in [Connect a bank](/guides/connect-a-bank). ## Start here Ask Claude or ChatGPT about your clients' bank accounts in plain words. Setup takes about 5 minutes. One brief to paste into Claude or ChatGPT, plus the steps only you can do. From a sandbox key to live transaction data in a few requests. Initiate, hand off the widget, and read back accounts. Get told the moment a connection goes active, fails, or refreshes. One error envelope, one field to branch on: `code`. Every endpoint, field, and response, in full. Bearer keys, sandbox vs. live, and the trace header. ## Everything is JSON over HTTPS No SDK required. Send and receive JSON, authenticate with a Bearer key, and read the `X-LS-Trace-Id` header on every response for support. Sandbox is one key away — grab an `sk_test_...` key, point at `https://api-sandbox.ledgersyncappv2.com/v3`, and link the built-in **FinBank** test bank with no MFA. ```bash Sandbox smoke test theme={null} curl https://api-sandbox.ledgersyncappv2.com/v3/institutions?q=chase \ -H "Authorization: Bearer sk_test_..." ``` When you're ready to try a full link end to end, head to the [Quickstart](/guides/quickstart).