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

# Refresh a connection

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




## OpenAPI

````yaml /openapi.yaml post /connections/{connection_id}/refresh
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:
  /connections/{connection_id}/refresh:
    parameters:
      - in: path
        name: connection_id
        required: true
        description: |
          Canonical Connection id (`con_<SOURCE>_<bankAccountId>`). The
          placeholder UUID returned during initiate is rejected here with
          400 — only `active` connections can be refreshed.
        schema:
          type: string
          example: con_FINICITY_41294
    post:
      tags:
        - Connections
      summary: Refresh a connection
      description: |
        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` 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.
      operationId: refreshConnection
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        description: |
          No body. Send `Content-Length: 0` so the upstream load
          balancer doesn't reject the request with 411.
        content:
          application/json:
            schema:
              type: object
              nullable: true
      responses:
        '202':
          $ref: '#/components/responses/Accepted'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '411':
          description: |
            Returned by the upstream HTTPS load balancer when a POST
            arrives without a `Content-Length` header. Retry with
            `Content-Length: 0` set explicitly.
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '502':
          description: |
            The upstream aggregator or the backend was unreachable
            while starting the refresh, so nothing was queued. Body
            carries the canonical `ErrorEnvelope` (typically
            `error.code = "upstream_unavailable"`); retry shortly.
          headers:
            X-LS-Trace-Id:
              $ref: '#/components/headers/X-LS-Trace-Id'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        > **Reserved, not yet honored.** The header is accepted and
        > ignored today: nothing reads it, no response is replayed,
        > and `idempotency_conflict` is never returned. **Do not
        > auto-retry a failed POST on the assumption that this
        > protects you** — a retried `POST /clients` or
        > `POST /clients/{id}/connections` creates a duplicate. Send
        > the header if you want to be ready for it; do not depend on
        > it. The behavior described below is the intended contract
        > for a future release.

        Safe-retry key for POSTs. Send any unique string per logical
        request (a UUIDv4 is great). If the network drops and you retry
        with the same `Idempotency-Key` within 24 hours, you get the
        exact same response back instead of creating a duplicate.

        **Cached responses include failures (4xx and 5xx) too.** If a
        call failed because of a bad input and you want to try again
        with the corrected input, use a **fresh** key — otherwise
        you'll keep getting the cached failure.
      schema:
        type: string
        maxLength: 255
        example: 6f1a8c50-3e9c-4d4a-b1f5-2c5b9a2f7d11
  responses:
    Accepted:
      description: |
        The request was accepted but the underlying work runs
        asynchronously. Use the returned `operation_id` to poll
        `GET /operations/{operation_id}` or just wait for the matching
        webhook event.
      headers:
        X-LS-Trace-Id:
          $ref: '#/components/headers/X-LS-Trace-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/OperationAck'
    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'
    NotFound:
      description: |
        We didn't find a resource matching the path parameters. Common
        causes: the id belongs to a different customer's key, the
        resource was deleted, or there's a typo in the id.
      headers:
        X-LS-Trace-Id:
          $ref: '#/components/headers/X-LS-Trace-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    TooManyRequests:
      description: |
        You hit the rate limit. Back off, then retry after the number
        of seconds in the `Retry-After` header. The
        `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers tell
        you the current window state.
      headers:
        X-LS-Trace-Id:
          $ref: '#/components/headers/X-LS-Trace-Id'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
        X-RateLimit-Policy:
          $ref: '#/components/headers/X-RateLimit-Policy'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      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
    X-RateLimit-Limit:
      description: |
        Requests allowed in the per-minute window. The day-window and the
        live-tier burst (2x over 60s) are advertised via
        `X-RateLimit-Policy`. Per-environment numbers:
          - sandbox: 120 req/min, 10,000 req/day
          - live:    60 req/min,  50,000 req/day, 2x burst over 60s
        Connection-initiation concurrency (sandbox 10 / live 50) and
        webhook-event/day budgets (sandbox 5k / live 25k) are enforced
        at the service layer and are NOT counted in these headers.
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Lowest remaining count across all active windows.
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix seconds when the per-minute window resets.
      schema:
        type: integer
    X-RateLimit-Policy:
      description: |
        IETF draft-ietf-httpapi-ratelimit-headers — comma-separated
        policy entries `<limit>;w=<seconds>` advertising every enforced
        window so an SDK consumer can tell which window tripped them.
        Example: `60;w=60, 50000;w=86400`.
      schema:
        type: string
    Retry-After:
      description: Seconds to wait before retrying (on 429 / 503).
      schema:
        type: integer
  schemas:
    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'
    OperationAck:
      type: object
      description: Returned with 202 Accepted for async operations.
      required:
        - operation_id
        - status
        - poll_url
      properties:
        operation_id:
          type: string
          example: op_01HXYZ8A6N7K2W9PQ4T5Z3V6E0
        status:
          type: string
          enum:
            - queued
        poll_url:
          type: string
          format: uri
          example: >-
            https://api-sandbox.ledgersyncappv2.com/v3/operations/op_01HXYZ8A6N7K2W9PQ4T5Z3V6E0
        estimated_seconds:
          type: integer
          description: Rough ETA for completion. Best-effort.
    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
  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.

````