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

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

<Info>
  **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.
</Info>

<Tip>
  Rather have an assistant write this for you? [Build with an AI assistant](/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.
</Tip>

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

<Steps>
  <Step title="Import the collection">
    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.
  </Step>

  <Step title="Set two variables">
    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` |
  </Step>

  <Step title="Run the Quickstart folder">
    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.
  </Step>
</Steps>

<Tip>
  Prefer a raw, always-current endpoint list? Postman can import our OpenAPI
  spec directly: **Import → Link** →
  `https://api.ledgersyncappv2.com/v3/openapi.yaml`.
</Tip>

## 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<br/>cli_9f2a4c1b...] --> B[Connection<br/>con_FINICITY_41294]
  B --> C[Account<br/>acc_FINICITY_...]
  C --> D[Transactions<br/>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.

<CardGroup cols={3}>
  <Card title="Finicity" icon="building-columns">
    Broadest US coverage. OAuth where the bank supports it.
  </Card>

  <Card title="MX" icon="shuffle">
    Alternate aggregator. Catches banks Finicity misses.
  </Card>

  <Card title="FDE" icon="file-lock">
    LedgerSync's proprietary extraction for banks neither aggregator covers.
  </Card>
</CardGroup>

<Steps>
  <Step title="Mint a sandbox key">
    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.
  </Step>

  <Step title="Register a webhook subscription">
    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. 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"
    }
    ```

    <Warning>
      The `signing_secret` is shown **once**. Store it now — you need it to verify every incoming delivery. If you lose it, rotate the subscription.
    </Warning>

    <Note>
      Subscription ids start with `whk_` followed by 32 hex characters. Anything else is rejected as unparseable and comes back `404`.
    </Note>

    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": "..." }` and nothing to poll — watch your endpoint for the `webhook.test` delivery. The response returns a bare UUID; the delivered `X-LS-Webhook-Event-Id` is the same UUID as `evt_` plus its digits with the dashes removed.
  </Step>

  <Step title="Create a Client">
    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.

    <Warning>
      **`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.
    </Warning>
  </Step>

  <Step title="Discover the institution">
    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.

    ```bash theme={null}
    curl "$LS_BASE/institutions?q=FinBank" \
      -H "Authorization: Bearer $LS_KEY"
    ```

    ```json theme={null}
    {
      "data": [
        {
          "id": "ins_0a01a5430925d0b2",
          "name": "FinBank",
          "capabilities": {
            "oauth": false,
            "transactions": true,
            "statements": true,
            "check_images": false
          }
        }
      ]
    }
    ```

    <Tip>
      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.
    </Tip>
  </Step>

  <Step title="Initiate the Connection">
    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_0a01a5430925d0b2" }'
    ```

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

    <Warning>
      **`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_<uuid>` **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_<SOURCE>_<number>` 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`.
    </Warning>
  </Step>

  <Step title="Hand off the widget">
    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).

    <Note>
      **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.
    </Note>
  </Step>

  <Step title="Receive connection.active">
    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.)

    <Note>
      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.
    </Note>
  </Step>

  <Step title="List accounts and transactions">
    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
        }
      ]
    }
    ```

    <Check>
      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.
    </Check>
  </Step>
</Steps>

## 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_<SOURCE>_<n>`. Use it as your idempotency key — see dedupe below. |
| `account_id`        | string                | The owning account, `acc_<SOURCE>_<n>`.                                              |
| `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 }
  ]
}
```

<Note>
  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.
</Note>

<Tip>
  **Dedupe on `id`.** The `txn_<SOURCE>_<n>` 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.
</Tip>

## Testing with FinBank

<Note>
  **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.
</Note>

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

<CardGroup cols={2}>
  <Card title="Connect a bank" icon="link" href="/guides/connect-a-bank">
    The widget flow and the hosted connect-session link, in depth.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/guides/webhooks">
    Verify signatures, dedupe on event\_id, handle retries.
  </Card>

  <Card title="Connection lifecycle" icon="arrows-rotate" href="/guides/connection-lifecycle">
    requires\_action, active, failed, disconnected — and capability changes.
  </Card>

  <Card title="API reference" icon="book" href="/api-reference">
    Every endpoint, parameter, and response shape.
  </Card>
</CardGroup>
