Skip to main content
Every error we return has the same shape and a stable, machine-readable code. Branch on the code, show your user something friendly, log the trace id, and consult the catalog below for the exact action. That’s the whole game.

The error envelope

Every error response, validation, auth, not found, rate limit, or a bank-side failure, carries the same JSON body under error:
{
  "error": {
    "code": "not_found",
    "message": "No client matches id 'cli_deadbeef'.",
    "type": "not_found",
    "doc_url": "https://portal.ledgersyncappv2.com/errors/not_found",
    "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
  }
}
FieldWhat it’s for
codeStable, machine-readable. Branch on this.
messageHuman-readable explanation. Good for logs; safe for developers, not always for end users.
typeStripe-style class derived from HTTP status: auth_error, invalid_request, not_found, idempotency_error, rate_limit_error, api_error. Coarse grouping only.
categoryPlaid-style class by origin: AUTH_ERROR, INSTITUTION_ERROR, CAPABILITY_UNAVAILABLE, CONNECTION_ERROR, RATE_LIMIT, INVALID_REQUEST, RESOURCE_NOT_FOUND, PLATFORM_ERROR. Orthogonal to type; still branch on code, not this.
is_user_actionabletrue when the end user (not you) can resolve it, e.g. by re-authenticating in the widget.
doc_urlDeep link to the page for this specific code.
trace_idThe id of this exact request in our logs. Quote it in support tickets.
param / errors[]On validation_failed, names the offending field(s).
source_diagnostic_codeOn bank-side errors, the raw upstream code (FIN-103, MX-DENIED, FDE-INTERNAL). For support triage, not control flow.
Branch on code, never on message and never on type. We reword messages over time, and type is deliberately coarse, several very different codes share type: api_error. The code is the contract.

HTTP status, at a glance

StatusMeaningTypical fix
400Validation / malformed requestRead error.message (and error.param), fix the field, retry. Don’t retry unchanged.
401Auth: missing, malformed, or wrong key/signatureSend Authorization: Bearer sk_test_...; check the key matches the environment.
403Authenticated but not allowed (scope, unapproved access, billing)Fix the key’s scope or finish the portal step named in the message.
404Not found or not visible to your keyConfirm the id and its environment.
405Right route, wrong HTTP methodUse the method the API reference documents.
409Conflict (idempotency key reuse, stale row_version)Re-fetch, then retry with fresh token/body.
422Accepted but couldn’t be processed (statement extraction)Read the operation’s error.message; fix the input.
429Rate limit against your quotaBack off with jitter; honor Retry-After.
5xxOur side or the bank’s sideRetry with backoff; escalate with the trace id.

Error-code catalog

Every code the API can return. The catalog is also browsable at portal.ledgersyncappv2.com/errors, and each error.doc_url deep-links to one entry.

Authentication & authorization

CodeHTTPWhen it firesWhat to do
missing_auth_headers401No Authorization header.Add Authorization: Bearer sk_....
unknown_api_key401Bearer token matches no key (typo, wrong env, revoked).Check the value and that the key matches the base URL.
revoked_api_key401Key was valid but has been revoked/rotated.Mint a new key in the portal, update config.
invalid_signature401HMAC signature didn’t match the computed one.Sign over the raw bytes you send; confirm the current secret. Prefer our signing helper.
invalid_timestamp401X-LS-Timestamp is more than 5 min off our clock.Fix the machine’s clock (NTP), resend.
replayed_nonce401X-LS-Nonce was already used.Generate a fresh 16+ byte random nonce per request.
insufficient_scope403Key authenticated but lacks the scope for this operation.Grant the scope named in message, or mint a key with it.
access_request_not_approved403Minting a key for an env whose access request isn’t APPROVED.Finish/resubmit the access request for that env in the portal.
billing_not_configured403Live request with no billing subscription linked.Add a payment method in the portal (Activate Live → Payment).

Request problems

CodeHTTPWhen it firesWhat to do
validation_failed400A field failed schema validation. error.param / error.errors[] name it.Fix the named field(s), resubmit.
invalid_request400Rejected for a non-schema reason (bad state transition, unsupported source, idempotency-key reused with a different body).Read message; use a fresh Idempotency-Key if retrying with a changed body.
method_not_allowed405Right route, wrong HTTP method.Use the documented method; regenerate clients from the spec.
idempotency_conflict409Same Idempotency-Key, different body/path.Reuse the exact original body to retry, or a new key for a new request.
version_conflict409Write carried a stale row_version (someone else moved the row).Re-fetch to get the current row_version and state, then retry if still needed.
read_only_client403The target Client is read-only — an aliased/linked pre-existing v2 client. Writes and connection mutations are rejected.Only mutate native cli_ clients; treat aliased linked clients as read-only.
billing_managed_externally409A billing action was attempted on a customer whose billing is managed outside the API.Manage that customer’s billing through the configured external flow, not the API.
not_found404No resource matches the id, or it belongs to another customer.Verify the id (stored the cli_..., not your external_id?).
statement_extraction_failed422A statement.extract operation finished failed (bad scan, not a statement, corrupt pages). Arrives inside the polled operation’s error, not as a direct HTTP response.Fix the document (clearer scan, all pages, real bank PDF) and re-POST /v3/statements/extract.

Rate limit

CodeHTTPWhen it firesWhat to do
rate_limit_exceeded429More requests than the per-key limit. X-RateLimit-Reset / Retry-After describe the window.Back off and retry after Retry-After; batch or ask support for a higher quota.

Server

CodeHTTPWhen it firesWhat to do
internal_error500Something broke on our side (usually transient).Retry once with backoff; escalate with the trace_id.
upstream_unavailable502A service v3 depends on (the main LedgerSync backend behind the bridge) is unreachable. Distinct from source_internal_error, our platform leg, not the aggregator.Retry with exponential backoff; escalate with the trace_id if it persists.

Connection & source errors (the bank side)

These come back on connection operations, most often inside the polled operation’s error, with the raw upstream code in error.source_diagnostic_code. All carry type: api_error and HTTP 502, so you must branch on code, not on type. They group cleanly by the action they require:
The wire error.category is too coarse to branch on. CAPABILITY_UNAVAILABLE covers agreement_required, statements_not_supported, fde_unsupported, and refresh_not_supported; CONNECTION_ERROR covers both mfa_required and reauthorization_required. Always switch on error.code.
Send the end user back through the widget (re-authenticate):
CodeWhen it fires
credentials_invalidBank rejected the login (changed password, re-enrolled creds). FIN-103/185/948, MX-DENIED.
mfa_requiredBank is challenging for a second factor. FIN-187, MX CHALLENGED/MFA_REQUIRED, FDE USER_ACTION.
mfa_expiredUser didn’t answer the MFA challenge in time. MX EXPIRED/MFA_EXPIRED.
reauthorization_requiredSaved session invalid; bank wants re-consent. MX CREDENTIALS_UPDATE_REQUIRED / USER_INTERACTION_REQUIRED.
agreement_requiredBank needs a new agreement/consent accepted. FIN-38006, MX-AGREEMENT_REQUIRED.
Don’t re-auth, retry with backoff (credentials are fine):
CodeWhen it fires
institution_unavailableBank is down/impaired/transient. Finicity 931/946/990-998, MX SITE_DOWN/IMPAIRED, FDE INSTITUTION_DOWN. Show “your bank is having issues,” retry minutes-to-hours later.
source_rate_limitedAggregator/bank throttled back-to-back pulls. FIN-947. Back off; don’t tighten your loop.
source_internal_errorAggregator returned a generic internal failure. Untranslated FIN-*, *-INTERNAL, MX-UNKNOWN. Small retry budget (2–3), then escalate.
Don’t retry, the capability isn’t there:
CodeWhen it fires
statements_not_supportedInstitution doesn’t expose statements. FIN-6004. Hide “view statements”; transactions/balances still work.
refresh_not_supportedSource has no on-demand refresh (MX). Subscribe to account.refresh.completed instead.
fde_unsupportedFDE-backed connection doesn’t expose this capability. Gate the feature by connection source.
User must act at the bank (not in your widget):
CodeWhen it fires
account_locked_by_institutionBank locked the account (too many attempts / fraud hold). FIN-109, MX-LOCKED. Tell the user to unlock with their bank; re-auth won’t help.

A handling pattern

The same steps work in any language: check the status, parse the envelope, branch on code, log the trace id.
import requests

resp = requests.post(
    "https://api-sandbox.ledgersyncappv2.com/v3/clients/cli_01HXYZ/connections",
    headers={"Authorization": "Bearer sk_test_..."},
    json={"institution_id": "ins_0a01a5430925d0b2"},
)

trace_id = resp.headers.get("X-LS-Trace-Id")

if resp.ok:
    operation = resp.json()
else:
    err = resp.json()["error"]
    log.warning("LedgerSync code=%s status=%s trace=%s",
                err["code"], resp.status_code, trace_id)

    code = err["code"]
    if code in ("credentials_invalid", "mfa_required", "mfa_expired",
                "reauthorization_required", "agreement_required"):
        send_user_back_through_widget(...)      # re-auth
    elif code in ("institution_unavailable", "source_rate_limited",
                  "source_internal_error", "upstream_unavailable"):
        retry_with_backoff(...)                 # credentials are fine
    elif code == "not_found":
        ...                                     # fix the id
    elif resp.status_code == 401:
        ...                                     # bad key — don't retry
    elif resp.status_code == 429:
        backoff_and_retry(...)                  # see Rate limiting

Rate limiting

A 429 (rate_limit_exceeded) just means slow down. Use exponential backoff with jitter, and honor Retry-After when present.
import time, random

def call_with_backoff(do_request, max_attempts=5):
    for attempt in range(max_attempts):
        resp = do_request()
        if resp.status_code != 429:
            return resp
        wait = float(resp.headers.get("Retry-After", (2 ** attempt))) + random.random()
        time.sleep(wait)
    return resp  # give up; log the trace id and escalate
The same backoff is the right response to 5xx and the retryable bank-side codes above. Only non-429 4xx errors mean “don’t retry until you change something.”

The trace id

Every response, success or error, includes an X-LS-Trace-Id header (also mirrored as error.trace_id in error bodies):
X-LS-Trace-Id: 7b3f9c2a1e4d40a8b6c0f1e2d3a4b5c6
Log it on every request, even successful ones. When something looks wrong downstream (“this account has no transactions”), the trace id from the original read is what we need.

Getting help

When you file a ticket, include: the X-LS-Trace-Id (or error.trace_id), the error.code, the endpoint, and whether you were in sandbox or live. That’s everything we need to find your exact request.

Testing in sandbox

Reproduce every error path on purpose with sandbox test banks.

Connection lifecycle

Most bank-side codes surface as a connection.failed transition.