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 (retryAfter in Node).
From SDK 3.0 every error also says whether to send the call again
(retryable) and which Idempotency-Key to send it with
(idempotency_key; idempotencyKey in Node): see
Retrying safely. From SDK 3.2 an error read from a
response also carries its headers, and the mistakes the SDK catches before sending
have their own errors: Refused before sending.
code is the API’s wire code, as the table below lists it,
exactly so with legacy_aliases=False (legacyAliases: false in Node);
by default it keeps the 2.x reading, which leaves some codes out and renames a few, and
body["code"] (body.code) is always the code as sent.
This page is the canonical list.
Contents
- The error body
- Error codes
- Failed items
- Retrying safely
- Connection failures and timeouts
- LenzConnectionError
- LenzRequestTimeoutError, and why it is not LenzTimeoutError
- LenzAbortError (Node)
- Authentication
- LenzAuthError
- LenzMissingKeyError
- LenzInvalidKeyError
- Verification
- LenzNeedsInputError
- LenzPipelineError
- 503 — providers unavailable
- LenzTimeoutError
- Assess
- Rows that carry no verdict
- 422 on a claims list
- Review and citation checks
- Claim rows that carry no verdict
- A failed citation check
- Conflicts
- Credits
- LenzQuotaExceededError (HTTP 402)
- Data retention
- Zero retention
- Rate limits
- LenzRateLimitError (HTTP 429)
- Webhooks
- LenzWebhookSignatureError
- LenzWebhookValidationError
- Validation
- LenzValidationError (HTTP 422)
- Refused before sending: LenzUsageError and LenzValidationError
- LenzInvalidResponseError
- Generic shapes
- LenzAPIError
- LenzNotFoundError (HTTP 404)
- LenzApiVersionError
- LenzError
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 (LenzNotFoundError from SDK 3.0) | No |
| 409 | A conflict: a request with the same Idempotency-Key is still running, the selection you're resolving was already resolved (no_selection_pending), the verification is not complete yet (verification_not_ready), or the task is a review's deep check and must be cancelled through the review (use_review_cancel) | Same-key conflict: yes, with the same key. Not ready: poll, or select when it needs input. no_selection_pending: poll the status first. use_review_cancel: cancel the review instead |
| 422 | Malformed input | No |
| 429 | Rate limit: the daily /extract cap, or too many reviews or citation checks running | 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. A call that gets no HTTP answer at all has no
status: the SDK raises LenzConnectionError
(LenzRequestTimeoutError when the request timed out).
The error body #
The shape depends on the API version the response is in (its
X-Lenz-API-Version header). In 2026-10-11 every error
body is {detail, code}: detail is one sentence on what happened,
code one of the codes below. A field you must fix adds
errors, one {loc, msg, type} per field. A wait is
retry_after (seconds, the same value as the Retry-After header), and a
docs link is docs_url.
In 2026-05-13 the body varies by endpoint: a 422 for a field of the
wrong type, or a missing one, has detail as a list of {type, loc, msg}
items; a docs link is doc_url; the wait on the /extract daily cap is
reset_in_seconds and on review_in_flight / citecheck_in_flight
retry_after_seconds; and the codes ask_failed, extraction_failed, framing_failed, invalid_selection, no_selection_pending, private_claim
are repeated in error.
Error codes #
Every code an error body can carry. The last column is
what a response in 2026-05-13 carries instead.
| code | meaning | in 2026-05-13 |
|---|---|---|
not_authenticated | No valid credential: send an API key or an OAuth access token. | no code
|
not_found | Nothing with this id (or path) exists on this account. | not_found (/review, /citecheck); no code (elsewhere)
|
method_not_allowed | The path does not take this HTTP method. | no code
|
invalid_request | A value in the request is not accepted; detail says which. | no code
|
internal_error | Lenz failed. The request may have completed: retry with the same Idempotency-Key. | no code
|
malformed_body | The request body is not valid JSON. | no code
|
validation_error | A field has the wrong type, length or form; errors lists each one. | validation_error (/review, /citecheck); no code (elsewhere)
|
blank_input | The text to check (or an item, or a message) is empty; errors names it. | blank_item (/assess, a claims list); validation_error (/review, /citecheck); no code (elsewhere)
|
unsupported_language | The language is not one Lenz answers in. | validation_error (/review, /citecheck); no code (elsewhere)
|
too_many_items | More items than one call takes. | too_many_items (/assess); no code (elsewhere)
|
idempotency_conflict | A request with this Idempotency-Key is still running. | idempotency_conflict (/review, /citecheck); no code (elsewhere)
|
idempotency_body_mismatch | The Idempotency-Key was used before with a different body. | idempotency_body_mismatch (/review, /citecheck); no code (elsewhere)
|
input_conflict | Send either claim or claims, not both. | the same |
item_too_long | An item in claims is longer than one call takes. | the same |
invalid_selection | A selected claim was not one of the offered ones. | the same |
no_selection_pending | The task is not waiting for a claim selection. | the same |
use_review_cancel | The task is a review's deep check; cancel the review, which cancels everything in it. | the same |
webhook_secret_missing | A webhook_url was sent but the credential has no signing secret. | the same |
invalid_verdict_label | A verdict label in escalate is not one Lenz uses. | the same |
invalid_confidence_band | A confidence band in escalate is not low, medium or high. | the same |
invalid_doi | A DOI in pairs is not a DOI. | the same |
invalid_cite | A cite in pairs is not exactly one legal authority Lenz reads. | the same |
quote_not_in_statement | A quote in pairs is not inside its statement. | the same |
url_input | /citecheck takes the text of a page, not its URL. | the same |
invalid_token | The access token is invalid, expired or revoked. | the same |
insufficient_scope | The access token does not allow this call; scope names what would. | the same |
oauth_only | This endpoint is for OAuth connections. | the same |
no_credits | Not enough credits for this call. | the same |
invalid_count | The requested number of calls is not a positive count. | the same |
extract_daily_limit | The daily /extract allowance is used up; retry_after says when it resets. | the same |
review_in_flight | Too many reviews are running on this account; retry after retry_after seconds. | the same |
citecheck_in_flight | Too many citation checks are running on this account; retry after retry_after. | the same |
capacity | Lenz is at capacity; retry after retry_after seconds. | the same |
upstream_unavailable | Model or search providers are unavailable; retry after retry_after seconds. | the same |
purged | The result was deleted under zero retention. | the same |
private_claim | The verification is private. | the same |
verification_failed | The verification failed; the body says why and whether to retry. | the same |
verification_not_ready | The verification has not finished; poll its task. | verification_not_ready (GET /verifications); no code (elsewhere)
|
internal | The work failed on our side; sending the same request again will not help. | framing_failed (/assess); extraction_failed (/extract)
|
page_unreadable | The page at the link could not be read; send the page's text instead. | extraction_failed (/extract, a 502)
|
ask_failed | The follow-up question could not be answered. | the same |
unsupported_file_type | The uploaded file is not in a supported format (.docx, .pdf, .png, .jpg, or .webp). | the same |
invalid_file | The uploaded file is corrupt or could not be read as its format. | the same |
encrypted_file | The uploaded file is password-protected. | the same |
empty_file | No text was found in the uploaded file. | the same |
no_readable_text | No readable text was found in the uploaded file; if it is a scan, run OCR or paste the text. | the same |
image_animated | The uploaded image is animated; export a still .png, .jpg or .webp frame. | the same |
file_too_large | The uploaded file is larger than 10 MB. | the same |
text_too_long | The document text is longer than 50,000 characters. | the same |
macros | The uploaded document contains macros; save it without macros. | the same |
too_many_pages | The uploaded document has more than 100 pages. | the same |
too_complex | The uploaded file is too large or has too many parts when unpacked. | the same |
upload_limit | Too many document uploads on this account in the past hour. | the same |
unsupported_format | The requested export format is not supported. | the same |
not_ready | The review has not finished yet; poll until it reaches a terminal status. | the same |
not_exportable | This review cannot be exported as a document (failed, cancelled, or URL review). | the same |
nothing_to_mark | The review finished with no findings or edits to mark in the document. | the same |
gone | The review draft is no longer available. | the same |
export_failed | Building the exported document failed. | the same |
export_limit | Too many document exports on this account in the past hour. | the same |
Failed items #
A request can succeed while an item in it fails: an /assess row, a verification, a
review or one of its rows, a citation check. The item says why with a code from this table.
In 2026-10-11 it sits in
failure.code, beside detail (the sentence below), hint,
failure_class, retryable and docs_url, with
status: "failed" on the item. In 2026-05-13 the
code is error_code on an /assess row, failure_reason on a
verification and in a review's failure block, and status on
/extract.
| code | detail | in 2026-05-13 |
|---|---|---|
no_checkable_claim | No claim in the input could be checked against public evidence. | no_claim (/assess, /review); not_a_claim (/extract, a verification)
|
page_unreadable | The page at the link could not be read. | invalid_input (/review)
|
upstream_unavailable | A provider, the time budget or a stopped run kept it from finishing; the same request can succeed later. | upstream_unavailable; timeout (/assess and review rows, citation checks: out of time); assessment_failed (/review: no row could be assessed)
|
internal | It failed on our side; sending it again as-is will not help. | internal; framing_failed (/assess rows); assessment_failed (/review: no row could be assessed); citation_check_failed (citation checks)
|
cancelled | The caller stopped it. | the same |
no_citations | The text holds no citation to check. | the same |
insufficient_credits | The balance ran out after the work was accepted. | the same |
The code set is open: branch on the codes you know and fall through on the rest. To decide
whether to retry, read retryable.
In the SDKs, a verify_batch_and_wait (verifyBatchAndWait) item whose
poll could not be read is failed and carries that error on error
(from SDK 3.2), so one bad item never ends the batch.
Retrying safely #
From SDK 3.0 every error carries two fields that answer "can I send this again, and how?".
| Field | Meaning |
|---|---|
retryable | Whether sending the same request again can succeed. true for a connection failure, a request timeout, a 429, a 5xx, and a 409 idempotency_conflict or verification_not_ready. false for any other 4xx and for LenzApiVersionError. None (null in Node) when there was no HTTP status to judge: a missing key, a *_and_wait timeout, a needs-input pause, a bad webhook signature. A failed verification, review or citation check carries the server’s own value (None / null when it sent none), and a boolean retryable in the response’s failure block wins. |
idempotency_key (idempotencyKey in Node) | The Idempotency-Key the failed call sent, yours or the automatic one; None (undefined) when it sent none. Set on every error of that call, a *_and_wait helper’s timeout included. |
A resend is safe only with that key. Pass it back and the server replays the first answer, or reports that the first request is still running, instead of running it again. A plain new call sends a new key, and if the first request reached the server it can run, and be charged, twice.
from lenz_io import Lenz, LenzError client = Lenz() try: row = client.assess(claim="The Eiffel Tower is in Berlin.").claims[0] except LenzError as exc: if not exc.retryable: raise # The same request with the same key: the server replays the first # answer, or says it is still running, instead of running it twice. row = client.assess( claim="The Eiffel Tower is in Berlin.", idempotency_key=exc.idempotency_key, ).claims[0]
import { Lenz, LenzError } from "lenz-io"; const client = new Lenz(); try { await client.assess({ claim: "The Eiffel Tower is in Berlin." }); } catch (err) { if (!(err instanceof LenzError) || !err.retryable) throw err; // The same request with the same key: the server replays the first // answer, or says it is still running, instead of running it twice. await client.assess({ claim: "The Eiffel Tower is in Berlin.", idempotencyKey: err.idempotencyKey, }); }
Every call that runs or charges for work sends a key by default:
extract, assess, select,
verify, verify_and_wait, verify_batch,
verify_batch_and_wait, ask.send, review and
citecheck. verify_batch and ask.send are new in
3.0; 2.x sent a key there only when you passed one. review and
citecheck always send one. On the others, idempotency=False
(idempotency: false in Node) sends none, and then an error has no
key to resend with. Asking the same question again on ask.send is a new
call with a new key, and is asked again; pin a key when your retry means
“the same question, once”. A 409 while the first
call is still running is sent again inside the call, with the same key, after
the wait the server states; if it still conflicts you get that error with
retryable true.
Connection failures and timeouts #
Three different things read as “it timed out”. Tell them apart before you retry, because only one of them means the work never started.
| Error | What happened | The job |
|---|---|---|
LenzConnectionError | No HTTP answer after the SDK’s own retries: the network, DNS, TLS or a dropped connection. | The request may have reached the server. |
LenzRequestTimeoutError | One request got no answer within its timeout (Lenz(timeout=); timeoutMs in Node), after the SDK’s own retries. A LenzConnectionError. | The request may have reached the server and be running. |
LenzTimeoutError | A *_and_wait helper reached its own deadline. | Keeps running on the server. Read it by id. |
LenzConnectionError
Raised, from SDK 3.0, for every transport failure worth sending again: a
server that hung up mid-response, a proxy failure, a read or write error
(in Python, 2.x let some of these escape as the raw httpx exception; in Node, a
response body that breaks off after the headers still throws the runtime’s own error, which
on a keyed call carries idempotencyKey too). It is a
LenzAPIError, as 2.x raised, with the same message; the underlying
exception is __cause__ (cause in Node). retryable
is true. In Python, a request that could never be sent (an unsupported
URL scheme, say) still raises the httpx exception.
The request may have reached the server. Send it again with its key (Retrying safely), never as a plain new call.
LenzRequestTimeoutError, and why it is not LenzTimeoutError
LenzRequestTimeoutError is one HTTP request that got no answer in time.
LenzTimeoutError is your wait ending: the request that started
the job succeeded, and the job is still running. After a
LenzTimeoutError do not resubmit; read the job by its id.
After a LenzRequestTimeoutError you do not know whether the job
started, so resubmit only with the key the error carries. A new call without it
can run, and be charged, a second time.
from lenz_io import Lenz, LenzRequestTimeoutError, LenzTimeoutError client = Lenz() try: v = client.verify_and_wait(claim="The Eiffel Tower is in Berlin.", timeout=60) except LenzTimeoutError as exc: # The wait ended; the verification is still running. Read it by id. status = client.get_status(exc.task_id) except LenzRequestTimeoutError as exc: # One request got no answer, and it may have reached the server. # Send it again with its own key, never as a plain new call. v = client.verify_and_wait( claim="The Eiffel Tower is in Berlin.", idempotency_key=exc.idempotency_key, )
import { Lenz, LenzRequestTimeoutError, LenzTimeoutError } from "lenz-io"; const client = new Lenz(); try { await client.verifyAndWait( { claim: "The Eiffel Tower is in Berlin." }, { timeoutMs: 60_000 }, // how long to wait ); } catch (err) { if (err instanceof LenzTimeoutError) { // The wait ended; the verification is still running. Read it by id. const status = await client.getStatus(err.taskId); } else if (err instanceof LenzRequestTimeoutError) { // One request got no answer, and it may have reached the server. // Send it again with its own key, never as a plain new call. await client.verifyAndWait({ claim: "The Eiffel Tower is in Berlin.", idempotencyKey: err.idempotencyKey, }); } else { throw err; } }
LenzAbortError (Node)
Thrown when the signal you passed to a call (or to a
withOptions copy) fires: during a request, a retry sleep, a poll, or
between the pages of a listAll. It is not a LenzError, so
code that retries every LenzError does not retry an abort; its
name is "AbortError" and its cause is the
signal’s reason.
Nothing is cancelled on the server. Work the server accepted keeps
running and is charged if it completes. The error carries what the call knew:
idempotencyKey when the request was keyed (resend with it to get the
same answer back), and once the work was accepted, taskId,
batchId and taskIds, reviewId or
citecheckId. Stop the work with cancel,
cancelReview or cancelCitecheck
(Stopping a run):
import { LenzAbortError } from "lenz-io"; try { await client.verifyAndWait( { claim: "The Eiffel Tower is in Berlin." }, { signal: AbortSignal.timeout(60_000) }, ); } catch (err) { if (err instanceof LenzAbortError && err.taskId) await client.cancel(err.taskId); else throw err; }
Authentication #
LenzAuthError
Fires when the server rejects the key (401 or 403): a wrong key, a revoked
one, or a token that has expired. From SDK 3.2 the two ways a call can fail
before it reaches the server have their own classes, below. Both are
LenzAuthErrors, so except LenzAuthError (Node:
instanceof LenzAuthError) still catches all three.
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.
LenzMissingKeyError
From SDK 3.2. A call that needs a key was made on a client with none:
api_key / apiKey left out and LENZ_API_KEY
unset, an empty or whitespace-only key, or a copy made with
with_options(api_key=None) / withOptions() without one.
It is raised before anything is sent, with status_code /
statusCode 0. An empty key is no key: it never falls back
to LENZ_API_KEY, so a server that picks a key per user cannot send one
user’s call with the process’s key. Fix: set the key, as above.
LenzInvalidKeyError
From SDK 3.2. The key has a character it cannot be sent with: a space, a control
character or a non-ASCII character inside it. It is raised when the client (or a
with_options copy) is built, before any call, with
status_code 0; the message never contains the key. Space,
tab and line breaks around a key are dropped silently, so a trailing newline
from a file or an environment variable is harmless. Fix: copy the key again from
lenz.io/api-credentials.
Two more codes come with an OAuth access token rather than an API key.
401 invalid_token: the token is invalid, expired
or revoked; the WWW-Authenticate header points at the
metadata a client uses to authorise again. 403
insufficient_scope: the token is valid but does not allow this
call; scope names the one to ask for. Neither clears by
retrying the same token.
403 private_claim answers a request for another
account's private verification, on the /ask routes. 403 oauth_only
answers GET /me/webhook-secret from an API key: that secret is
managed on the API credentials
page, and the endpoint is for OAuth connections.
Verification #
LenzNeedsInputError
The pipeline paused for input:
| reason | what to do |
|---|---|
multi_claim |
The submitted text contains multiple distinct claims, in one
sentence or several ("Acme was founded in 2001 and employs 400
people" is two). 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 (failure.code: no_checkable_claim in
2026-10-11) 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 September 2026 a single sentence of 400 characters or less that
held several claims did not pause: it was verified on its first claim,
and the others were listed on the result page. It now pauses with
multi_claim like any other text holding several claims.
Until 2026-09-12 a vague input paused with reason
clarification_required and a candidates list.
That reason is no longer sent and candidates has left the
schema; a vague input is now verified, and charged, like any other.
LenzPipelineError
Terminal failure inside the verification pipeline.
cause + failure_reason on the exception
describe the step that gave up.
The code is one of four: no_checkable_claim (the input wasn't a
verifiable claim; not_a_claim in 2026-05-13),
upstream_unavailable (a provider or our own executor failed; retry),
internal (anything else) and cancelled. The
detail sentence names the cause, such as the step that stopped.
The code used to name the step (framing_failed,
conclusion_failed, task_stuck, …); those now read as the
code above, in both versions.
A claim no public record settles is not a failure: it completes with
verdict: "No Public Record" and lenz_score: null.
2026-05-13 never receives that verdict and reads such a run as
it did before, failed with research_empty (no source found) or
research_insufficient and class insufficient_evidence.
The status body (and the verification.failed webhook) also
carries failure_class and retryable.
The code says what failed;
failure_class says why. Both are closed sets of stable
identifiers.
upstream_unavailable
Our model or search providers were rate-limited or down, the search for
sources did not finish, or the task was never run — resubmit after a
short wait. The only class with
retryable: true.
insufficient_evidence
2026-05-13 only: too few sources to judge the claim
(research_empty / research_insufficient). The current version
answers such a run with verdict: "No Public Record" instead. The same
submission gives the same outcome.
invalid_input
Not a checkable claim. The input has to change, not the timing.
cancelled
You stopped the run with POST /verify/{task_id}/cancel
(Stopping a run). A cancelled run is not charged and
saves no verification. In 2026-10-11 the status body reads
status: "cancelled" and the webhook is
verification.cancelled; in 2026-05-13 they read
status: "failed" with this class, and verification.failed.
Never retryable.
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. The SDK exception carries them too:
failure_class and retryable on
LenzPipelineError (failureClass and retryable in
Node). 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; its code is internal in 2026-10-11
(framing_failed on /assess, extraction_failed on
/extract in 2026-05-13). 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.
/review answers the same 503
capacity under general load, and again when its citation
checks would be shed. /citecheck answers it when citation
checks are shed. Both carry
Retry-After and retry_after, and nothing was
charged.
/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 |
|---|---|---|---|
| 422 | page_unreadable | The page could not be read: it is behind a login, missing, has too little text, or is a video with no transcript. 2026-05-13 answers 502 extraction_failed. | 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
(taskId in Node) lets you resume. Do not submit it again. This is not
LenzRequestTimeoutError, which is one
request that got no answer:
status = client.get_status(timeout_err.task_id) if status.status == "completed": verification = status.result
Bump timeout= on verify_and_wait() (in Node,
timeoutMs in the second argument:
verifyAndWait({ claim }, { timeoutMs: 600_000 })) if you
regularly hit this (default is 300s; 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. In 2026-10-11 such a row has status: "failed",
verdict and confidence null, and the reason in
failure (failed items), whose code
is the shared failure vocabulary: timeout reads upstream_unavailable
and framing_failed reads internal, with the cause in
detail. Resend a row when failure.retryable is true, never on a
list of codes.
| error_code | meaning | what to do |
|---|---|---|
no_claim | No claim in the item could be checked against public evidence. no_checkable_claim in 2026-10-11. | 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). internal in 2026-10-11. | Send it as one factual statement and retry. |
upstream_unavailable | Too few model providers answered 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. upstream_unavailable in 2026-10-11. | 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_input | An item is empty; detail names its index. blank_item in 2026-05-13. |
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.
Review and citation checks #
Claim rows that carry no verdict
A claim row of /review whose quick check produced no verdict
has assessment.status: "failed" and says why in
assessment.error_code (assessment.failure.code in
2026-10-11), as on /assess.
The row is not charged.
| error_code | meaning | what to do |
|---|---|---|
timeout | The review ran out of time before this claim was done. upstream_unavailable in 2026-10-11. | Send the claim to /assess, or review the draft again. |
upstream_unavailable | A model provider was down for this claim. | Retry after a short wait. |
internal | No verdict for a reason of ours: not a provider outage and not the time budget. | Send the claim to /assess. If it keeps failing, tell us. |
A failed citation check
A /citecheck that ends failed carries
failure.failure_reason (failure.code in 2026-10-11),
failure.failure_class
and a hint, like a failed review.
| failure_reason | meaning | what to do |
|---|---|---|
no_citations | The text has no citation to check: no link, DOI or numbered reference with one. Class invalid_input; nothing was charged. | Send a text that cites its sources, or send pairs of statement and source. |
A failed review (or citation check) and a failed citation row carry the same failure
vocabulary in 2026-10-11: no_checkable_claim,
page_unreadable (the link the review was sent could not be read;
invalid_input in 2026-05-13),
upstream_unavailable (a provider or the time budget; timeout,
and assessment_failed when no row could be assessed for want of time or a
provider, in 2026-05-13), internal
(assessment_failed otherwise, and citation_check_failed, in
2026-05-13), cancelled, no_citations and
insufficient_credits. detail names the cause. A review stopped by its
own time budget while reading the draft is upstream_unavailable and retryable
in 2026-10-11; 2026-05-13 keeps the class it always
sent, cancelled.
Conflicts #
These answers say the work or the Idempotency-Key is not in
the state the call needs: mostly 409, plus the 422
for a reused key and a bad selection. Nothing is charged for any of them.
The key is how a retry avoids doing the work twice.
| status | code | where | what to do |
|---|---|---|---|
| 409 | idempotency_conflict (2026-05-13: no code); detail reads "A request with this Idempotency-Key is already in progress." and task_id is set | /verify, /verify/batch, /verify/{id}/select, /assess, /extract, /ask, when the first call with that key has not finished | Wait a moment and send the same call with the same key: once the first call finishes, you get its stored response. SDK 3.0 does this inside the call; if the first call is still running after its retries, the error has retryable true and the key as idempotency_key. Only on /verify is task_id the run itself, to poll at GET /verify/status/{task_id}. On the other endpoints it is the placeholder "pending", with nothing to poll. |
| 409 | idempotency_conflict | /review, /citecheck: the first call with that key is still being created | Retry shortly with the same key. The body carries review_id (or citecheck_id) once the work exists, so poll that instead. From SDK 3.2 the SDK returns this answer as the started job, not an error, with settled_by_conflict (settledByConflict) true. |
| 422 | idempotency_body_mismatch (2026-05-13: only on /review and /citecheck; the other endpoints send detail only) | Any endpoint that takes Idempotency-Key | The key was already used with a different body. Send a new key for a different request, or the original body to replay the first answer. |
| 409 | no_selection_pending | POST /verify/{id}/select | The task has no claim options to select from yet. Poll GET /verify/status/{task_id}: when the task is waiting for a selection, the poll lists the options, and the select can then be sent. |
| 422 | invalid_selection | POST /verify/{id}/select | A selected text was not one of the claims offered. Send the exact text of an offered claim (its text in 2026-05-13, claim in 2026-10-11). |
| 409 | verification_not_ready with status: "processing" | GET /verifications/{id} with a task_id whose run has not completed | Poll GET /verify/status/{task_id} until it completes, then read the result. hint names the call. |
| 409 | verification_not_ready with status: "needs_input" | The same call, when the run is waiting for you to choose among several claims | Polling does not finish it. GET /verify/status/{task_id} lists the options; resolve them with POST /verify/{task_id}/select (see LenzNeedsInputError). |
| 409 | verification_failed | The same call, when the run failed | The body carries failure_reason, failure_class and retryable where they apply; branch on retryable. |
| 409 | use_review_cancel | POST /verify/{task_id}/cancel, for a task that is a /review's deep check | A review's deep check cannot be stopped alone. Cancel the review with POST /reviews/{review_id}/cancel, which stops everything in it. |
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, in 2026-05-13 (2026-10-11 names doc_url docs_url):
{
"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"
}
| 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. Divide one by the other for a capability's own unit (the
verify, ask and assess blocks hold
the same division). It is one balance, not separate allowances, so
spending on one capability moves every count:
u = client.usage() print(u.credits.remaining, "credits left", u.costs) print(u.credits.remaining // u.costs["verify"], "verify calls left") print(u.credits.remaining // u.costs["assess"], "assess calls left") print( u.credits.extra, "of them extra credits (grants and top-ups, never expiring)", ) print("allowance resets at", u.credits.resets_at)
Two fields are deprecated: read credits and
costs instead. 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.
Both stay for existing callers.
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.
Data retention #
By default a verification, its evidence and your API-call log are kept for as long as your account exists. On Pro and Scale you can set a retention period in days on the API credentials page. It applies to everything the account submits, on every channel, and takes effect 24 hours after you set it. From then on, anything older than the period is removed within an hour: the text you sent, each verification’s result and evidence, the request and response bodies in your API-call log, and your follow-up questions.
A removed verification keeps its verification_id and
answers 410:
{
"detail": "This verification is no longer available: ...",
"code": "purged",
"purged_at": "2026-10-24T09:00:00+00:00"
}
| request | on a removed verification |
|---|---|
GET /verifications/{id} (by id or task_id) | 410 purged |
GET /verify/status/{task_id} | 410 purged for a completed task; a running task is never removed |
GET /verifications/{id}/related, /ask/{id} | 410 purged |
GET /verifications | left out of the list |
GET /verifications/{id}/certificate | unchanged: the certificate is kept |
A caller who could not read the verification before it was removed gets
the usual 404, not a 410. Stop polling on a 410: the result is gone for
good. A draft checked through /review or /citecheck is
stored, with its statements, quick checks, citation checks and suggested
edits, and the
retention period removes its text like a verification’s. A quick
check of a single statement (/assess), and an extraction
(/extract), create no record: their text and result are held
in caches for up to 24 hours, and in your account’s API-call log,
which the retention period also covers. Copies held in our
caches expire within 24 hours, and backups within 60 days.
A retention period does not remove warranty certificates. A certificate
retains the checked statement, which can be identical to the text you
submitted, the verdict, the summary and the sources, for as long as the
warranty needs it. To stop new certificates being issued, turn them off
on the same page (reason
account).
Zero retention
The shortest period is zero retention (retention_days: 0 on
the settings page). What the account submits is then never written to
our database: no text, no result or evidence, no request or response
bodies in the API-call log. Requests answer from our cache for 24 hours
after a verification completes: in those first 24 hours,
GET /verifications/{id},
GET /verify/status/{task_id}, GET /verifications
and /ask/{id} answer as usual, follow-up questions
included; after that they answer 410
purged. Because nothing is written to our database, nothing
reaches our backups. Certificates are off while zero retention is set,
and a verification reports reason account.
What it does not change: the task queue holds a check for the minutes it runs, request logs keep identifiers only, and the model and search providers keep what their own terms say (see subprocessors).
Setting a retention period or turning certificates off needs a paid Pro or Scale plan. On any plan, you can always return to the defaults.
Rate limits #
LenzRateLimitError (HTTP 429)
A rate limit. Two things answer 429: the /extract daily
cap of 1,000 calls per account, which resets at 00:00 UTC, and the limit on
reviews or citation checks running at once. Unlike a 402, both
clear on their own. The wait is on the Retry-After header
on every 429; the body repeats it under the name each case uses, below.
extract_daily_limit
{
"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"
}
reset_in_seconds is the seconds until the cap resets, the same
value as the Retry-After header (2026-10-11 names it
retry_after, and doc_url docs_url); the SDK rate-limit error exposes
it as retry_after in Python and retryAfter in
Node, beside the raw reset_in_seconds / resetInSeconds. A /review of a URL reads the page like
/extract and counts as one call against this cap, so it can
answer the same code. 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. The cap is the same on every plan, so upgrading does not lift
it; it does not apply to a key Lenz has marked unlimited for
/extract, which you can ask us about.
review_in_flight and citecheck_in_flight
An account can have 3 reviews and 3 citation checks running at a time.
A fourth POST /review answers 429
review_in_flight, and a fourth POST /citecheck
citecheck_in_flight. The body carries detail,
code and the wait in seconds (60), and the response carries
Retry-After with the same value. The wait is
retry_after_seconds in 2026-05-13 and retry_after in
2026-10-11. Nothing was
charged and the Idempotency-Key was released: send the same
call again after the wait, or when one of the running checks completes.
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.parse(raw_body=raw_body, headers=headers) # event is typed: VerificationCompleted / VerificationFailed / # VerificationNeedsInput / VerificationCancelled, ReviewEvent, # CitecheckEvent; switch on event.event for any other
// Node, on a server that hands you the raw body (Express with express.raw) import { LenzWebhooks, isEvent } from "lenz-io"; const wh = new LenzWebhooks({ secret: YOUR_HMAC_SECRET }); const event = wh.parse(rawBody, headers); if (isEvent(event, "verification.completed")) { console.log(event.verification.result.verdict); }
On an edge runtime, the Node SDK’s await webhooks.unwrap(request) verifies a
standard Request with WebCrypto. A full receiver for each SDK, the event list and
the edge runtimes are on Going to production.
Work submitted with SDK 3.x sends verification.cancelled,
review.cancelled and citecheck.cancelled when a run is
stopped, not *.failed: a receiver that branches only on
*.failed misses them. Update every receiver to SDK 2.21 or later before
a sender moves to 3.x (Upgrading the SDKs).
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 server found the request body malformed and no amount of retrying changes that.
Common causes: an empty text, a source_url without an
http:// or https:// scheme, a message over
500 characters on /ask, an empty claims list on
verify_batch, 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.
Refused before sending: LenzUsageError and LenzValidationError
From SDK 3.2 a call the API would refuse is refused by the SDK first, with the
API’s own sentence as the message, so nothing is sent and nothing is charged.
The Python SDK raises LenzUsageError, a ValueError and
deliberately not a LenzError: an except LenzError that handles
API answers never catches a mistake in the call, and code that catches
ValueError keeps working. The Node SDK throws
LenzValidationError with statusCode 0 and no
body, the same class it throws for a 422 from the server. Both carry
code, what is wrong, and param, the argument at fault
(claims[2], page_size / pageSize,
task_id / taskId; empty when no one argument is). Branch on
these, never on the message. They are local codes, the same in both SDKs and whatever
legacy_aliases / legacyAliases is, and not the server’s
code on an error answer.
| code | When |
|---|---|
blank_input | A blank claim, text or message on verify, assess, review or ask.send; citecheck with neither text nor pairs |
blank_item | A blank item in an assess list |
empty_list | An empty assess list, or a select list that is empty or all blank |
invalid_page_size, invalid_page | page_size / pageSize outside 1 to 100; a page below 1 |
invalid_id | An id that cannot name anything: empty, . or .. |
invalid_header, invalid_option | A reserved or malformed header; a bad client option or timeout / max_retries (timeoutMs / maxRetries) |
conflicting_input | Two arguments that exclude each other |
invalid_argument | Anything else, such as an assess item that is not a string |
The SDK refuses only what the API would refuse. A blank claim on verify or
assess reads claim is required., a blank assess item
claims[1] is blank., a blank ask.send message
Message cannot be empty.. select sends a list with some blank
items unchanged, because the API drops them, and refuses only when nothing is left:
claims is required. verify_batch (verifyBatch)
with an empty list or a blank item, and citecheck with
pairs=[], are not checked locally; the API answers them with a
LenzValidationError from the server (HTTP 422), as above.
LenzInvalidResponseError #
From SDK 3.2. The server answered, but the SDK cannot read the answer. It is a
LenzAPIError, so except LenzAPIError catches it, and it is
raised for:
- a status below 400 whose body is not JSON, typically a proxy or captive portal answering in the API’s place;
- a JSON body that is not an object (
null, a list, a number, a string); - an empty 2xx, a 204 and a 205 included;
- any 3xx. The API never redirects, and the SDK never follows one, so the key does not go to the new address;
- an object with a field of the wrong type, on the read itself or later, on a field read only when you ask for it;
- a
completedstatus with no result, a run that ended whose verification cannot be read.
It carries the real status_code / statusCode, the
request_id, the response headers, and what came back:
body for an object, body_text / bodyText (the first
1,000 characters) for the rest. retryable is unset: the work may have
run, so send a paid call again only with its Idempotency-Key
(Retrying safely). Inside a *_and_wait helper, a poll
that cannot be read is polled again, as after a 5xx, unless its status says the
run ended, which raises it at once. A wrongly typed field often means the API is newer than
the SDK: upgrade it.
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).
LenzNotFoundError (HTTP 404)
Nothing with the id or key the call names is visible to this credential: a
wrong id, another account’s private verification, a deleted one. It is a
LenzError, as 2.x raised for a 404; its fix says to check the
id (2.x advised retrying), and retryable is false. From SDK 3.0
wait, verify_and_wait and verify_batch_and_wait
stop at once on a 404 (and on a 401 or 403) instead of polling to a
LenzTimeoutError. In verify_batch_and_wait a 404 fails that
item only (failed) and the others keep going; a 401 or 403 is about the key,
so it raises from the call.
LenzApiVersionError
SDK 3.x asks for API version 2026-10-11 and reads only that shape.
When a response names another version in its X-Lenz-API-Version
header (in practice 2026-05-13: a server still on the older version,
or a reply replayed from an idempotent request first sent before the change), the call
raises LenzApiVersionError instead of misreading it. From SDK 3.2 this applies only to a successful answer: an error answer (status 400 or above) in another version raises its own error, read from the body as sent. It carries
api_version (apiVersion in Node), from SDK 3.2 also served_version and expected_version (servedVersion, expectedVersion), status_code
(statusCode) and body as sent, and retryable is
false: the same request reads the same version again. A response without
the header is read as usual, and webhook payloads are never refused.
If it persists, contact support with the request id. For a replay of work first sent with 2.x, finish it with 2.x; never change the key to get past the error, which would run (and charge) the request a second time. The version page has the dates.
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 )
request_id from the exception. We page on
P1s and read every issue.
Start here if you're new: Quickstart →