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. To do it programmatically, POST /v3/webhooks/subscriptions:
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"
    ]
  }'
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.
EventFires when
connection.initiatedYou’ve kicked off a link; the user hasn’t finished the widget yet.
connection.requires_actionThe connection is waiting on the user in the widget (sign-in, MFA, account pick).
connection.activeThe user finished linking. Data is flowing. Carries the canonical connection id.
connection.failedBad credentials or an aggregator-reported failure. The link did not complete.
connection.disconnectedThe user revoked access, or the connection needs a re-link.
connection.capability_changedA single capability on an active connection flipped working ↔ not-working.
account.refresh.completedA background data refresh for an account finished successfully.
account.refresh.failedA background data refresh for an account failed.
webhook.testA manual test delivery you triggered. Safe to ignore in business logic.
See Connection lifecycle for how initiated → requires_action → active (and the failure paths) fit together as a state machine.

Delivery shape

Every delivery is an HTTP POST to your URL with a JSON body and these X-LS-Webhook-* headers:
HeaderMeaning
X-LS-Webhook-Event-IdStable unique id for this event. Dedupe on it (same value as event_id in the body).
X-LS-Webhook-Event-TypeThe event type, e.g. connection.active. Lets you route before parsing.
X-LS-Webhook-TimestampUnix seconds when we produced the event. Part of the signed payload and used for replay protection.
X-LS-Webhook-Signaturehex(HMAC-SHA256(signing_secret, timestamp + "." + raw_body)). Verify before trusting anything.
X-LS-Webhook-Signature-PrevPresent 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:
Example delivery body
{
  "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:
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.
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.
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
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.
Idempotent handling
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
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.

Test-fire a delivery

Once a subscription exists, send yourself a real signed delivery:
Fire a test event
curl -X POST \
  https://api-sandbox.ledgersyncappv2.com/v3/webhooks/subscriptions/whsub_01HXYZ.../test \
  -H "Authorization: Bearer sk_test_..."
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:
CapabilityCovers
transactionsTransaction data feed.
balanceCurrent balance.
available_balanceAvailable (spendable) balance.
statementsStatement documents.
check_imagesCheck image retrieval.
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
{
  "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 adds an action object telling you what the user must do. The common one is widget_url:
connection.requires_action
{
  "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"
    },
    "action": {
      "kind": "widget_url",
      "widget_url": "https://connect.ledgersyncappv2.com/w/...",
      "expires_at": "2026-07-07T15:22:05Z"
    }
  }
}
action.kind is a discriminated union — widget_url (open it), mfa_challenge (carries challenge_id + questions), or reauthorize. Branch on kind. account.refresh.completed carries data.account, not data.connection:
account.refresh.completed
{
  "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": 4110.55, "iso_currency_code": "USD" }
    }
  }
}
Treat new_transaction_count > 0 as your cue to re-read /accounts/{id}/transactions for that connection. 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
{
  "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"
      }
    }
  }
}

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.