> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ledgersyncappv2.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Changelog

> New features, improvements, and fixes to the LedgerSync v3 API. Newest first.

<Update label="2026-09-04" tags={["Accounts"]}>
  ## 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).
</Update>

<Update label="2026-09-04" tags={["Transactions", "Accounts"]}>
  ## 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.
</Update>

<Update label="2026-09-02" tags={["Accounts"]}>
  ## 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.
</Update>

<Update label="2026-08-25" tags={["Connections"]}>
  ## 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).
</Update>

<Update label="2026-07-21" tags={["Connections"]}>
  ## Reauthorize (fix) a broken connection in place

  New `POST /v3/connections/{connection_id}/reauthorize` — repair a connection
  that reports `status: requires_action` **without** minting new 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`).
  * Keeps the **same** `con_` / `acc_` / `txn_` ids, unlike starting a fresh
    connection — so a delta re-sync over the overlap window dedupes cleanly
    against transactions you already have.
  * 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).
</Update>

<Update label="2026-07-17" tags={["Checks", "FDE"]}>
  ## 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).
</Update>

<Update label="2026-07-15" tags={["Connections", "MX"]}>
  ## 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.
</Update>

<Update label="2026-07-10" tags={["Accounts", "Transactions"]}>
  ## 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).
</Update>

<Update label="2026-07-08" tags={["Pagination"]}>
  ## 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).
</Update>

<Update label="2026-07-02" tags={["Statements"]}>
  ## 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`.
</Update>
