Skip to main content
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.
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), but for everything after — refreshes, disconnects, capability changes — webhooks are the only push channel.

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. If you’d rather monitor delivery health from your own code, every subscription also reports it over the API. See Check delivery health. To do it programmatically, POST /v3/webhooks/subscriptions:
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.

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.
See Connection lifecycle for how initiated → requires_action → active (and the failure paths) fit together as a state machine.
Connection events are not only for changes you start. A status change LedgerSync detects on its own sends the matching connection.* event: the bank asking for new credentials, an outage, a recovery. That includes changes found by the scheduled daily refresh, so a connection that recovers after connection.failed sends connection.active. The status in each event is the one GET /v3/connections/{id} returns for the same state. One exception today: a Finicity MFA challenge on an existing connection reads requires_action on GET without sending an event.
client_id on connection events. data.connection.client_id names the client that owns the connection, as the client_id you use with the API. It is present whenever LedgerSync can resolve the owner at the time of the event. When it cannot, the key is omitted, never null, so check for the key.
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.
There is also no v3 account-added event. connection.active is not a reliable account-addition signal: it may be absent or emitted again without proving the account set changed. account.refresh.completed tells you a refresh finished. Compare paginated account lists before and after the flow to determine which LedgerSync account IDs are new to your integration.To find new account records, re-list all pages of GET /v3/accounts?client_id=... and diff on account ID. Add connection_id only when you deliberately want to exclude accounts on other connections, including a new connection created by a changed bank login or source. See Adding a new account.

Delivery shape

Every delivery is an HTTP POST to your URL with a JSON body and these X-LS-Webhook-* headers: 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:
Example delivery body

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

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.
  • Treat event_id as an opaque string. Compare the exact value you received; don’t parse it, and don’t validate a prefix or a length. Most events carry an evt_ prefix, while account.refresh.completed, account.refresh.failed and statement.available currently arrive as a bare UUID. An event’s id never changes between retries, so exact-string comparison is always the right check.
Idempotent handling
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.

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.
The pattern
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.
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.
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}.

Test-fire a delivery

Once a subscription exists, send yourself a real signed delivery:
Fire a test event
You get back 202 with { "event_id": "evt_6d84cf35-b2ee-4734-b408-4e6f10fedad8" }. There is no operation_id and nothing to poll — watch your endpoint for the webhook.test delivery. That id is the delivery’s own id. The identical string arrives in the X-LS-Webhook-Event-Id header and as the envelope’s event_id, so you can match the call to the delivery, or look it up in your dedupe store, with no reformatting. A 202 means queued, not delivered: if your endpoint is unreachable the delivery is retried on the normal schedule, and nothing about the 202 changes. (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.

Check delivery health

A test fire tells you your endpoint works right now. To tell whether deliveries have actually been arriving since, read the two timestamps every subscription carries:
Read them together, not separately:
  • last_failure_at newer than last_success_at means deliveries are failing right now. This is the one to alert on.
  • last_success_at newer means the endpoint recovered. An older last_failure_at is just history; we never clear it on a later success, deliberately, because an endpoint that keeps flapping is worth being able to see.
  • Both absent means nothing has ever been delivered to this subscription. Expected for one you just created.
A subscription stays active no matter how badly delivery is going, because we keep retrying rather than disabling your endpoint out from under you. So status on its own will never tell you your webhooks are broken. last_failure_at is the field to watch.
Both fields are omitted entirely rather than sent as null when there is nothing to report, so check whether the key is present rather than comparing to null. Failures are stamped on every failed attempt, including retries that will be tried again, so last_failure_at moves during a retry storm instead of waiting for the 24-hour budget to run out. Test fires count toward last_success_at: they are real signed deliveries to the same URL.

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

connection.capability_changed
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:
connection.requires_action
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.
account.refresh.completed carries data.account, not data.connection:
account.refresh.completed
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: 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):
account.refresh.failed
statement.available carries data.statement. It fires once, the first time a statement PDF is stored for a connected account:
statement.available
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.
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.
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.

Next steps

Connection lifecycle

How connections move through initiated, active, failed, and disconnected.

Connect a bank

The full linking flow, from institution search to a canonical connection id.

Errors

The error envelope, status codes, and how to branch on code.

Testing

Sandbox banks and credentials to drive every event end-to-end.