Errors reference

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

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

StatusConditionRetry?
401Missing, malformed or unknown keyAfter fixing credentials
402Out of balanceNo — billing action
403Authenticated but not allowed (private verification, IP block)No
409An identical request is already in flight (in_progress), or the selection you're resolving was already resolved (no_selection_pending)Only in_progress
422Malformed inputNo
429Per-key rate limitYes, after Retry-After

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

Authentication #

LenzAuthError

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

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

Verification #

LenzNeedsInputError

The pipeline paused for clarification. Two reasons:

reasonwhat to do
multi_claim The submitted text contains multiple distinct claims. The error's claims array lists them. Resolve by calling client.select(task_id, claim_index=0) with the index you want, then re-call verify_and_wait() on the returned task.
clarification_required The submission is ambiguous. The error's candidates array lists possible interpretations. Pick one via client.select(task_id, text="...").

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

LenzPipelineError

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

Common reasons: framing_failed (the input wasn't a verifiable claim), research_insufficient (couldn't gather enough sources), conclusion_failed (the model refused to commit). Retry with a different claim, or rephrase to something more specific.

LenzTimeoutError

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

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

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

Quota #

LenzQuotaExceededError (HTTP 402)

You're out of balance. Either the key's monthly quota and any bonus 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.

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 quota is not folded into 429: a 429 tells every client to retry, and an empty balance never succeeds on retry.

The body:

{ "detail": "No remaining claim checks.", "code": "no_credits", "doc_url": "https://lenz.io/docs/errors#quota", "upgrade_url": "https://lenz.io/plans?wall=8f14e45f-ceea-467a-9b1e-2c1f6d1e0b3a", "wall_id": "8f14e45f-ceea-467a-9b1e-2c1f6d1e0b3a", "remaining": 0, "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.
remainingUsable capacity left for that capability (monthly quota + bonus credits). Omitted, never null, when the server can't resolve it — an absent key is honest; null would read as a zero balance.
resets_atWhen the monthly quota rolls over. Same omission rule.
requestedBatch size that didn't fit. Only on /verify/batch, /verify/{id}/select and multi-claim /assess.

Check remaining before you hit the wall via client.usage() — monthly quota and one-off bonus credits are reported separately per capability:

u = client.usage() print(u.verify.remaining, "verify calls left") # quota_remaining + credits print(u.verify.quota_used, "/", u.verify.quota_total, "monthly quota") print(u.verify.credits, "bonus credits") print("quota resets at", u.quota_resets_at)

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 burning a credit.

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

Rate limits #

LenzRateLimitError (HTTP 429)

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

{ "detail": "Daily /extract limit of 1000 reached.", "code": "extract_daily_limit", "limit": 1000, "reset_in_seconds": 7200, "doc_url": "https://lenz.io/docs/errors#rate-limits", "upgrade_url": "https://lenz.io/plans" }

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

Webhooks #

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

# Python from lenz_io import LenzWebhooks wh = LenzWebhooks(secret=YOUR_HMAC_SECRET) event = wh.verify(raw_body, headers) # event is a typed VerificationCompleted / VerificationFailed / VerificationNeedsInput
// Node import { LenzWebhooks } from "lenz-io"; const wh = new LenzWebhooks({ secret: YOUR_HMAC_SECRET }); const event = wh.verify(rawBody, headers);

LenzWebhookSignatureError

The signature didn't match. Causes:

  • Wrong secret. Compare against the value at /api-integration (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. X-Lenz-Timestamp guards against replay attacks.

LenzWebhookValidationError

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

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

Validation #

LenzValidationError (HTTP 422)

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

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

Generic shapes #

LenzAPIError

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

LenzError

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

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

Start here if you're new: Quickstart →