Errors reference

Every Lenz SDK error carries a one-line message, a cause (why it fired), a fix (what to try), and a doc_url pointing back here. From SDK 2.7.0 errors also carry the server's code, and specific errors add their own fields — quota carries upgrade_url and remaining, rate limits carry retry_after. This page is the canonical list.

The HTTP status says what to do next, before anything parses the body:

StatusConditionRetry?
401Missing, malformed or unknown keyAfter fixing credentials
402Out of balanceNo — billing action
403Authenticated but not allowed (private verification, IP block)No
404Unknown verification, or no certificate for it on this accountNo
409An identical request is already in flight (in_progress), or the selection you're resolving was already resolved (no_selection_pending)Only in_progress
422Malformed inputNo
429Rate limitYes, after Retry-After
502A synchronous step (/assess, /extract, /ask) failed deterministicallyNo — same input, same outcome
503Providers unavailable (upstream_unavailable) or no capacity (capacity)Yes, after Retry-After

Every response also carries an X-Request-ID header. Include it when you report a problem.

Authentication #

LenzAuthError

Fires when an API call needs a key but none was provided, or the key was rejected by the server. Causes: env var unset, wrong key, key revoked.

Fix: pass api_key= / apiKey: to new Lenz(), set the LENZ_API_KEY env var, or get one at lenz.io/api-credentials. The library endpoints don't need a key — every other verb does.

Verification #

LenzNeedsInputError

The pipeline paused for input:

reasonwhat to do
multi_claim The submitted text contains multiple distinct claims. payload["claims"] lists them as {"text": ..., "domain": ...} objects. Resolve by calling client.select(task_id, claims=[...]) with the exact text of the claim(s) you want. Selection is by text, not index, and every text must match one that was offered. Each selected claim fans out into its own task — poll each items[].task_id from the returned BatchAccepted, and note that each one is billed as its own verification. You will not see this for a claim text /extract or /assess returned and you POSTed back verbatim within 24 hours — in normal operation those are verified as-is.

The body (and the verification.needs_input webhook) also carries hint: one sentence on the call that resolves it. A vague input does not pause: it is verified on its most likely reading, and the result's claim is that reading. When it has no checkable reading, the result is failed with failure_reason: not_a_claim and a hint saying which way the input fell short (an opinion, a prediction, a request for content, nothing specific enough to check) and what to send instead.

Until 2026-09-12 a vague input paused with reason clarification_required and a candidates list. That reason is no longer sent; candidates stays in the schema, always empty, and a vague input is now verified, and charged, like any other.

The duplicate_found branch is web-only — API customers never see it (every API submission gets its own verification id; see API Terms).

LenzPipelineError

Terminal failure inside the verification pipeline. cause + failure_reason on the exception describe the step that gave up.

Common reasons: not_a_claim (the input wasn't a verifiable claim), framing_failed (the framing step could not complete), research_insufficient (couldn't gather enough sources), conclusion_failed (the verdict step could not complete), task_stuck (the task was never run).

The status body (and the verification.failed webhook) also carries failure_class and retryable. failure_reason says where the pipeline stopped; failure_class says why. It is a closed set of stable identifiers; failure_reason is open.

upstream_unavailable

Our model or search providers were rate-limited or down, or the task was never run — resubmit after a short wait. The only class with retryable: true.

insufficient_evidence

We looked, and the sources are not there. The same submission gives the same outcome.

invalid_input

Not a checkable claim, an ambiguity we could not resolve, or a URL we could not read. The input has to change, not the timing.

cancelled

The verification was cancelled.

internal

Anything else, including a cause we could not determine.

The failed status body carries docs_url pointing at the entry for its class. Branch on retryable, not on the reason — the same reason can mean either. Read both from the raw status body: the SDK exception currently exposes failure_reason only. Failed verifications are not charged.

503 — providers unavailable

The synchronous endpoints (/assess, /extract, /ask) answer 503 with code: upstream_unavailable, a Retry-After header and the same value as retry_after in the body when our model providers were rate-limited or down for that step (reading the claims on /assess, extraction on /extract, the reply on /ask) and every fallback was exhausted. Nothing about your request was wrong; resubmit after the stated wait. Both SDKs honour the header automatically. A deterministic failure of the same step (a schema or parse failure) stays 502 — retrying it does not help. On /ask that deterministic case is code: ask_failed — the model could not answer this particular question, and rephrasing it is the thing that helps. Every one of these decisions is made before the call is charged: a /extract unit is refunded, and /assess and /ask have not debited yet. On /ask nothing is written to the conversation either, whichever status you get, so the question is yours to ask again.

/assess also answers 503 with code: capacity when the call ran out of its own time budget before any claim reached a verdict — under load, or on a list long enough that reading it used the budget the panel needed. Nothing was charged. Resubmit after the stated wait, with fewer items per call. Both SDKs abort their retry ladder on this code and surface the wait.

/extract on a web page URL

When text is a page URL, reading the page can fail in three ways. None of them uses a unit of the daily cap.

statuscodemeaningwhat to do
502extraction_failedThe page could not be read: it is behind a login, missing, has too little text, or is a video with no transcript.Send the text itself. The same URL gives the same answer.
503upstream_unavailableThe services that read pages, or YouTube, could not be reached.Retry after Retry-After.
503capacityToo many page reads were in flight on our side.Retry after Retry-After.

LenzTimeoutError

Your client-side polling timeout elapsed. The verification is still running server-side — the exception's task_id lets you resume:

status = client.get_status(timeout_err.task_id) if status.status == "completed": verification = status.result

Bump timeout= on verify_and_wait() if you regularly hit this (default is 120s; typical pipeline is 60-90s).

Assess #

Rows that carry no verdict

POST /assess takes one text in claim (expanded: every claim in it becomes a row, up to 20) or a list of up to 20 in claims (exactly one row per item, in the order you sent them, all checked in parallel). Match rows to items by position. A row that could not be given a verdict has verdict: "Error", costs nothing, and says why in error_code; hint is one sentence on what to send next, and claim echoes the item as submitted.

error_codemeaningwhat to do
no_claimThe item holds no factual statement that can be checked against evidence.Read hint; send one factual statement, or run a longer text through /extract.
framing_failedThe item could not be processed (a parse failure on our side).Send it as one factual statement and retry.
upstream_unavailableA model provider was down for this item.Retry the item; when every item is affected the call is a 503 instead.
timeoutThe call ran out of its time budget before this item was done. Nothing about the item was judged wrong, and it was not charged.Send it again in a new call; fewer items per call makes it less likely. When no item produced a verdict, the call is a 503 capacity instead.

A vague item is assessed on its most likely reading, and claim on its row is that reading. Until 2026-09-12 such an item was a free ambiguous Error row carrying candidate_claims; it is now a verdict row, charged like any other, and candidate_claims is always []. A verdict row whose item held more than one claim is assessed on its main claim; the others are listed in identified_claims (send them as their own items to check the rest) and hint says so. Claims that came from /extract are assessed exactly as written and never split.

422 on a claims list

codemeaning
input_conflictclaims was sent together with claim/text. Send one or the other.
too_many_itemsMore than 20 items. Split the list.
blank_itemAn item is empty; detail names its index.
item_too_longAn item exceeds 2,000 characters. Items are claims, not documents — run a document through /extract first.

All four are decided before any model call: nothing is charged and an Idempotency-Key sent with the request is released.

Credits #

LenzQuotaExceededError (HTTP 402)

You're out of credits. One pool per account funds every billable call, at a fixed weight per endpoint — 10 credits for /verify (5 at depth: "low"), 1 for /assess, 1 for /ask. Either the monthly allowance and any extra credits are both spent, or the capability needs a higher plan than the key's. Retrying will not clear it — the fix is a billing action.

/extract costs no credits and never returns a 402; it carries a daily fair-use cap that answers 429 instead.

402, not 403. The status separates "you're out of balance" from "you're not allowed", so a proxy, retry ladder or platform integration can branch on it before anything parses the body. It is also why an empty balance is not folded into 429: a 429 tells every client to retry, and an empty balance never succeeds on retry.

The body:

{ "detail": "No remaining claim checks.", "code": "no_credits", "doc_url": "https://lenz.io/docs/errors#quota", "upgrade_url": "https://lenz.io/plans?wall=8f14e45f-ceea-467a-9b1e-2c1f6d1e0b3a", "wall_id": "8f14e45f-ceea-467a-9b1e-2c1f6d1e0b3a", "remaining": 0, "cost": 10, "credits_remaining": 4, "resets_at": "2026-09-01T00:00:00+00:00" }
FieldMeaning
codeCurrently always no_credits — the 402 status is the signal, so you don't need to read this. It exists so a future condition can be added without a new status, and it names the condition, never the plan, capability, or endpoint. To tell "top up" from "retry a smaller batch", read remaining.
upgrade_urlWhere the wall lifts. Send users here rather than mapping codes to plan names. Also present on 429. Treat it as opaque — follow it whole rather than matching it against a fixed string; it carries a query parameter.
wall_idIdentifies this specific rejection. Already appended to upgrade_url, so following that URL is all you need — this field exists for clients that build their own upgrade link and want the same continuity. Safe to log; it identifies the rejection, not the user.
remainingCalls of this capability you could still make — verifications on /verify, assessments on /assess. Spans both buckets (monthly allowance + extra credits). Always counted at the standard price, so on a rejected depth: "low" call it can read 0 while you can still afford one at the lower price — compare credits_remaining against cost for that. Omitted, never null, when the server can't resolve it — an absent key is honest; null would read as a zero balance.
credits_remainingThe same balance in credits. Read it beside cost: "4 credits, this call needs 10" is one top-up away (top up on lenz.io/billing), "0 credits" is a plan decision, and remaining: 0 alone cannot tell you which. Same omission rule.
costCredits the rejected call would have taken, scaled by requested for a batch. Depth-aware: a rejected depth: "low" verify reports 5, and a mixed batch reports its real summed total rather than a multiple of either price.
resets_atWhen the monthly allowance rolls over. Extra credits (credits.extra on /me/usage: grants and top-ups) never expire and are unaffected. Same omission rule.
requestedBatch size that didn't fit, in calls — not credits. Only on /verify/batch, /verify/{id}/select and multi-claim /assess.

Check the balance before you hit the wall via client.usage(). credits is the balance and costs is the price list; the per-capability blocks are the same balance projected into each capability's own unit, not separate allowances — spending on one moves all of them:

u = client.usage() print(u.credits.remaining, "credits left", u.costs) print(u.verify.remaining, "verify calls left") print(u.assess.remaining, "assess calls left") print(u.credits.extra, "of them extra credits (grants and top-ups, never expiring)") print("allowance resets at", u.quota_resets_at)

Two fields are deprecated and removed on 2026-11-29. credits.bonus is the old name of credits.extra, the same number. The per-capability credits field is an alias of that block's bonus; it never meant the pool, only that capability's one-off top-up balance.

Re-submitting the same claim text under the same key after it first succeeded is a same-user cache hit — returns the existing verification_id for free, without spending credits.

Migrating from 403 (August 2026). These rejections used to be HTTP 403, which the SDKs map to LenzAuthError. They are now 402, which maps to LenzQuotaExceededError — and that is not a subclass of LenzAuthError. If you catch LenzAuthError to handle an empty balance, that branch stops firing. Add a LenzQuotaExceededError handler. This takes effect on every SDK version, including ones released before the change — you don't need to upgrade for it to reach you. detail strings are unchanged, so message-matching keeps working.

Rate limits #

LenzRateLimitError (HTTP 429)

A rate limit. Today that means the /extract daily cap of 1,000 calls per account, which resets at 00:00 UTC. Unlike a 402, this one does clear on its own — retry after retry_after seconds.

{ "detail": "Daily /extract limit of 1000 reached for this account. The allowance is shared by every key on the account and by extractions run from the Lenz website.", "code": "extract_daily_limit", "limit": 1000, "reset_in_seconds": 7200, "doc_url": "https://lenz.io/docs/errors#rate-limits", "upgrade_url": "https://lenz.io/plans" }

The same value is on the Retry-After header. The SDKs retry it automatically up to 3 times. From 2.7.0 they do so only when the wait is 60 seconds or less, and above that raise immediately with the true retry_after, so a call can't block for hours inside a sleeping retry ladder — schedule the work instead. Earlier versions sleep the full stated wait, which the daily cap can put hours out. A paid plan lifts the cap.

Webhooks #

Every webhook POST carries an X-Lenz-Signature header (sha256=...). Verify it before trusting the body. The Python and Node SDKs both ship a webhook helper:

# Python from lenz_io import LenzWebhooks wh = LenzWebhooks(secret=YOUR_HMAC_SECRET) event = wh.verify(raw_body, headers) # event is a typed VerificationCompleted / VerificationFailed / VerificationNeedsInput
// Node import { LenzWebhooks } from "lenz-io"; const wh = new LenzWebhooks({ secret: YOUR_HMAC_SECRET }); const event = wh.verify(rawBody, headers);
Check your endpoint before you spend a verification. At /api-credentials, once a webhook URL and a signing secret are both set, Send test delivers a signed webhook.test payload to your endpoint and tells you what it answered. It is the same delivery path as a real event — same signature, same headers — so a green result means a real verification's webhook will arrive too. It carries no result and no real task_id: switch on event and ignore it.

LenzWebhookSignatureError

The signature didn't match. Causes:

  • Wrong secret. Compare against the value at /api-credentials (Webhooks panel).
  • Body modified in transit. Use the raw bytes — re-serializing the parsed JSON changes whitespace and breaks the HMAC.
  • Replay older than the 5-minute window. The guard is delivered_at inside the signed body, not a header — it is covered by the HMAC, so it cannot be altered without breaking the signature. Each delivery attempt carries its own timestamp. Widen it with replay_window_seconds / replayWindowSeconds if you queue webhooks before verifying.

LenzWebhookValidationError

The body parsed but didn't match the expected event schema. Check that the SDK version is current (pip install -U lenz-io / npm install lenz-io@latest) — schema additions ship as minor bumps.

Cache-hit semantics for webhooks. When an API submission cache-hits, the customer's webhook still fires verification.completed with the customer's own verification_id — never another customer's. A same-user cache-hit re-uses the existing claim's id; a different-user cache-hit clones the verdict and fires with the clone's new id. Customers can dedup on verification_id + event.

Validation #

LenzValidationError (HTTP 422)

The request body was malformed and no amount of retrying changes that. Common causes: empty text, an empty claims or texts array, a source_url without an http:// or https:// scheme, a message over 500 characters on /ask, or an unsupported language code.

A malformed count carries code=invalid_count. It is a 422 and never a 402 — a bad n is a bug in the call, not an empty wallet, and sending someone to a pricing page over it wastes their time.

Generic shapes #

LenzAPIError

Server returned a non-2xx that doesn't map to a more specific exception. Inspect status_code, cause, and request_id (forward this to support if you need help triaging).

LenzError

Base class. Catch this if you want to handle every Lenz exception uniformly:

from lenz_io import LenzError try: v = client.verify_and_wait(claim="...") except LenzError as exc: log.error("Lenz call failed: %s (%s)", exc.message, exc.request_id)
Missing an error you hit in production? Open an issue at github.com/lenzhq/lenz-io-python or github.com/lenzhq/lenz-io-node with the request_id from the exception. We page on P1s and read every issue.

Start here if you're new: Quickstart →