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

# Webhooks

> Get notified the moment a bank connection goes live, fails, or changes — instead of polling for it.

Bank linking is asynchronous. After you hand a user off to the widget, they might sign in and pick accounts in ten seconds — or wander off and finish twenty minutes later on their phone. Webhooks let LedgerSync tell you the instant something happens, so you never have to sit in a polling loop.

The single most important event is `connection.active`: it fires when a user finishes linking and data starts flowing. That payload carries the **canonical** connection id (like `con_FINICITY_41294`) — the only id that works on `/accounts`, `/transactions`, and `/statements`. Subscribe to webhooks and you get it delivered for free.

<Note>
  Webhooks are the recommended way to learn about connection state. You *can* poll `GET /v3/operations/{id}` during the initial link (see [Connect a bank](/guides/connect-a-bank)), but for everything after — refreshes, disconnects, capability changes — webhooks are the only push channel.
</Note>

## Subscribe

The easiest path is the **Webhooks page in the portal**: paste your endpoint URL, tick the events you want, and copy the signing secret. It's the recommended day-to-day way to manage subscriptions, rotate secrets, and inspect recent deliveries.

To do it programmatically, `POST /v3/webhooks/subscriptions`:

<CodeGroup>
  ```bash Create a subscription theme={null}
  curl -X POST https://api-sandbox.ledgersyncappv2.com/v3/webhooks/subscriptions \
    -H "Authorization: Bearer sk_test_..." \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://yourapp.com/webhooks/ledgersync",
      "event_types": [
        "connection.active",
        "connection.failed",
        "connection.disconnected",
        "account.refresh.completed"
      ]
    }'
  ```

  ```json Response theme={null}
  {
    "id": "whk_7c1e4a9b83d24f6ab05e2f8c91d3a640",
    "url": "https://yourapp.com/webhooks/ledgersync",
    "event_types": [
      "connection.active",
      "connection.failed",
      "connection.disconnected",
      "account.refresh.completed"
    ],
    "status": "active",
    "created_at": "2026-09-03T14:22:08Z",
    "signing_secret": "whsec_9f2c...shown-once"
  }
  ```
</CodeGroup>

<Warning>
  The `signing_secret` is shown **once**, at creation. Store it somewhere safe immediately — you'll need it to verify every delivery. If you lose it, rotate the secret from the portal Webhooks page.
</Warning>

## Event catalog

There are exactly eight real events, plus a `webhook.test` event you can fire on demand. Subscribe only to the ones you handle.

| Event                           | Fires when                                                                                                                                                                                                                                                   |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `connection.requires_action`    | Something the **user** can fix: waiting on them in the widget (sign-in, MFA, account pick), or their credentials need re-entering, or the bank locked the account.                                                                                           |
| `connection.active`             | The user finished linking. Data is flowing. Carries the **canonical** connection id.                                                                                                                                                                         |
| `connection.failed`             | Something the user **cannot** fix: the bank is unavailable, the bank is refusing automated access, or the aggregator reported a failure. See [`requires_action` vs `failed`](/guides/connection-lifecycle#the-status-flow) before prompting for credentials. |
| `connection.disconnected`       | The user revoked access, or the connection needs a re-link.                                                                                                                                                                                                  |
| `connection.capability_changed` | A single capability on an active connection flipped working ↔ not-working.                                                                                                                                                                                   |
| `account.refresh.completed`     | A background data refresh for an account finished successfully.                                                                                                                                                                                              |
| `account.refresh.failed`        | A background data refresh for an account failed.                                                                                                                                                                                                             |
| `statement.available`           | A bank statement PDF was stored for an account for the first time and is ready to download.                                                                                                                                                                  |
| `webhook.test`                  | A manual test delivery you triggered. Safe to ignore in business logic.                                                                                                                                                                                      |

<Tip>
  See [Connection lifecycle](/guides/connection-lifecycle) for how `initiated → requires_action → active` (and the failure paths) fit together as a state machine.
</Tip>

<Note>
  There is no `connection.initiated` **event**. The `initiated` connection *status* exists in the state machine, but the first webhook every new link produces is `connection.requires_action`. Subscriptions listing an unknown event type are rejected with `validation_failed`.
</Note>

<Warning>
  There is also **no account-added event**. When a client opens a new account at a bank they already linked, nothing is pushed to you. `connection.active` fires on the transition into `active`, so an already-active connection that stays active produces no event, and `account.refresh.completed` tells you a refresh finished, not that the account set changed.

  To find the new account, re-list `GET /v3/accounts?client_id=...&connection_id=...` and diff on account id. See [Adding a new account](/guides/adding-accounts).
</Warning>

## Delivery shape

Every delivery is an HTTP `POST` to your URL with a JSON body and these `X-LS-Webhook-*` headers:

| Header                        | Meaning                                                                                                                                                                                          |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `X-LS-Webhook-Event-Id`       | Stable unique id for this event. **Dedupe on it** (same value as `event_id` in the body).                                                                                                        |
| `X-LS-Webhook-Event-Type`     | The event type, e.g. `connection.active`. Lets you route before parsing.                                                                                                                         |
| `X-LS-Webhook-Timestamp`      | Unix seconds when we produced the event. Part of the signed payload and used for replay protection.                                                                                              |
| `X-LS-Webhook-Signature`      | `hex(HMAC-SHA256(signing_secret, timestamp + "." + raw_body))`. Verify before trusting anything.                                                                                                 |
| `X-LS-Webhook-Signature-Prev` | Present **only** for 24h after you rotate the signing secret — the same signature under your *previous* secret. Accept either while you roll your verifier so a rotation never drops a delivery. |

Every body shares the same base fields — `event_id`, `type`, `created_at`,
`api_version`, `livemode` — plus a `data` object whose shape depends on the
event:

```json Example delivery body theme={null}
{
  "event_id": "evt_01HXYZ...",
  "type": "connection.active",
  "created_at": "2026-07-07T14:22:05Z",
  "api_version": "2026-05-22",
  "livemode": false,
  "data": {
    "connection": {
      "id": "con_FINICITY_41294",
      "client_id": "cli_01HXYZ...",
      "source": "FINICITY",
      "status": "active"
    }
  }
}
```

## Verify the signature

Never trust a delivery you haven't verified. The **signed payload** is the
timestamp header, a literal `.`, then the raw body. (Generated code gets this
backwards often, signing the body alone. If an assistant wrote your verifier,
see [If something is not working](/guides/build-with-ai#4-if-something-is-not-working).)

```
signed_payload = X-LS-Webhook-Timestamp + "." + raw_body
expected       = hex( HMAC-SHA256( signing_secret, signed_payload ) )
```

Compare `expected` to `X-LS-Webhook-Signature` in constant time. Also reject
anything whose `X-LS-Webhook-Timestamp` is more than five minutes old — that
stops replay attacks.

<Warning>
  Sign the **raw bytes** of the body, exactly as received, prefixed with `timestamp + "."`. If your framework parses JSON and re-serializes it, the bytes change and the signature won't match. Capture the raw body before any JSON middleware touches it.
</Warning>

<CodeGroup>
  ```python Python (Flask) theme={null}
  import hmac, hashlib, time
  from flask import Flask, request, abort

  SIGNING_SECRET = b"whsec_9f2c..."
  MAX_AGE_SECONDS = 5 * 60

  app = Flask(__name__)

  @app.post("/webhooks/ledgersync")
  def handle():
      raw = request.get_data()  # raw bytes, before JSON parsing
      timestamp = request.headers.get("X-LS-Webhook-Timestamp", "")
      signature = request.headers.get("X-LS-Webhook-Signature", "")

      # Reject stale deliveries (replay protection)
      if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > MAX_AGE_SECONDS:
          abort(400)

      # Signed payload = timestamp + "." + raw body
      signed = (timestamp + ".").encode("utf-8") + raw
      expected = hmac.new(SIGNING_SECRET, signed, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(expected, signature):
          abort(401)

      event = request.get_json()
      # ... dedupe on event["event_id"], enqueue, return fast ...
      return "", 200
  ```

  ```javascript Node (Express) theme={null}
  const crypto = require("crypto");
  const express = require("express");

  const SIGNING_SECRET = "whsec_9f2c...";
  const MAX_AGE_SECONDS = 5 * 60;

  const app = express();

  // Capture the RAW body — do not let express.json() reserialize it.
  app.post(
    "/webhooks/ledgersync",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const raw = req.body; // Buffer of raw bytes
      const timestamp = req.get("X-LS-Webhook-Timestamp") || "";
      const signature = req.get("X-LS-Webhook-Signature") || "";

      // Reject stale deliveries (replay protection)
      const age = Math.abs(Date.now() / 1000 - Number(timestamp));
      if (!/^\d+$/.test(timestamp) || age > MAX_AGE_SECONDS) {
        return res.sendStatus(400);
      }

      // Signed payload = timestamp + "." + raw body
      const signed = Buffer.concat([Buffer.from(timestamp + "."), raw]);
      const expected = crypto
        .createHmac("sha256", SIGNING_SECRET)
        .update(signed)
        .digest("hex");

      const a = Buffer.from(expected);
      const b = Buffer.from(signature);
      if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
        return res.sendStatus(401);
      }

      const event = JSON.parse(raw.toString("utf8"));
      // ... dedupe on event.event_id, enqueue, return fast ...
      res.sendStatus(200);
    }
  );
  ```
</CodeGroup>

<Info>
  **During a secret rotation**, deliveries also carry `X-LS-Webhook-Signature-Prev`
  for 24h — the same payload signed with your previous secret. Accept a match on
  *either* header while you roll the new secret across your instances, and a
  rotation never drops an event.
</Info>

<Check>
  Use a constant-time comparison — `hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node. A plain `==` leaks timing information an attacker can use to forge signatures.
</Check>

## Retries and idempotency

If your endpoint doesn't return a `2xx` quickly, we retry with **exponential backoff for up to 24 hours**. That means:

* **Return `2xx` within 30 seconds.** Do the minimum — verify, enqueue, respond. Push slow work (DB writes, downstream calls) onto a background queue. A slow handler looks like a failure and gets retried.
* **Deliveries can repeat.** A retry after a network blip — or a delivery you already processed but responded to slowly — means the same event can arrive more than once. **Dedupe on `event_id`.** Treat it as an idempotency key: if you've seen it, ack and move on.

```python Idempotent handling theme={null}
if seen_before(event["event_id"]):
    return "", 200          # already processed — ack and skip
mark_seen(event["event_id"])
enqueue(event)              # do the real work off the request path
return "", 200
```

<Info>
  Order is not guaranteed under retries. Design handlers to be self-contained: react to the state in `data`, don't assume the previous event already landed.
</Info>

## Ordering

Webhooks are **not** delivered in order. There is no sequence counter on the envelope, and retries run on their own schedule — a delivery that failed and is being retried can arrive *after* an event that was produced later. Concretely:

* A `connection.failed` can land before the `connection.requires_action` that preceded it.
* A slow-retried `account.refresh.completed` can arrive after a newer refresh for the same account.
* A back-pull of older statements means `statement.available` can deliver an earlier `statement_date` after a later one. Order statements by `statement_date`, not by arrival.
* Two events produced milliseconds apart can arrive in either order.

So don't build a state machine that assumes the previous event already landed. **Treat every webhook as a signal, not a source of truth:** it tells you *something changed on this connection* — then you re-fetch the current state to act on it.

```text The pattern theme={null}
1. Verify the signature.
2. Dedupe on event_id (X-LS-Webhook-Event-Id) — skip if seen.
3. GET /v3/connections/{id} to read the CURRENT status.
4. Reconcile your local state to what the API returns.
```

Because step 3 always reads current truth, out-of-order arrival is harmless: even if `connection.failed` shows up before `connection.requires_action`, the `GET /v3/connections/{id}` you run for each one returns the connection's real, latest status — so you converge on the right state regardless of arrival order.

<Info>
  Every envelope carries `created_at` — the time the event was **produced**. If you need a tiebreaker (e.g. to ignore a stale event you've already superseded), compare `created_at`, not the `X-LS-Webhook-Timestamp` header. The header is regenerated on **each delivery attempt** (it's the send time, used for replay protection), so a retried event's header timestamp reflects when it was *re-sent*, not when it happened. `created_at` is stable across retries.
</Info>

<Warning>
  Never treat a webhook payload's `status` as authoritative when order matters. The `data` in a late-arriving retry reflects the state **at the time that event was produced**, which may already be stale. When you need to act, re-fetch with `GET /v3/connections/{id}`.
</Warning>

## Test-fire a delivery

Once a subscription exists, send yourself a real signed delivery:

```bash Fire a test event theme={null}
curl -X POST \
  https://api-sandbox.ledgersyncappv2.com/v3/webhooks/subscriptions/whk_7c1e4a9b83d24f6ab05e2f8c91d3a640/test \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Length: 0"
```

You get back `202` with `{ "event_id": "..." }`. There is no `operation_id` and nothing to poll — watch your endpoint for the `webhook.test` delivery. Note the two forms of that id: the response gives you a bare UUID, while the delivery's `X-LS-Webhook-Event-Id` header and the envelope's `event_id` carry the same UUID as `evt_` plus its hex digits with the dashes removed. Strip the dashes and add the prefix if you want to match them up. (Send the explicit `Content-Length: 0`; the load balancer in front of the API rejects body-less POSTs without it.)

The test delivery is signed with your **real** signing secret, so it exercises your verification code end-to-end. It also **bypasses the `event_types` filter**, so you can trigger it regardless of what you subscribed to. Use it to confirm your endpoint is reachable, your signature check passes, and your dedupe logic works before you rely on live events.

## `connection.capability_changed` in depth

An active connection isn't all-or-nothing. A bank might keep serving transactions while its statements feed breaks for a week. `connection.capability_changed` tells you when one **individual capability** flips between working and not-working — so you can, say, warn a user that statements are temporarily unavailable without tearing down the whole connection.

**Capabilities that can change:**

| Capability          | Covers                                                                               |
| ------------------- | ------------------------------------------------------------------------------------ |
| `transactions`      | Transaction data feed.                                                               |
| `balance`           | Current balance.                                                                     |
| `available_balance` | Available (spendable) balance.                                                       |
| `statements`        | Statement documents.                                                                 |
| `check_images`      | Check image retrieval. FDE-only — read them with [the check routes](/guides/checks). |

**Capability statuses:** `succeeded`, `failed`, `skipped`, `unsupported`, `pending`.

### Hysteresis: no flapping

We don't fire on every hiccup. A capability only transitions after a run of consistent observations:

* **`failed`** after **3 consecutive** failed observations.
* **`succeeded`** after **2 consecutive** succeeded observations.
* **Sentinel statuses** (`unsupported`, `skipped`, `pending`) are *not* debounced — they flip **immediately** and arrive with `consecutive_observations: 1`.

This debounce keeps a single transient error from spamming you. The event fires **only on a real transition** — not on every refresh that happens to agree with the current state.

### Payload

```json connection.capability_changed theme={null}
{
  "event_id": "evt_01HXYZ...",
  "type": "connection.capability_changed",
  "created_at": "2026-07-07T14:22:05Z",
  "api_version": "2026-05-22",
  "livemode": false,
  "data": {
    "connection": {
      "id": "con_FINICITY_41294",
      "client_id": "cli_01HXYZ...",
      "source": "FINICITY"
    },
    "change": {
      "capability": "statements",
      "previous_status": "succeeded",
      "current_status": "failed",
      "consecutive_observations": 3,
      "last_error": {
        "code": "statements_not_supported",
        "message": "This institution doesn't expose statements.",
        "type": "api_error",
        "category": "CAPABILITY_UNAVAILABLE"
      }
    }
  }
}
```

The per-capability detail lives under `data.change`. `previous_status` and
`current_status` tell you the direction of the flip; `consecutive_observations`
is how many in a row triggered it (3 for a fail, 2 for a recovery); `last_error`
is populated only when `current_status` is `failed`.

## Other payload shapes

Most events carry `data.connection` (shown above). Three carry a different `data`.

**`connection.requires_action`** carries the same `data.connection` shape as
every other connection event:

```json connection.requires_action theme={null}
{
  "event_id": "evt_...",
  "type": "connection.requires_action",
  "created_at": "2026-07-07T14:22:05Z",
  "api_version": "2026-05-22",
  "livemode": false,
  "data": {
    "connection": {
      "id": "con_...",
      "client_id": "cli_...",
      "source": "FINICITY",
      "status": "requires_action"
    }
  }
}
```

<Warning>
  **No webhook ever carries a URL to send the user to.** This payload tells you
  *that* the user must act, not *how* to get them back in. Call
  `POST /v3/connections/{connection_id}/reauthorize` to mint a `reauth_url` for
  an existing connection; for a brand-new one, the widget URL comes from the
  initiate operation's `result.connection.action.widget_url`. Code that reads
  `data.action` off this event gets `undefined`.
</Warning>

**`account.refresh.completed`** carries `data.account`, not `data.connection`:

```json account.refresh.completed theme={null}
{
  "event_id": "evt_...",
  "type": "account.refresh.completed",
  "created_at": "2026-07-07T14:22:05Z",
  "api_version": "2026-05-22",
  "livemode": false,
  "data": {
    "account": {
      "id": "acc_FINICITY_889201",
      "connection_id": "con_FINICITY_41294",
      "source": "FINICITY",
      "refreshed_at": "2026-07-07T14:22:05Z",
      "new_transaction_count": 12,
      "current_balance": { "amount": 4210.55, "iso_currency_code": "USD" },
      "available_balance": { "amount": 3980.10, "iso_currency_code": "USD" }
    }
  }
}
```

Treat `new_transaction_count > 0` as your cue to re-read
`/accounts/{id}/transactions` for that connection.

Optional fields on this payload are **omitted entirely rather than sent as
`null`** — check for a key's presence, don't compare it to `null`. That applies
to `current_balance`, `available_balance`, and `realized_capabilities` alike.

Which of them show up depends on the source:

| Field                   | Present on                     |
| ----------------------- | ------------------------------ |
| `current_balance`       | Finicity and MX. Never on FDE. |
| `available_balance`     | MX only.                       |
| `realized_capabilities` | All three sources.             |

`current_balance` is normalized exactly as it is on `GET /accounts`: positive is
value the client holds, negative is value they owe. The two surfaces agree, so a
credit card read from the API and the same card seen on this event carry the
same signed figure. It is wrapped as
`{ "amount": ..., "iso_currency_code": ... }` here, where `GET /accounts`
returns a bare decimal.

`available_balance` is available credit rather than a debt, so it is never
sign-flipped on either surface.

`client_id` on this event is the same `cli_` id you get back from
`GET /v3/clients` — use it directly on any client-scoped endpoint. The key is
omitted entirely when the owning client cannot be resolved, so treat it as
optional rather than assuming it is always present.

**`account.refresh.failed`** replaces the balances with a `failed_at` and an
`error` (same shape as HTTP error responses, including `code`, `type`, and
`category`):

```json account.refresh.failed theme={null}
{
  "type": "account.refresh.failed",
  "data": {
    "account": {
      "id": "acc_FINICITY_889201",
      "connection_id": "con_FINICITY_41294",
      "source": "FINICITY",
      "failed_at": "2026-07-07T14:22:05Z",
      "error": {
        "code": "institution_unavailable",
        "type": "api_error",
        "category": "INSTITUTION_ERROR"
      }
    }
  }
}
```

**`statement.available`** carries `data.statement`. It fires **once**, the first
time a statement PDF is stored for a connected account:

```json statement.available theme={null}
{
  "event_id": "evt_...",
  "type": "statement.available",
  "created_at": "2026-07-27T14:22:05Z",
  "api_version": "2026-05-22",
  "livemode": false,
  "data": {
    "statement": {
      "id": "stmt_FINICITY_9911",
      "account_id": "acc_FINICITY_110040",
      "connection_id": "con_FINICITY_41300",
      "client_id": "cli_01K9YTQ8V3E7WJ4M2N6RB0XZAD",
      "source": "FINICITY",
      "statement_date": "2026-06-30",
      "download_url": "/v3/statements/stmt_FINICITY_9911/download?client_id=cli_01K9YTQ8V3E7WJ4M2N6RB0XZAD"
    }
  }
}
```

`download_url` already includes the `/v3` prefix, so join it to the API **host**
(`https://api-sandbox.ledgersyncappv2.com`), not to the `/v3`-inclusive base URL
you use elsewhere — concatenating it onto the base URL yields `/v3/v3/...` and
404s. It returns the PDF bytes and needs the `read:statements` scope. Fetch it on
receipt rather than storing it: it is derived from the ids, not a signed link.

<Note>
  This is a **save-time** event. Balances and transaction counts are extracted
  later by OCR and are deliberately absent from the payload. If you need the
  extracted figures, poll the statement after receiving this event.
</Note>

<Warning>
  Two fields are optional and omitted rather than sent as `null`:

  * `statement_date` is absent when the date could not be determined from the document.
  * `client_id` is absent when the owning client cannot be resolved — and `download_url` is omitted with it, because that route is client-scoped.

  A re-pull of a statement you already received does **not** fire this event again. It fires only on first storage.
</Warning>

## Next steps

<CardGroup cols={2}>
  <Card title="Connection lifecycle" icon="arrows-rotate" href="/guides/connection-lifecycle">
    How connections move through initiated, active, failed, and disconnected.
  </Card>

  <Card title="Connect a bank" icon="link" href="/guides/connect-a-bank">
    The full linking flow, from institution search to a canonical connection id.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/guides/errors">
    The error envelope, status codes, and how to branch on `code`.
  </Card>

  <Card title="Testing" icon="flask" href="/guides/testing">
    Sandbox banks and credentials to drive every event end-to-end.
  </Card>
</CardGroup>
