> ## 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.

# List 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.




## OpenAPI

````yaml /openapi.yaml get /accounts
openapi: 3.1.0
info:
  title: LedgerSync API
  version: '2026-05-22'
  summary: A modern REST API for connecting bank accounts and pulling financial data
  description: >
    > **First time here?** The [**Getting started**
    guide](https://portal.ledgersyncappv2.com/dashboard/getting-started) walks
    the full integration end-to-end (about 15 minutes). This page is the
    endpoint reference for after you've read it.


    Welcome. This is LedgerSync's API for connecting your users' bank

    accounts and pulling their transactions, statements, and account

    details.


    Everything is JSON over HTTPS. Errors are easy to read. Webhooks

    fire as state changes. Sandbox is one key away.


    ## Connection lifecycle and the two `connection.id` formats


    A Connection's `id` changes shape once the user finishes linking

    their bank — there are two distinct identifiers, and you must

    only use the canonical one to read accounts, transactions, or

    statements.


    1. **Placeholder id (pending state).** `POST /v3/clients/{id}/connections`
       returns `202 Accepted`. The initiate operation first surfaces
       a `connection_with_action` result whose embedded
       `connection.id` is a UUID-prefixed placeholder of the form
       `con_<uuid>` (e.g. `con_a1b2c3d4-e5f6-47a8-9b12-c3d4e5f6a7b8`), paired
       with a `widget_url`. The placeholder is **not** a valid id for
       any other endpoint — calling
       `GET /v3/connections/{placeholder_uuid}` returns
       `400 Bad Request` with `missing source separator`. Treat it as
       opaque routing state for the widget step only.
    2. **Canonical id (active state).** Once the user finishes the
       widget and the source pushes its first callback, the
       connection moves to `status=active` and the
       `connection.active` webhook fires. Exactly two surfaces report
       the canonical id `con_<SOURCE>_<bankAccountId>` (for example
       `con_FINICITY_41294`, `con_MX_1224`): the `connection.active`
       webhook payload, and `GET /v3/clients/{id}/connections`.
       **This** is the id every other endpoint accepts
       (`/accounts`, `/transactions`, `/statements`, refresh, delete).

    **Tell the two apart by shape, not by where you got them.** The

    initiate operation's `result` is a snapshot written once, at the

    moment the widget URL is issued, and nothing rewrites it

    afterwards. The operation is already `succeeded` while the user

    still has the widget open, so `status=succeeded` does **not** mean

    the bank is linked, and re-polling later returns exactly what it

    returned the first time. For Finicity and MX that is a

    placeholder. (An FDE initiate can return the canonical id

    directly; checking the shape covers both without special-casing

    the source.)


    The correct integration pattern is therefore:


    - Initiate the connection, capture `operation_id`.

    - Open `result.connection.action.widget_url` for the user. Store
      `result.connection.id` only if it matches
      `con_<SOURCE>_<bankAccountId>`; never store a `con_<uuid>`.
    - Wait for the `connection.active` webhook and read
      `data.connection.id` from it. If you cannot run a webhook
      handler, poll `GET /v3/clients/{id}/connections` and take the
      canonical id from there.

    Do not try to `GET /v3/connections/{placeholder_uuid}` during the

    pending window. Listing endpoints

    (`GET /v3/clients/{id}/connections`, `GET /v3/connections/{id}`)

    only ever return canonical ids, because they only surface

    connections that have reached `active`.


    ## Quick start


    Mint a sandbox key in the developer portal, then create your first

    **Client** (your end-user — the person whose bank we'll be reading):


    ```bash

    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"}'
    ```


    You'll get back a `Client` object with an `id`. From there the

    full flow is:


    1. **Register a webhook** at `POST /v3/webhooks/subscriptions` so
       you can be notified when the connection progresses.
    2. **Initiate a connection** at
       `POST /v3/clients/{id}/connections` with an `institution_id`
       from `GET /v3/institutions`. LedgerSync's router picks the
       underlying source. Every source hands back a `widget_url` in
       the `connection.requires_action` result — for FDE it points at
       a LedgerSync-hosted connect page where credentials are entered,
       never sent to the API.
    3. **Open the widget URL** in your user's browser. They pick
       their bank, log in, and choose accounts to share.
    4. **Receive `connection.active`** on your webhook URL. List
       accounts and transactions.

    **Don't want to build your own bank picker?** Skip steps 2–3: call

    `POST /v3/clients/{id}/connect-session` to get a LedgerSync-hosted

    link, and email it to your member. They open it, search for their

    own bank, pick it, and connect it — all on a page we host, with no

    `institution_id` needed up front. You still receive

    `connection.active` on your webhook exactly as above. Revoke a link

    at any time with `DELETE /v3/clients/{id}/connect-session/{sid}`.

    (This is the v3 replacement for the old `account/add/lite` widget.)


    Want a step-by-step walkthrough with curl per step plus a sandbox

    shortcut that skips the widget? Read the

    [Getting started
    guide](https://portal.ledgersyncappv2.com/dashboard/getting-started).


    ## Authentication


    Pass your secret key in the `Authorization` header as a Bearer

    token: `Authorization: Bearer sk_test_...` (sandbox) or

    `Bearer sk_live_...` (production). Treat secret keys like

    passwords — never embed them in mobile apps or front-end code.


    ## Conventions


    **Sync vs async.** Most endpoints respond synchronously — you

    get the resource back right away. A handful of flows are

    genuinely async (initiating a bank connection, extracting a

    statement, generating a verification report); those return

    `202 Accepted`

    with an `operation_id` you can poll, and the matching webhook

    fires when the work finishes.


    **Errors.** Every error is `{ "error": { "code", "message", "doc_url",
    "type" } }`.

    Branch on `code`. Click `doc_url` for the troubleshooting page.

    Every response carries an `X-LS-Trace-Id` header — paste it in

    support tickets and we can jump straight to your request.


    **Webhooks.** Every delivery carries `X-LS-Webhook-Signature`:

    hex HMAC-SHA256 over `X-LS-Webhook-Timestamp`, a literal `.`, and

    the **raw request body**, in that order. Signing the body alone

    will not match. Verify before trusting the payload. Full event

    catalog + signature example on the [Webhooks tab](#webhooks).


    ## Need help?


    Email [support@ledgersync.com](mailto:support@ledgersync.com) or

    open a thread in the developer portal. Quote the `X-LS-Trace-Id`

    from your response — it makes everything faster.
  contact:
    name: LedgerSync Developer Support
    email: support@ledgersync.com
    url: https://portal.ledgersyncappv2.com
  license:
    name: Proprietary
    url: https://ledgersync.com/terms
servers:
  - url: https://api-sandbox.ledgersyncappv2.com/v3
    description: >-
      Sandbox — use `sk_test_...` keys from the developer portal. Routes to the
      real Finicity and MX sandbox banks (FinBank, mxbank) via the same
      connector code paths as production.
  - url: https://api.ledgersyncappv2.com/v3
    description: >-
      Production — use `sk_live_...` keys. Hits real banks via Finicity, MX, or
      FDE depending on the connection.
security:
  - bearer: []
tags:
  - name: Clients
    description: |
      A **Client** is the end-user whose bank accounts you're managing
      — usually a real person or business. You create one Client per
      user before linking any accounts. Pass your own
      `external_id` to join Clients back to records in your system.
  - name: Connections
    description: |
      A **Connection** links a Client to a bank or financial-data
      source (Finicity, MX, or FDE). One Client can have many
      Connections — one per bank they've linked. You initiate a
      Connection, the user finishes it (widget or MFA), and a
      `connection.active` webhook tells you when it's ready.
  - name: Accounts
    description: |
      Once a Connection goes active, the bank's accounts (checking,
      savings, credit, loans) show up here. Use these endpoints to
      list, inspect, and refresh them.
  - name: Transactions
    description: |
      Posted transactions and pending charges for an account. New
      transactions are surfaced by a future push-delivery event for
      transactions after each refresh; you can also list/page them
      directly.
  - name: Statements
    description: |
      Period statements (usually monthly PDFs) the bank publishes.
      For FDE-sourced statements, extracted line items are attached
      after OCR completes.
  - name: Checks
    description: |
      Images of paper checks, captured alongside FDE statement
      extraction. FDE-only: Finicity and MX report `check_images`
      as `unsupported`. Fronts only — LedgerSync does not capture
      the back of a check, so there is no `side` field.
  - name: Operations
    description: |
      The handful of LedgerSync calls that genuinely run asynchronously
      return an `operation_id`. Poll it here, or just listen for the
      matching webhook — your call.
  - name: Webhooks
    description: |
      Manage where LedgerSync delivers event notifications. Each
      subscription has its own HMAC signing secret. Rotate it whenever
      you want — the old secret stays valid for 24 hours so deploys
      don't break verification.
  - name: ApiKeys
    description: |
      Create, list, and revoke API keys. Keys are scoped to one
      environment (sandbox or live). The plaintext secret is returned
      exactly once at creation — store it somewhere safe.
  - name: Institutions
    description: |
      The unified v3 institution catalog. Search by name; pick a row;
      pass its `id` to `POST /clients/{client_id}/connections`.
      LedgerSync's router decides which underlying source (Finicity,
      MX) to use — integrators don't pick a source.
  - name: Settings
    description: |
      Account-level settings the integrator manages once per environment.
      Currently exposes the redirect-URL allowlist consulted at
      `/connections/initiate`. Same scope as Stripe Connect / Plaid Link /
      OAuth — register your app's redirect URIs once, not per end-user.
  - name: Sandbox
    description: |
      Sandbox-only helpers. Use `GET /sandbox/institutions` to list
      the sandbox-eligible test banks you can target when creating a
      sandbox connection (Finicity FinBank, MX `mxbank`, FDE test
      extractors). Lifecycle transitions and webhooks come from the
      same real connector paths as live traffic — no synthetic
      lifecycle driver, no replay fixtures. Drive the widget with
      the public test bank credentials documented in the developer
      portal.
  - name: Metrics
    description: |
      Read-only aggregations powering the developer portal's "Metrics"
      page. Every endpoint is scoped to the authenticated principal's
      customer and environment — `sandbox` and `live` data never mix
      across the wire. Default window when `from`/`to` are omitted is
      the last 30 days (max 366).
  - name: Health
    description: A simple liveness probe. No auth required.
  - name: PortalGating
    description: |
      Developer-portal access-request endpoints (sandbox + live gating).
      These are HMAC-signed calls from the LedgerSync portal to v3 and not
      part of the integrator-facing API surface. Documented here so the
      single spec stays authoritative.
paths:
  /accounts:
    get:
      tags:
        - Accounts
      summary: List accounts
      description: |
        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.
      operationId: listAccounts
      parameters:
        - $ref: '#/components/parameters/ClientId'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: connection_id
          description: |
            Only return Accounts surfaced by this Connection. Must be the
            canonical id (`con_<SOURCE>_<bankAccountId>`, e.g.
            `con_FINICITY_41294`); the placeholder UUID returned during
            initiate is rejected with 400.
          schema:
            type: string
            example: con_FINICITY_41294
        - in: query
          name: source
          description: Only return Accounts from this aggregation source.
          schema:
            $ref: '#/components/schemas/Source'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - has_more
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Account'
                  next_cursor:
                    type: string
                    nullable: true
                    description: >-
                      Opaque cursor to pass as `cursor` for the next page.
                      Null/absent when has_more is false.
                  has_more:
                    type: boolean
                    description: Whether more items exist beyond this page.
        '401':
          $ref: '#/components/responses/Unauthorized'
components:
  parameters:
    ClientId:
      name: client_id
      in: query
      required: true
      description: |
        The Client (`cli_…`) to scope the read to. Required on account,
        transaction, and statement reads — LedgerSync's backend data APIs
        are scoped per end-user, so these resources are resolved within a
        single Client.
      schema:
        type: string
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque cursor returned by a previous list response.
      schema:
        type: string
    Limit:
      name: limit
      in: query
      required: false
      description: Max items per page. Values above the maximum are capped, not rejected.
      schema:
        type: integer
        minimum: 1
        maximum: 500
        default: 100
  schemas:
    Source:
      type: string
      description: |
        Financial-data source backing an account. Aggregator sources
        (`FINICITY`, `MX`) and `FDE` back a Connection; `PDF` does not.

        Values:
        - `FINICITY` — Finicity aggregation
        - `MX` — MX aggregation
        - `FDE` — Financial Document Extraction (LedgerSync proprietary)
        - `PDF` — Uploaded bank statements. Read-only: exposed on
          `/accounts` and `/transactions` (statements are not served for
          PDF), and never as a Connection (PDF accounts have
          `connection_id: null`).
      enum:
        - FINICITY
        - MX
        - FDE
        - PDF
      x-enum-descriptions:
        - Finicity aggregation
        - MX aggregation
        - Financial Document Extraction (LedgerSync proprietary)
        - Uploaded bank statements (read-only; not a connection source)
    Account:
      type: object
      required:
        - id
        - source
        - iso_currency_code
        - type
      properties:
        id:
          type: string
          example: acc_FINICITY_45
        connection_id:
          type:
            - string
            - 'null'
          description: >-
            Owning connection — canonical id (`con_<SOURCE>_<bankAccountId>`).
            Null on a sandbox single-fetch (a sandbox account id doesn't encode
            its connection). Always present when listing accounts by connection.
          example: con_FINICITY_41294
        client_id:
          type: string
        source:
          $ref: '#/components/schemas/Source'
        name:
          type: string
        type:
          type: string
          enum:
            - checking
            - savings
            - credit_card
            - loan
            - investment
            - other
          description: >-
            Coarse account classification, normalized by LedgerSync so the same
            six values mean the same thing on every source. Always present.
            `other` means the source did not classify the account, or classified
            it as something with no home in this enum (a rewards balance, an MX
            "other liability") - read `subtype` to tell those apart. This list
            is closed and stable; new upstream products appear in `subtype`,
            never here.
          example: credit_card
        subtype:
          type: string
          pattern: ^[a-z0-9_]+$
          description: >-
            The finer label the source reported, before it was collapsed into
            `type`. Always present. This is an OPEN vocabulary that grows as
            sources report new products, which is why it is a plain string and
            not an enum: a new value here is never a breaking change. Branch on
            `type`; use `subtype` for display, or for finer routing you control
            and can update. `unknown` when the source reported no type at all,
            which today is every FDE account and roughly half of the accounts
            created from uploaded statements. Examples of the collapse:
            `money_market` and `certificate_of_deposit` report `type: savings`;
            `mortgage` and `line_of_credit` report `type: loan`; `ira`, `roth`
            and `plan_401k` report `type: investment`; `bank` reports `type:
            checking`.
          example: money_market
        mask:
          type:
            - string
            - 'null'
          description: Last 4 digits of the account number (Plaid-style mask).
          example: '1234'
        iso_currency_code:
          type: string
          description: ISO-4217 currency code. Defaults to USD when the source omits it.
          example: USD
        current_balance:
          type:
            - number
            - 'null'
          description: >-
            Most recent balance in the account's currency, as a decimal, signed
            from the account holder's point of view and normalized by LedgerSync
            so it means the same thing on every source. Positive is value you
            hold, negative is value you owe, so summing this across a client's
            accounts gives net worth. A credit card or loan carrying a balance
            is therefore 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 is positive. Null when the source
            hasn't reported one yet. The `account.refresh.completed` webhook
            reports the same account with the same sign, wrapped as `{amount,
            iso_currency_code}` rather than a bare decimal.
          example: -1234.56
        available_balance:
          type:
            - number
            - 'null'
          description: >-
            Available balance, when the source distinguishes it from the current
            balance. On a credit card this is available credit, an amount you
            can spend, so it is NOT sign-flipped the way `current_balance` is
            and is normally positive. MX is the only source that reports it, and
            only for some accounts (common on checking, rare on cards), so it is
            null far more often than not. Check
            `realized_capabilities.available_balance` rather than assuming it is
            present.
        last_refreshed_at:
          type:
            - string
            - 'null'
          format: date-time
        oldest_transaction_date:
          type:
            - string
            - 'null'
          format: date
          description: >-
            How far back our transaction history for this account actually goes:
            the `date` of the oldest transaction we hold. It is the minimum
            `date` you will see if you page this account's entire history
            through `GET /accounts/{account_id}/transactions`. Measured from the
            transactions we hold, not a claim by the aggregator about what it
            could supply. Null means we hold no transactions for this account at
            all, which is normal for one that was linked but has not completed a
            first refresh. It moves backwards, never forwards, when a backfill
            lands, and it is not a promise about what a future refresh will
            retrieve.
          example: '2023-04-11'
        liabilities:
          description: |
            Credit-card terms for this account: what the client can borrow,
            what they owed at the last statement close, and what the bank
            wants next. Null for every account that is not a credit card or
            line of credit, and also for cards at institutions that do not
            report these values, so its presence is itself the signal that
            anything is known. Individual fields inside it are independently
            nullable for the same reason.
          oneOf:
            - $ref: '#/components/schemas/AccountLiabilities'
            - type: 'null'
        realized_capabilities:
          description: |
            Per-account observed outcome of the most recent attempt at
            each capability. Today the backend tracks status at the
            bank level — accounts within the same connection share this
            snapshot. Per-account divergence (one account in a
            connection failed but others succeeded) requires a backend
            extension and is deferred.
          oneOf:
            - $ref: '#/components/schemas/RealizedCapabilities'
            - type: 'null'
        created_at:
          type:
            - string
            - 'null'
          format: date-time
          description: |
            When LedgerSync first stored this account, which is when we
            first saw it at the source. For accounts present at link time
            this tracks the connection; for one shared later it is when it
            arrived. There is no `account.added` webhook, so this is the
            field to sort or diff on when you re-list accounts to find what
            is new. It never moves afterwards.
        updated_at:
          type:
            - string
            - 'null'
          format: date-time
    AccountLiabilities:
      type: object
      description: |
        Credit-card terms, reported per account. Every field is independently
        nullable: institutions differ in what they publish, and a value absent
        here means the bank did not report it, never that it is zero.

        Availability differs by source. Finicity reports all five. MX reports
        every field except the statement balance, which it does not track at
        all. Portal connections and uploaded statements report none of them, so
        this object is always null there.
      properties:
        credit_limit:
          type:
            - number
            - 'null'
          description: The account's total credit line, as a positive amount.
          example: 65000
        available_credit:
          type:
            - number
            - 'null'
          description: >-
            Credit still available to draw on, as a positive amount. Typically
            the credit limit minus the current balance, computed by the bank
            rather than by us, so it may not reconcile exactly against
            `current_balance` at any given instant.
          example: 50596.54
        statement_balance:
          type:
            - number
            - 'null'
          description: >-
            The amount owed at the last statement close, NEGATIVE like every
            other amount owed in this API, so it agrees in sign with
            `current_balance` on the same account. This is a normalized
            magnitude: institutions disagree with each other on the sign of this
            figure, several of them reporting a debt as positive while reporting
            the same debt as a negative account balance, so passing the reported
            sign through would make the field unusable. One consequence is that
            a card that was in credit at statement close, because the holder
            overpaid, is reported as owed.
          example: -14381.66
        minimum_payment:
          type:
            - number
            - 'null'
          description: >-
            The minimum the bank requires by `payment_due_date`, as a positive
            amount.
          example: 143
        payment_due_date:
          type:
            - string
            - 'null'
          format: date
          description: >-
            When the next payment is due. This is the bank's own figure and it
            is only as current as the last refresh: a bank that has not yet
            published a new cycle will still be reporting the previous one, so a
            date in the past means the cycle has rolled over and we have not
            seen the new one, not that a payment is overdue. Check
            `last_refreshed_at` before acting on it.
          example: '2026-09-24'
    RealizedCapabilities:
      type: object
      description: |
        Observed per-capability outcomes. Exhaustive — every capability
        key is always present. Rolled-up across accounts at the
        Connection level; per-account ground truth at the Account level.
      required:
        - transactions
        - balance
        - available_balance
        - statements
        - check_images
      properties:
        transactions:
          $ref: '#/components/schemas/RealizedCapability'
        balance:
          $ref: '#/components/schemas/RealizedCapability'
        available_balance:
          $ref: '#/components/schemas/RealizedCapability'
        statements:
          $ref: '#/components/schemas/RealizedCapability'
        check_images:
          $ref: '#/components/schemas/RealizedCapability'
    ErrorEnvelope:
      type: object
      description: |
        Error envelope returned as the top-level body of every non-2xx
        HTTP response. The actual error payload lives in the `error`
        field — branch on `error.code`.
      required:
        - error
      properties:
        error:
          $ref: '#/components/schemas/ErrorInfo'
    RealizedCapability:
      type: object
      required:
        - status
      properties:
        status:
          $ref: '#/components/schemas/CapabilityRealizedStatus'
        last_success_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Timestamp of the most recent successful delivery, when known.
        next_refresh_after:
          type:
            - string
            - 'null'
          format: date-time
          description: |
            Earliest moment the next attempt is expected to fire.
            Null when no further attempt is scheduled (e.g. `unsupported`).
        last_error:
          description: |
            Source-translated error from the most recent failed attempt.
            Same shape as the top-level `ErrorInfo` envelope; only
            populated when `status=failed`.
          oneOf:
            - $ref: '#/components/schemas/ErrorInfo'
            - type: 'null'
    ErrorInfo:
      type: object
      description: |
        The inner error payload. Used as the top-level body of an
        HTTP error response (wrapped in `ErrorEnvelope`) and as the
        embedded `error` field on resources that record a failure
        (like `Operation.error`).
      required:
        - code
        - message
        - type
      properties:
        code:
          type: string
          description: |
            Stable machine-readable identifier. Branch on this in
            your code, not on `message`.
          example: unknown_api_key
        message:
          type: string
          description: Plain-English explanation safe to log.
          example: The API key you presented doesn't match any active key.
        doc_url:
          type: string
          format: uri
          description: |
            Link to the docs page for this specific error code.
            Shareable with teammates in support tickets.
          example: https://portal.ledgersyncappv2.com/errors/unknown_api_key
        type:
          type: string
          description: |
            Stripe-style broad failure-mode category — useful for
            "treat all of these the same way" branches. Derived
            from the HTTP status. Orthogonal to `category`, which
            classifies by origin.
          enum:
            - auth_error
            - invalid_request
            - rate_limit_error
            - idempotency_error
            - not_found
            - api_error
          example: auth_error
        category:
          type: string
          description: |
            Plaid-style coarse classification by origin. Branch on
            this for routing logic — retry the request, surface to
            the end user in the widget, or page on-call. Orthogonal
            to `type` (which is HTTP-status-derived).
          enum:
            - AUTH_ERROR
            - INSTITUTION_ERROR
            - CAPABILITY_UNAVAILABLE
            - CONNECTION_ERROR
            - RATE_LIMIT
            - INVALID_REQUEST
            - RESOURCE_NOT_FOUND
            - PLATFORM_ERROR
          example: AUTH_ERROR
        is_user_actionable:
          type: boolean
          description: |
            True when the end user can resolve this error (re-enter
            credentials, complete MFA, accept an updated
            agreement). False when the error requires
            institution-side or LS platform-side action. Useful for
            deciding whether to send the end user back to the
            widget or surface an "operational issue" banner.
          example: true
        source_diagnostic_code:
          type: string
          pattern: ^(FIN|MX|FDE)-[A-Z0-9_]+$
          description: |
            Opaque upstream-source diagnostic identifier (e.g.
            `FIN-103`, `MX-DENIED`). Present only on errors that
            originated at an aggregator/extractor. Use for support
            triage — do NOT branch on this value; the unified
            `code` and `category` are the integrator-facing
            vocabulary.
          example: FIN-103
        param:
          type: string
          description: |
            On every 400 or 413 that is about one request field
            (`validation_failed` and `invalid_request` alike), the
            field that tripped the check. Dot-separated path for
            nested fields. Absent on errors that are not about a
            field (auth, not found, upstream faults).
          example: client.email
        trace_id:
          type: string
          description: |
            Same as the `X-LS-Trace-Id` response header — paste in
            a support ticket to jump to the request.
          example: 4bf92f3577b34da6a3ce929d0e0e4736
        errors:
          type: array
          description: |
            Per-field validation errors (only present on 400 when
            multiple fields failed at once).
          items:
            type: object
            required:
              - param
              - message
            properties:
              param:
                type: string
                example: client.email
              message:
                type: string
                example: must be a valid email address
              code:
                type: string
                example: invalid_email
    CapabilityRealizedStatus:
      type: string
      description: |
        Observed outcome of the most recent attempt at a capability.
        `succeeded` — last attempt delivered data; `last_success_at` is
        populated. `failed` — last attempt errored; `last_error` carries
        the source-translated diagnostic. `skipped` — capability not
        attempted this cycle (catch-up exhausted, integrator opt-out).
        `unsupported` — matching `expected_capabilities` promise is
        `never`; entry is still emitted so integrators don't have to
        disambiguate "missing means unsupported" from "not yet
        attempted". `pending` — initial sync running or attempt
        in flight.
      enum:
        - succeeded
        - failed
        - skipped
        - unsupported
        - pending
  responses:
    Unauthorized:
      description: |
        Either the `Authorization` header is missing or the bearer
        token doesn't match an active API key. Double-check the key
        and the environment — sandbox keys can't be used against the
        production base URL and vice versa.
      headers:
        X-LS-Trace-Id:
          $ref: '#/components/headers/X-LS-Trace-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  headers:
    X-LS-Trace-Id:
      description: Distributed-trace ID for this request. Quote in support tickets.
      schema:
        type: string
        example: 4bf92f3577b34da6a3ce929d0e0e4736
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: LedgerSync API key
      description: |
        Pass your secret key in the `Authorization` header as a Bearer
        token: `Authorization: Bearer sk_test_...` (sandbox) or
        `Bearer sk_live_...` (production).

        Keys are created in the developer portal and the plaintext
        secret is shown exactly once at creation. Treat them like
        passwords — never embed them in mobile apps or front-end code.

````