The error envelope, the full error-code catalog, and exactly what to do for each one.
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.
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" }}
Field
What it’s for
code
Stable, machine-readable. Branch on this.
message
Human-readable explanation. Good for logs; safe for developers, not always for end users.
type
Stripe-style class derived from HTTP status: auth_error, invalid_request, not_found, idempotency_error, rate_limit_error, api_error. Coarse grouping only.
category
Plaid-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_actionable
true when the end user (not you) can resolve it, e.g. by re-authenticating in the widget.
doc_url
Deep link to the page for this specific code.
trace_id
The 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_code
On 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.
A field failed schema validation. error.param / error.errors[] name it.
Fix the named field(s), resubmit.
invalid_request
400
Rejected 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_allowed
405
Right route, wrong HTTP method.
Use the documented method; regenerate clients from the spec.
idempotency_conflict
409
Same Idempotency-Key, different body/path.
Reuse the exact original body to retry, or a new key for a new request.
version_conflict
409
Write 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_client
403
The 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_externally
409
A 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_found
404
No resource matches the id, or it belongs to another customer.
Verify the id (stored the cli_..., not your external_id?).
statement_extraction_failed
422
A 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.
Retry once with backoff; escalate with the trace_id.
upstream_unavailable
502
A 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.
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,
andrefresh_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):
Code
When it fires
credentials_invalid
Bank rejected the login (changed password, re-enrolled creds). FIN-103/185/948, MX-DENIED.
mfa_required
Bank is challenging for a second factor. FIN-187, MX CHALLENGED/MFA_REQUIRED, FDE USER_ACTION.
mfa_expired
User didn’t answer the MFA challenge in time. MX EXPIRED/MFA_EXPIRED.
reauthorization_required
Saved session invalid; bank wants re-consent. MX CREDENTIALS_UPDATE_REQUIRED / USER_INTERACTION_REQUIRED.
agreement_required
Bank needs a new agreement/consent accepted. FIN-38006, MX-AGREEMENT_REQUIRED.
Don’t re-auth, retry with backoff (credentials are fine):
Code
When it fires
institution_unavailable
Bank 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_limited
Aggregator/bank throttled back-to-back pulls. FIN-947. Back off; don’t tighten your loop.
source_internal_error
Aggregator returned a generic internal failure. Untranslated FIN-*, *-INTERNAL, MX-UNKNOWN. Small retry budget (2–3), then escalate.
A 429 (rate_limit_exceeded) just means slow down. Use exponential backoff
with jitter, and honor Retry-After when present.
import time, randomdef 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-4294xx errors mean “don’t retry until you change
something.”
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.
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.