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:
| Status | Condition | Retry? |
|---|---|---|
| 401 | Missing, malformed or unknown key | After fixing credentials |
| 402 | Out of balance | No — billing action |
| 403 | Authenticated but not allowed (private verification, IP block) | No |
| 404 | Unknown verification, or no certificate for it on this account | No |
| 409 | An identical request is already in flight (in_progress), or the selection you're resolving was already resolved (no_selection_pending) | Only in_progress |
| 422 | Malformed input | No |
| 429 | Rate limit | Yes, after Retry-After |
| 502 | A synchronous step (/assess, /extract, /ask) failed deterministically | No — same input, same outcome |
| 503 | Providers 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:
| reason | what 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.
| status | code | meaning | what to do |
|---|---|---|---|
| 502 | extraction_failed | The 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. |
| 503 | upstream_unavailable | The services that read pages, or YouTube, could not be reached. | Retry after Retry-After. |
| 503 | capacity | Too 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:
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_code | meaning | what to do |
|---|---|---|
no_claim | The 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_failed | The item could not be processed (a parse failure on our side). | Send it as one factual statement and retry. |
upstream_unavailable | A model provider was down for this item. | Retry the item; when every item is affected the call is a 503 instead. |
timeout | The 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
| code | meaning |
|---|---|
input_conflict | claims was sent together with claim/text. Send one or the other. |
too_many_items | More than 20 items. Split the list. |
blank_item | An item is empty; detail names its index. |
item_too_long | An 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.
The body:
| Field | Meaning |
|---|---|
code | Currently 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_url | Where 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_id | Identifies 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. |
remaining | Calls 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_remaining | The 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. |
cost | Credits 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_at | When the monthly allowance rolls over. Extra credits (credits.extra on /me/usage: grants and top-ups) never expire and are unaffected. Same omission rule. |
requested | Batch 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:
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.
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.
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:
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_atinside 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 withreplay_window_seconds/replayWindowSecondsif 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.
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:
request_id from the exception. We page on
P1s and read every issue.
Start here if you're new: Quickstart →