Skip to content

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 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 account (LenzNotFoundError from SDK 3.0)No
409A 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
422Malformed inputNo
429Rate limit: the daily /extract cap, or too many reviews or citation checks runningYes, 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. 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.

codemeaningin 2026-05-13
not_authenticatedNo valid credential: send an API key or an OAuth access token.no code
not_foundNothing with this id (or path) exists on this account.not_found (/review, /citecheck); no code (elsewhere)
method_not_allowedThe path does not take this HTTP method.no code
invalid_requestA value in the request is not accepted; detail says which.no code
internal_errorLenz failed. The request may have completed: retry with the same Idempotency-Key.no code
malformed_bodyThe request body is not valid JSON.no code
validation_errorA field has the wrong type, length or form; errors lists each one.validation_error (/review, /citecheck); no code (elsewhere)
blank_inputThe 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_languageThe language is not one Lenz answers in.validation_error (/review, /citecheck); no code (elsewhere)
too_many_itemsMore items than one call takes.too_many_items (/assess); no code (elsewhere)
idempotency_conflictA request with this Idempotency-Key is still running.idempotency_conflict (/review, /citecheck); no code (elsewhere)
idempotency_body_mismatchThe Idempotency-Key was used before with a different body.idempotency_body_mismatch (/review, /citecheck); no code (elsewhere)
input_conflictSend either claim or claims, not both.the same
item_too_longAn item in claims is longer than one call takes.the same
invalid_selectionA selected claim was not one of the offered ones.the same
no_selection_pendingThe task is not waiting for a claim selection.the same
use_review_cancelThe task is a review's deep check; cancel the review, which cancels everything in it.the same
webhook_secret_missingA webhook_url was sent but the credential has no signing secret.the same
invalid_verdict_labelA verdict label in escalate is not one Lenz uses.the same
invalid_confidence_bandA confidence band in escalate is not low, medium or high.the same
invalid_doiA DOI in pairs is not a DOI.the same
invalid_citeA cite in pairs is not exactly one legal authority Lenz reads.the same
quote_not_in_statementA 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_tokenThe access token is invalid, expired or revoked.the same
insufficient_scopeThe access token does not allow this call; scope names what would.the same
oauth_onlyThis endpoint is for OAuth connections.the same
no_creditsNot enough credits for this call.the same
invalid_countThe requested number of calls is not a positive count.the same
extract_daily_limitThe daily /extract allowance is used up; retry_after says when it resets.the same
review_in_flightToo many reviews are running on this account; retry after retry_after seconds.the same
citecheck_in_flightToo many citation checks are running on this account; retry after retry_after.the same
capacityLenz is at capacity; retry after retry_after seconds.the same
upstream_unavailableModel or search providers are unavailable; retry after retry_after seconds.the same
purgedThe result was deleted under zero retention.the same
private_claimThe verification is private.the same
verification_failedThe verification failed; the body says why and whether to retry.the same
verification_not_readyThe verification has not finished; poll its task.verification_not_ready (GET /verifications); no code (elsewhere)
internalThe work failed on our side; sending the same request again will not help.framing_failed (/assess); extraction_failed (/extract)
page_unreadableThe page at the link could not be read; send the page's text instead.extraction_failed (/extract, a 502)
ask_failedThe follow-up question could not be answered.the same
unsupported_file_typeThe uploaded file is not in a supported format (.docx, .pdf, .png, .jpg, or .webp).the same
invalid_fileThe uploaded file is corrupt or could not be read as its format.the same
encrypted_fileThe uploaded file is password-protected.the same
empty_fileNo text was found in the uploaded file.the same
no_readable_textNo readable text was found in the uploaded file; if it is a scan, run OCR or paste the text.the same
image_animatedThe uploaded image is animated; export a still .png, .jpg or .webp frame.the same
file_too_largeThe uploaded file is larger than 10 MB.the same
text_too_longThe document text is longer than 50,000 characters.the same
macrosThe uploaded document contains macros; save it without macros.the same
too_many_pagesThe uploaded document has more than 100 pages.the same
too_complexThe uploaded file is too large or has too many parts when unpacked.the same
upload_limitToo many document uploads on this account in the past hour.the same
unsupported_formatThe requested export format is not supported.the same
not_readyThe review has not finished yet; poll until it reaches a terminal status.the same
not_exportableThis review cannot be exported as a document (failed, cancelled, or URL review).the same
nothing_to_markThe review finished with no findings or edits to mark in the document.the same
goneThe review draft is no longer available.the same
export_failedBuilding the exported document failed.the same
export_limitToo 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.

codedetailin 2026-05-13
no_checkable_claimNo claim in the input could be checked against public evidence.no_claim (/assess, /review); not_a_claim (/extract, a verification)
page_unreadableThe page at the link could not be read.invalid_input (/review)
upstream_unavailableA 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)
internalIt 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)
cancelledThe caller stopped it.the same
no_citationsThe text holds no citation to check.the same
insufficient_creditsThe 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?".

FieldMeaning
retryableWhether 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.

ErrorWhat happenedThe job
LenzConnectionErrorNo HTTP answer after the SDK’s own retries: the network, DNS, TLS or a dropped connection.The request may have reached the server.
LenzRequestTimeoutErrorOne 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.
LenzTimeoutErrorA *_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:

reasonwhat 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.

statuscodemeaningwhat to do
422page_unreadableThe 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.
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 (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_codemeaningwhat to do
no_claimNo 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_failedThe 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_unavailableToo few model providers answered 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. 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

codemeaning
input_conflictclaims was sent together with claim/text. Send one or the other.
too_many_itemsMore than 20 items. Split the list.
blank_inputAn item is empty; detail names its index. blank_item in 2026-05-13.
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.

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_codemeaningwhat to do
timeoutThe 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_unavailableA model provider was down for this claim.Retry after a short wait.
internalNo 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_reasonmeaningwhat to do
no_citationsThe 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.

statuscodewherewhat to do
409idempotency_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 finishedWait 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.
409idempotency_conflict/review, /citecheck: the first call with that key is still being createdRetry 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.
422idempotency_body_mismatch (2026-05-13: only on /review and /citecheck; the other endpoints send detail only)Any endpoint that takes Idempotency-KeyThe 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.
409no_selection_pendingPOST /verify/{id}/selectThe 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.
422invalid_selectionPOST /verify/{id}/selectA 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).
409verification_not_ready with status: "processing"GET /verifications/{id} with a task_id whose run has not completedPoll GET /verify/status/{task_id} until it completes, then read the result. hint names the call.
409verification_not_ready with status: "needs_input"The same call, when the run is waiting for you to choose among several claimsPolling does not finish it. GET /verify/status/{task_id} lists the options; resolve them with POST /verify/{task_id}/select (see LenzNeedsInputError).
409verification_failedThe same call, when the run failedThe body carries failure_reason, failure_class and retryable where they apply; branch on retryable.
409use_review_cancelPOST /verify/{task_id}/cancel, for a task that is a /review's deep checkA 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.

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, 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"
}
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. 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.

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.

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"
}
requeston 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 /verificationsleft out of the list
GET /verifications/{id}/certificateunchanged: 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).

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 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.

codeWhen
blank_inputA blank claim, text or message on verify, assess, review or ask.send; citecheck with neither text nor pairs
blank_itemA blank item in an assess list
empty_listAn empty assess list, or a select list that is empty or all blank
invalid_page_size, invalid_pagepage_size / pageSize outside 1 to 100; a page below 1
invalid_idAn id that cannot name anything: empty, . or ..
invalid_header, invalid_optionA reserved or malformed header; a bad client option or timeout / max_retries (timeoutMs / maxRetries)
conflicting_inputTwo arguments that exclude each other
invalid_argumentAnything 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 completed status 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
    )
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 →