Lenz 发布 lenz-io Node SDK:为 AI 产品团队提供事实核查 API
lenzhq/lenz-io-node
Lenz 推出面向 AI 产品团队的官方 Node SDK lenz-io,封装事实核查 API 的六类调用:extract 抽取可验证声明(免费,每账户每天 1000 次)。
Official Node SDK for the Lenz Fact Checking API for AI Product Teams.
Six API calls: one research-depth ladder, one call that runs it on a whole draft, and the citation check on its own.
extract— pull verifiable claims out of any text, optionally narrowed with afocus. Free, 1000 calls/account/day (shared across your API keys).assess— fast 3-model panel verdict in ~15s; one claim, or up to 20 claims in one call. Sync, paid.verify— full multi-model pipeline with citations in ~90s. Async, paid.citecheck— the citation check on its own: does each source a draft cites say what the draft says? Async.ask— follow-up questions grounded on a verification. Sync, paid.review— the ladder on a draft in one async call, its citations too if asked: its claims, a quick verdict on each, a deep check on the doubtful ones, issues, with rewrites.
Built for teams whose AI output is async or document-shaped: legal-memo generators, deep-research products, due-diligence platforms, vertical agents producing structured deliverables. Not chat AI, not voice AI, not real-time copilots — pipeline runs are the wrong shape for those.
First call
npm install lenz-io export LENZ_API_KEY=lenz_... # from https://lenz.io/api-credentials (a free account comes with credits)
import { Lenz } from "lenz-io"; const client = new Lenz(); // reads LENZ_API_KEY const { claims } = await client.assess({ claim: "The Eiffel Tower is in Berlin." }); for (const row of claims) { if (row.status === "failed") console.log("No verdict:", row.failure?.hint); else console.log(row.verdict, row.confidence, row.claim); // False high The Eiffel Tower is in Berlin. }
assess is the quick check: a verdict and a confidence for each claim in
about 15-20 seconds, 1 credit a claim. For sources and a 1-10 score, deep-check
a claim with verify (below); to check a whole draft, review it.
Review a draft
import { Lenz } from "lenz-io"; const client = new Lenz({ apiKey: "lenz_..." }); const draft = ` The EU AI Act entered into force on 1 August 2024, and its obligations for general-purpose models applied from 2 August 2025. Fines for prohibited practices reach 7% of global annual turnover. About 40% of European companies had started compliance work by the end of 2024. `; // One call: extract, assess, and verify the doubtful claims (async, 2–4 min) const review = await client.reviewAndWait({ text: draft }); console.log(review.outcome); // clean | issues_found | incomplete | unchecked for (const i of review.issues) { console.log(i.verdict, i.confidence, i.claim); if (i.suggested_rewrite) console.log(" Suggested rewrite:", i.suggested_rewrite); } // Past the cap: send the remaining claims to /verify in one batch const capped = review.claims .filter((c) => c.escalation?.disposition === "cap") .map((c) => ({ claim: c.claim })); const results = capped.length ? await client.verifyBatchAndWait({ claims: capped }) : [];
review reads the draft's claims (up to 20, most check-worthy first), gives
each a quick assess verdict, and sends the ones whose quick verdict is
False, Mostly False or Mixed, or whose confidence is low, through the
full verify pipeline, up to five. Change the rule with the flat options:
await client.review({ text: draft, verdicts: ["False", "Mostly False"], // quick labels that get a deep check ([] = none) confidence: ["low", "medium"], // quick confidence bands that do ([] = none) maxAssessments: 10, // how many claims get a quick verdict (1-20) maxVerifications: 3, // the deep-check cap (0 = quick verdicts only) depth: "low", // depth of every deep check });
outcomeis the field to branch on once the review ends:clean,issues_found,incomplete(a check failed) orunchecked(the review failed before it assessed anything).cleancovers the claims the review selected, at the depth its policy chose.issuesare the claims whose final verdict isFalse,Mostly FalseorMixed; a completed deep check overrides the quick verdict. A deep-checked issue carrieskey_finding,verification_idandsuggested_rewrite; an issue that stayed on its quick verdict carriesrationaleand anescalationsaying why it stayed quick (cap,credits, …).suggested_rewritecomes from the deep check; an issue that stayed on its quick verdict has one only when the review asked for suggested edits (suggestEdits: true). It is not verified itself: review it, or run it throughverify, before you use it.failureslists the claims outside the issues whose check failed.positionson each claim row says where the draft makes the claim (every place, at most 10), andmore_claim_locationsgives{ claim, positions }for each ofmore_claims, in the same order. A review checks only the claims it traced back to the draft. APositionis{ start, end, text }:start/endcount code points, so slice withArray.from(text).slice(start, end).join(""), andtextis the passage. For a URL draftstart/endarenullandtextstill carries the passage.positionsisnullwhen the claim could not be located.
Checking the draft's sources. maxCitations: N (1-20) also checks the
draft's first N citations, its links and DOIs: does each source say what the
draft says it does? Links are read from text, so keep a link as a markdown
link ([words](https://...)); a Word or Google document pasted as plain text
loses them. With maxAssessments: 0 the review checks the sources and no
claim.
const review = await client.reviewAndWait({ text: draft, maxCitations: 20, maxAssessments: 0 }); const s = review.summary; console.log(`${s.citations_found} found, ${s.citations_selected} checked`); for (const c of review.citation_issues) { // most serious first console.log(c.finding, c.cited_url ?? c.doi, c.statement); if (c.snippet) console.log(" The source says:", c.snippet); }
finding is one of doi_not_found, page_not_found, contradicted,
quote_not_in_source, not_in_source or metadata_mismatch, most serious
first. partly_supported (the source backs part of the statement) is reported
in citations with its snippet, but it is not an issue: is_issue is false
and it is not in citation_issues. citations lists every checked
citation with its check; a row that could not be checked says why in
check.unchecked_reason and what to do in check.hint, and
citation_failures lists the ones that failed on our side. A citation issue
makes outcome issues_found even when issues is empty. rationale is a
reviewer's note, not a checked source; snippet is the passage from the page.
more_claims and more_citations list what the draft holds past
maxAssessments and maxCitations: found, not checked, to send in a later
request. Leave maxCitations out (or 0) and no citation is checked.
Suggested edits. suggestEdits: true also returns, for each claim with a
suggested rewrite (from its deep check, or from its quick check when it stayed
on the quick verdict), the smallest edits to the draft that make it say what
the rewrite says, in the draft's own language. They cost no extra credits, and
the review completes once they are settled. Each claim row
(and its issue) carries suggested_edits: status "pending" or
"completed", and edits, each a span of the text you sent (start/end in
code points, text the exact slice) and its replacement. An empty edits
means no edit could be made safely, or it could not be computed. They are not
themselves verified. Two claims that share a passage can return overlapping
edits: apply one of them.
Offsets all index the text you sent, so apply every claim's edits together, from the end of the text back:
const review = await client.reviewAndWait({ text: draft, suggestEdits: true }); const edits = review.claims .flatMap((c) => c.suggested_edits?.edits ?? []) .sort((a, b) => b.start - a.start); const chars = Array.from(draft); // code points let takenFrom = chars.length; for (const e of edits) { // skip an edit that overlaps one applied, or whose text the draft no longer holds if (e.end <= takenFrom && chars.slice(e.start, e.end).join("") === e.text) { chars.splice(e.start, e.end - e.start, ...Array.from(e.replacement)); takenFrom = e.start; } } const edited = chars.join("");
reviewAndWait polls on the review's own poll_after_seconds and takes
{ timeoutMs, onUpdate }: onUpdate(review) fires on every poll that changed
the review, so you can show the quick verdicts as they land. It throws
ReviewFailedError (hint, review, whose failure.code says why) when the review fails and
ReviewTimeoutError (reviewId, partial) at the deadline (10 minutes by
default); the review keeps running, so read it later with
client.getReview(reviewId). Without waiting: client.review(...) returns
{ review_id }, and client.getReview(reviewId, { view: "issues" }) returns
the review without its claims.
Credits: 1 per claim assessed, plus 10 (5 at depth: "low") per deep check,
plus 1 per checked citation;
review.credits.charged says what the review cost. A resend with the same
idempotencyKey within 24 hours returns the same review; a new key is a new
review.
A runnable version is in examples/core/review-draft.ts.
Check a draft's citations
citecheck runs the citation check on its own, without the rest of a review:
does each cited source say what the draft says it does? Send a draft (its
links are read from the text, as for review) or the statement-source pairs
yourself.
const check = await client.citecheckAndWait({ text: draft, maxCitations: 10 }); console.log(check.outcome); // clean | issues_found | incomplete | unchecked for (const c of check.citation_issues) console.log(c.finding, c.cited_url ?? c.doi, c.statement); // Pairs: each checked as it is (maxCitations does not apply). A DOI pair can // carry what the reference gives: citedTitle, citedAuthors, citedYear, citedJournal. await client.citecheckAndWait({ pairs: [ { statement: "Water boils at 100 degrees Celsius at sea level.", url: "https://en.wikipedia.org/wiki/Boiling_point", }, { statement: "Diamond sensors can measure temperature in a living cell.", doi: "10.1038/nature12373", citedYear: "2013", }, ], });
The body carries the same rows as a review's: citations, citation_issues,
citation_failures, summary and more_citations (the draft's citations past
maxCitations, found but not checked). client.citecheck(...) returns a
citecheck_id at once; read it with client.getCitecheck(citecheckId).
citecheckAndWait throws CitecheckFailedError when the check fails and
CitecheckTimeoutError at the deadline. citecheck.completed and
citecheck.failed webhooks parse into CitecheckCompleted / CitecheckFailed.
A runnable version is in examples/core/citecheck.ts.
Quickstart — the canonical integration
import { Lenz } from "lenz-io"; import type { AssessClaim } from "lenz-io"; const client = new Lenz({ apiKey: "lenz_..." }); // 1. extract — pull verifiable claims out of any text (free) // add focus: "..." to narrow it to the claims you care about const out = await client.extract({ text: llmOutput }); const claims = (out.claims ?? []).map((c) => c.claim); // 2. assess — one call per 20 claims (extract finds up to 100), one row per // claim, in the same order (~15s a call, sync) const quick: AssessClaim[] = []; for (let i = 0; i < claims.length; i += 20) { quick.push(...(await client.assess({ claims: claims.slice(i, i + 20) })).claims); } for (const c of quick) { console.log(c.verdict, c.confidence, c.claim); if (c.rationale) console.log(" ", c.rationale); } // 3. verify — escalate the low-confidence rows to the full panel + citations // verifyBatchAndWait takes up to 20 claims a call: the first 20 here const doubtful = quick .filter((c) => c.status !== "failed" && c.confidence === "low" && c.claim) .map((c) => ({ claim: c.claim! })) .slice(0, 20); const results = doubtful.length ? await client.verifyBatchAndWait({ claims: doubtful }) : []; for (const r of results) { if (r.status === "completed") { const v = r.verification!; console.log(v.verdict, v.lenz_score, v.executive_summary); } } // 4. ask — a follow-up question on a completed deep check, when there is one const deep = results.find((r) => r.status === "completed")?.verification; if (deep?.verification_id) { const reply = await client.ask.send(deep.verification_id, { message: "Which source is strongest?", }); console.log(reply.content); }
assess({ claims }) takes up to 20 claims per call and answers with exactly
one row per item, in the order sent. A row with status === "failed" had no
verdict: failure.code says why (no_checkable_claim, framing_failed,
upstream_unavailable, or timeout — an open set; the last two are the ones
worth resending as-is) and failure.hint says what to send next. Failed rows
are free. A compound item is assessed on its main claim and lists the rest in
more_claims — send those as their own items to check them. The
single form, assess({ claim }), takes one text and answers with a row per
claim found in it, up to 20, at 1 credit each; a text that makes more claims
gets its 20 most check-worthy checked and the rest in more_claims, unchecked
and free — send them back as claims, 20 a call. The two are mutually
exclusive.
Each verdict row also carries an optional note. rationale is the
reasoning of a reviewer who agrees with the panel's verdict. It is a
reviewer's note, not a checked source; for sourced evidence, call verify.
Read it as optional: it can be null or absent. (dissent is deprecated: it
is always null and is kept only so code that reads it keeps working.)
Suggested rewrite. suggestRewrite: true also writes, on each row the
check found False or Mostly False with high confidence, the claim with its
wrong part corrected (suggested_rewrite, else null), at no extra credit.
It is not itself verified: review it, or run it through verify, before
using it.
const [row] = ( await client.assess({ claim: "Venus is the closest planet to the Sun.", suggestRewrite: true }) ).claims; if (row?.suggested_rewrite) console.log(row.suggested_rewrite); // e.g. "Mercury is the closest planet to the Sun."
assess and verify share a result cache server-side: if a claim
already has a deep verification, assess returns it via
verification_url and you can skip the escalation. An answer served from
that cache (a claim checked in the last hour) is free, so a tool that
resends the same request is not charged twice.
How verification works
Framing → Research → Debate (2 models, 2 rounds) → Panel Review
(3 reviewers running the same checks, 2 more when they disagree) → Conclusion. ~90 seconds wall-clock
per claim. assess runs a leaner 3-model panel against the same
framing for the ~15s pass.
Quickstart demo
import { Lenz } from "lenz-io"; const client = new Lenz({ apiKey: "lenz_..." }); const v = await client.verifyAndWait({ claim: "Sharks don't get cancer" }); console.log(v.verdict, v.lenz_score); // False 2.0 for (const source of (v.sources ?? []).slice(0, 3)) { console.log(" -", source.title, source.url); }
The demo claim is cached for an hour after anyone verifies it, so it can come back in seconds; otherwise it runs the full pipeline (~90s) like your own claims. Use webhooks for production async flows.
Get your webhook secret here → lenz.io/api-credentials
What you get on the client
Inputs are camelCase; outputs keep the API's names. (The 2.x snake_case
inputs, source_url / webhook_url on a batch item and cited_title /
cited_authors / cited_year / cited_journal on a citation pair, still
work and are deprecated. A citation pair's own enumerable keys are read, as
when it is serialized. Giving both spellings of a field with different values
throws the local argument error (LenzValidationError) naming both before anything is sent.)
client.extract({ text })→ExtractedClaims. Free, capped at 1000/account/day. Addfocusto narrow the list, andlocate: trueto keep only the claims traced back to your text with where each is made — see Steering extract. Each attempt waits up to 150s by default (a timeout is retried like any transport error, under the same idempotency key);timeoutMsin the options argument (extract(input, { timeoutMs })) overrides it for that call.client.assess({ claim })→AssessResponse. Sync, ~15s, returns one entry per identified claim. (textis accepted as an alias: a document istext, a claim isclaim.)client.assess({ claims })→AssessResponse. Up to 20 claims in one call, one row per item in the order sent; rows without a verdict come back in position withstatus: "failed"and afailure(code,hint). Both forms take a per-calltimeoutMsin the options argument (default 100s: a long text can take up to 90s on the server).client.verify({ claim })→TaskAccepted. Async submit; returns atask_id. Get the result by polling (client.wait(...)/client.getStatus(...)) or via a webhook.client.verifyAndWait({ claim, ... })→Verification. Submit + poll until the pipeline lands (sync ergonomic). Equivalent towait(verify(...)).client.wait(task)→Verification. Block on atask_id(or aTaskAccepted) until it terminates. The polling counterpart to a webhook.client.verifyBatch({ claims })→BatchAccepted. Fan-out for multi-claim LLM outputs.client.verifyBatchAndWait({ claims })→BatchItemResult[]. Fan out a batch and poll every item to completion; one result per claim, in input order, never throws on a per-item failure.client.ask.{history,send,reset}(verificationId, ...)→ Q&A on a verification.reply.contentuses a small markdown subset (**bold**,*italic*,-or*bullets, blank-line paragraphs) — render with a minimal markdown library or display verbatim. See docs/quickstart#ask-reply-format.client.verifications.{list,get,delete,related}(...)→ manage past verifications.verifications.list({ page, pageSize })reads one page;pageSizeis a whole number from 1 to 100 (anything else throws before a request) and, when omitted, the server's default of 20 applies.verifications.listAll()iterates every page (for await (const v of client.verifications.listAll({ pageSize: 100 })) …), one request a page, each asking for the samepageSize. All API claims are private; reference them byverification_id. Cache-hit on another customer's claim is transparent — you always see your ownverification_id, never another customer's.client.library.list(...)→ browse the public catalog (no API key needed).library.listAll(filters)iterates every page of a filtered list (anysortbut"random").client.usage()→ your credit balance (credits), the price list (costs—verify10,assess1,ask1,extract0 — pluscost_optionsfor parameter-dependent prices such asdepth), and that balance projected into each capability's unit (verify/ask/assess), plus the dailyextractrate limit. Also reportshas_webhook_secret— whether this key can receive signed webhook callbacks (verifywith awebhook_urlneeds one); the secret value itself is never exposed. See Credits.
Polling without webhooks
verify() returns immediately with a task_id; the pipeline runs async (~90s
for a cold claim). You don't need webhooks to get the result — poll for it.
The one-liner is verifyAndWait(). If you already hold a task_id (or want to
submit and wait separately), use wait():
const task = await client.verify({ claim: "Sharks don't get cancer" }); // async const verification = await client.wait(task); // blocks console.log(verification.verdict, verification.lenz_score);
To run several claims in parallel, submit a batch and wait on all of them.
verifyBatchAndWait returns one BatchItemResult per claim, in input order, and
never throws because a single claim failed — inspect each item's status:
const results = await client.verifyBatchAndWait({ claims: [ { text: "Sharks don't get cancer" }, { text: "The Eiffel Tower is 330m tall", sourceUrl: "https://example.com/paris-guide" }, ], }); for (const r of results) { if (r.status === "completed") { console.log(r.claim, "→", r.verification!.verdict); } else { console.log(r.claim, "→", r.status); // needs_input | failed | timeout } }
A failed item with no status_detail is one whose poll answered something
waiting will not change for that claim: the verification was removed under its
account's retention period (HTTP 410, see Retention), the task
was not found (404), or the answer came in another API version. Every other
failure carries a status_detail.
A verify takes ~90 seconds, so show your users where it is. onProgress fires
once per poll while the run is going — it takes the taskId as well, because
the batch helper round-robins several ids in one loop:
await client.verifyAndWait( { claim: "Sharks don't get cancer" }, { onProgress: (taskId, p) => console.log(`${p.step} — step ${p.index} of ${p.total}`) }, ); // framing — step 1 of 5 // research — step 2 of 5 // ...
p.step is one of starting / framing / research / debate /
adjudication / conclusion. p.index is stage position, not elapsed
work — the stages are uneven, so a bar driven by it sits on research for
roughly half the run. A throw inside your callback never breaks the poll.
Every waiter takes its wait options as the second argument:
await client.wait(task, { timeoutMs: 180_000, onProgress }); await client.verifyAndWait({ claim }, { timeoutMs: 180_000, onProgress }); await client.verifyBatchAndWait({ claims }, { timeoutMs: 180_000, onProgress }); await client.reviewAndWait({ text: draft }, { timeoutMs: 600_000, onUpdate }); await client.citecheckAndWait({ text: draft }, { timeoutMs: 600_000, onUpdate });
timeoutMs is the wait's deadline: 300 s by default for verifications, 10
minutes for reviews and citation checks. Every wait starts it after the submit
(review and citation waits since 3.0; before, their budget included the
submit). With 0 or less a wait submits normally and polls once.
The verification waits call onProgress(taskId, progress); review and citation
waits call onUpdate(body) with the whole changed body. Passing timeoutMs /
onProgress inside the verifyAndWait / verifyBatchAndWait input, as 2.x
did, still works and is deprecated; when both are given, the second argument
wins field by field.
Prefer webhooks for production async flows (no long-lived HTTP connection);
prefer polling for scripts and request/response handlers where awaiting is
fine. For full control over the loop, call getStatus(taskId) yourself — it's a
single non-blocking poll. A completed status always carries its result:
one that does not (the API never sends it; an expired verification answers 410
instead) throws LenzInvalidResponseError with the answer's status, headers
and body (since 3.2), as the waits do.
Stopping a run
Stop a run you no longer need: three calls, one per kind of work. None sends a
body or an Idempotency-Key; cancelling is safe to repeat, so a failed attempt
is retried like any other request.
const accepted = await client.verify({ claim: "..." }); const out = await client.cancel(accepted.task_id); // { task_id, cancelled, status } const review = await client.cancelReview(reviewId); // the review, as getReview returns it const check = await client.cancelCitecheck(citecheckId); // the check, as getCitecheck returns it
cancel(taskId)answers for every run of yours, whatever its state.cancelled: truemeans the run is cancelled (status: "cancelled"), by this call or an earlier one, so a repeat, or a retry after a lost response, answerstrueagain.cancelled: falsemeans it was not cancelled and nothing changed:statusis the run's status, normallycompleted(the verification exists and was charged as usual) orfailed(not charged). A task thatselectalready resolved answerscancelled: falsewithneeds_input: cancel the task idsselectreturned. A cancelled verification is not charged and saves nothing. Reading it afterwards withgetStatusreturns the status"cancelled", andwaitthrows the error for a failed run withfailureClass"cancelled"andretryablefalse.cancelReview(reviewId)stops the review and the deep checks it started, and returns the full view withstatus: "cancelled"; a review that had already finished is returned unchanged. A review's deep checks cannot be cancelled on their own:cancelon one throws aLenzErrorwithstatusCode409 andcode"use_review_cancel"(not retryable). Cancel the review instead.cancelCitecheck(citecheckId)does the same for a citation check.
A review is charged only for what it delivered before the cancel (the quick checks it served, the deep checks that finished, the citations it checked); the rest is refunded or never charged. A citation check is charged only for the citations it checked; the rest are refunded.
An unknown id, another account's, or (for cancel) the task of a run started
on the website throws LenzNotFoundError (404); a purged review or check
throws LenzGoneError (410). An empty id, . or .. throws before any request
is sent.
Aborting a call
Every method takes a signal (see Configuration). When it
fires, the call stops where it is (a request, a retry sleep, a poll, the items
of a listAll) and throws LenzAbortError:
import { LenzAbortError } from "lenz-io"; try { await client.verifyAndWait({ claim }, { signal: AbortSignal.timeout(60_000) }); } catch (e) { if (e instanceof LenzAbortError && e.taskId) await client.cancel(e.taskId); else throw e; }
LenzAbortErroris not aLenzError(an abort is your decision, not an API answer), itsnameis"AbortError", and itscauseis the signal'sreason(aTimeoutErrorforAbortSignal.timeout(ms), which bounds a whole call, retries and polls included).- Nothing is cancelled on the server unless a wait asks for it with
cancelOnAbort: true(below). Work the server accepted keeps running and is charged if it completes. Stop it withcancel,cancelRevieworcancelCitecheck, as above. - It carries what the call knew:
idempotencyKeywhen the request was keyed (resend with it to get the same answer, or the submit's receipt, back instead of starting the work again), and once the work was accepted,taskId,batchIdandtaskIds(every accepted task, in input order),reviewIdorcitecheckId. A call withidempotency: falsecarries no key; if it was aborted during the submit, there is no safe way to find the task (verifications.listmay show it). - A client copy made with a signal (
withOptions({ signal })) is dead once the signal fires: every later call on it throwsLenzAbortError. Make such a copy per request, and cancel through the client you made it from.
Cancel the run when the caller goes away
The waits (wait, verifyAndWait, verifyBatchAndWait, reviewAndWait,
citecheckAndWait) take cancelOnAbort: true (since 3.1; default false).
When the signal fires after the run was accepted, the wait sends the matching
cancel itself, then throws the same LenzAbortError:
export async function POST(request: Request): Promise<Response> { const { text } = (await request.json()) as { text: string }; const review = await client.reviewAndWait( { text }, { signal: request.signal, cancelOnAbort: true }, ); return Response.json(review); }
- The signal must fire when your caller goes away. Where your framework hands
you a
Requestwhosesignalfires on a client disconnect (Bun does), passrequest.signal. On Cloudflare Workers,request.signalneeds theenable_request_signalcompatibility flag, and a disconnect may end the invocation before the cancel leaves: send the cancel yourself insidectx.waitUntil, or do not rely on this. Express has norequest.signal: make anAbortController, callcontroller.abort()onres.on("close", …)when!res.writableFinished, and passcontroller.signal. - The cancel is
cancelfor a verification (one per task a batch accepted and not yet seen to end, sent concurrently),cancelReviewfor a review andcancelCitecheckfor a citation check. It is best effort: one attempt each, no retry, all within 5 s, after which the abort is thrown whatever the cancels did. So the abort is thrown up to 5 s after the signal fires: withAbortSignal.timeout(ms), the call can overrunmsby up to 5 s. - A cancel that fails, times out, or finds the run already ended is reported
to the client's
logger.warnwith the job id only, never thrown. Without a logger it is silent. - What is charged: a cancelled verification is not charged; a cancelled review or citation check is still charged for what it had delivered (only the rest is refunded); a run that completed before the cancel reached it is billed as a completed run.
- An abort during the submit has nothing to cancel: resend with the error's
idempotencyKeyto find the run, as above. A*AndWaitcalled with a signal that has already fired sends nothing;wait(taskId, …)already has the id, so it sends the cancel, then throws withtaskId. - The wait's own
timeoutMsrunning out is not an abort and never cancels the run. A signal you pass,AbortSignal.timeout(ms)included, is an abort. - It is an option of one wait, not of
withOptions(which throws if giventrue): a copy'ssignalfiring cancels the run only for the waits called withcancelOnAbort: true.
Response shape — the unified vocabulary
Every claim-shaped response shares these fields at top level:
| Field | Type | Notes |
|---|---|---|
claim |
string |
The framed claim text. |
verdict |
string |
"True" | "Mostly True" | "Mixed" | "Mostly False" | "False" | "Error". |
confidence |
string |
Categorical: "high" | "medium" | "low". |
lenz_score |
number | null |
Integer 1–10 (deep verdicts and list endpoints; assess omits it). |
Newer field names
Since 3.0 the SDK asks for API version 2026-10-11 (X-Lenz-API-Version),
the response shape with one name for each field, and reads only that shape
for its own calls (webhooks of both shapes are still parsed). Responses carry
the newer names beside the 2.x ones, which keep their 2.x values and are
deprecated (struck through in editors) but still there, so code written
against 2.x keeps compiling and reading the same fields, except for the
differences listed under Breaking in the changelog and the
steps under its Migrating section (the API must answer 2026-10-11; code
that reads raw bodies, and webhook receivers on lenz-io older than 2.21.0,
need updating first). The raw bodies (LenzError.body, a
webhook event's raw) show the response as sent.
| Read this | Instead of (deprecated) |
|---|---|
extract → claims ([{ claim, positions }]) |
claim, identified_claims, locations |
assess row status (completed | failed) |
verdict === "Error" |
failure (code, detail, hint, ...) |
error, error_code, failure_reason, row hint |
more_claims on an assess or review row |
identified_claims |
claim on receipt items and needs_input options |
claim_text, text |
completed_at on a verification |
modified_at (set only on a later calendar day) |
claim_limit_exceeded, citation_limit_exceeded |
claim_limit_reached, citation_limit_reached |
The full list is under "Deprecated" in the 3.0.0 entry of the
changelog. A failure's code says no_checkable_claim where
the 2.x fields say not_a_claim (verify, extract) or no_claim
(assess, review). extract's status keeps reading not_a_claim.
Coverage reasons
On an account with the warranty, a verification carries coverage. When
coverage.status is "uncovered", coverage.reasons says why, from a closed
set (CoverageReason): plan, account, depth, verdict, quality,
withdrawn, issue_failed. account means the account turned certificates
off; it applies to checks submitted after the change, and a verification that
already carries a certificate keeps it.
Suggested rewrite
A verification can carry suggested_rewrite, a string: a suggested rewrite
of claim that the verification's findings support, to use in place of the
original sentence. It has not been verified itself: before using it, review
it or run it through client.verify({ claim }). It is null for a true
claim, when no correction is established, and on verifications that predate
the field, and absent on responses from an API that predates it. It is on every verification, single or listed:
verifications.get, verifications.list, library.list, verifyAndWait,
wait, and the verification.completed webhook's result. assess rows
carry their own with suggestRewrite: true.
const rewrite = v.suggested_rewrite ?? null; if (rewrite) { console.log("Suggested rewrite:", rewrite); }
Webhooks
Lenz signs each delivery with HMAC-SHA256 over the raw body. LenzWebhooks
verifies the signature, rejects a payload outside the replay window, and
returns a typed event. Two ways to call it, by runtime:
await webhooks.unwrap(request)takes a standardRequestand verifies with WebCrypto. Use it on Cloudflare Workers, Deno, Bun, Vercel Edge, Next.js route handlers, Hono, and any other framework that gives you aRequest. It needs no Node built-in.await webhooks.parseAsync(rawBody, headers)is the same for a framework that hands you the body (a string or bytes) and the headers instead.webhooks.parse(rawBody, headers)is synchronous and uses Node'scrypto. Use it on Node servers that give you the raw body (Express withexpress.raw). On a runtime without Node'scryptoit throws an error that points you tounwrap.
Both throw the same LenzWebhookSignatureError for a missing or wrong
signature, a body that is not a JSON object, or a stale delivered_at.
// Next.js: app/api/lenz-webhook/route.ts import { LenzWebhooks } from "lenz-io"; export async function POST(request: Request) { // Built inside the handler: `next build` imports this module without the secret. const webhooks = new LenzWebhooks({ secret: process.env.LENZ_WEBHOOK_SECRET! }); const event = await webhooks.unwrap(request); // throws LenzWebhookSignatureError // ...handle the event (below)... return Response.json({ received: "ok" }); }
// Hono, on Workers, Deno or Bun app.post("/webhook", async (c) => { const event = await new LenzWebhooks({ secret: c.env.LENZ_WEBHOOK_SECRET }).unwrap(c.req.raw); // ... return c.json({ received: "ok" }); });
Do not read the body (request.json(), request.text()) before unwrap: the
signature covers the exact bytes sent, and a body can be read once.
Handling the event, here on Node with Express:
import { LenzWebhooks, isEvent } from "lenz-io"; const webhooks = new LenzWebhooks({ secret: "whsec_..." }); // In your Express handler (use express.raw() to get rawBody as Buffer): app.post("/lenz-webhook", express.raw({ type: "application/json" }), (req, res) => { const event = webhooks.parse(req.body, req.headers as Record<string, string>); // isEvent narrows on the event name AND the member it promises, so a // malformed payload under a known name is never taken for a real one. if (isEvent(event, "verification.completed")) { const r = event.verification.result; // r.verdict, r.lenz_score, r.confidence, ... } else if (isEvent(event, "verification.needs_input")) { // …surface candidate claims, call client.select(taskId, ...) to resolve } else if (isEvent(event, "verification.failed")) { // failure.code is WHERE the pipeline stopped; failure_class is WHY // (closed set) and retryable tells you what to do about it. if (event.failure?.retryable) { resubmitLater(event.taskId); // transient provider outage } else { logPermanentFailure(event.taskId, event.failure?.code); } } else if (isEvent(event, "verification.cancelled")) { // Stopped elsewhere (the website's Stop button, another process): nothing // to retry. Sent for work submitted under 2026-10-11; older work arrives // as verification.failed with failure class "cancelled". markCancelled(event.taskId); } else if (isEvent(event, "review.completed")) { // Dedupe on eventId: a retry of the same delivery keeps it. if (!alreadyHandled(event.eventId)) { for (const i of event.review.issues) flagIssue(i.claim, i.verdict, i.suggested_rewrite); } } else if (isEvent(event, "citecheck.completed")) { for (const c of event.citecheck.citation_issues) flagCitation(c); } // Any other event (one you do not recognise, or a malformed one): ignore // it. New kinds are added without a major release. res.status(200).send(); });
verification.cancelled, review.cancelled and citecheck.cancelled (a task
cancelled elsewhere) narrow with isEvent; each carries the cancelled
verification / review / citecheck and its eventId, nothing more. They are sent only for work submitted under API version
2026-10-11; a cancellation of older work keeps arriving as *.failed with
failure class cancelled.
review.completed and review.failed carry the whole review under review,
as client.getReview returns it, and citecheck.* the whole check under
citecheck; a review's own deep checks fire no verification.* events.
Dedupe on eventId, which stays the same across retries while attempt
changes. Every event carries eventId when its payload does (all of them,
for work started with 3.x); the original shape of verification.* events
has none.
See examples/core/nextjs-webhook.ts (Next.js),
examples/core/hono-webhook.ts (Hono) and
examples/core/express-webhook.ts (Express)
for runnable receivers, and examples/core/verify-llm-output.ts
for the headline assess-then-escalate pattern.
Credits
One balance per account, spent by every billable call:
| Call | Credits |
|---|---|
verify (and verifyBatch, select) |
10 per claim |
verify with depth: "low" |
5 per claim |
assess |
1 per claim; Error rows are free |
ask |
1 |
extract |
0 — free, bounded by a daily cap instead |
const u = await client.usage(); u.credits.remaining; // 5070 — the balance, in credits u.credits.extra; // 200 — the non-expiring part of it u.credits.resets_at; // when the monthly allowance refills, or null u.costs["verify"]; // 10 credits per verification u.cost_options.verify.depth.low; // 5 — half price at depth: "low" u.verify.remaining; // 507 — the same balance, in verifications u.assess.remaining; // 5070 — and in assessments u.extract.calls_today; // /extract is free: a daily cap, not a credit price u.extract.daily_limit;
verify / ask / assess are projections of the one balance, not
separate allowances — spending on any of them moves all three. Divide
credits.remaining by costs[...] yourself if you prefer; the blocks just do
it for you, flooring (5 credits is 5 assessments and 0 verifications).
Read costs as a map rather than destructuring known names: a new capability
appears in it without an SDK release, and the keys are the server's own.
credits.bonus is the deprecated old name of credits.extra, the same
number, and the per-capability credits field (always that capability's
one-off top-up balance) is the deprecated name of bonus. Both, like the
verify / ask / assess blocks and quota_resets_at, are kept for
existing code: usage() fills them in from credits and costs when the API
sends only those.
Depth pricing
cost_options.verify.depth.low is the price of a depth: "low" verification — half a
standard one. low caps research breadth (fewer discovery queries, a hard
extraction ceiling, no recovery fetch tiers) while every reasoning step runs
the same models; it is not a model downgrade.
It is a price, not a capability, which is why it is nested under
cost_options rather than sitting in costs beside the four capability
names. There is deliberately no u.verify_low
block beside u.verify — it would report the same balance in a second unit.
Divide the balance yourself when you want the count:
// Every level is optional: a server predating this field sends `{}`, and // the capability's default price in `costs` is the right fallback. const low = u.cost_options.verify?.depth?.low ?? u.costs["verify"]; const lowDepthLeft = Math.floor(u.credits.remaining / low); // 1014
You are charged for the depth you requested, not the one you were served.
The depth echoed on the completed verification is what the verdict was
produced with, so it can read standard on a low request — the echo
describes the evidence behind the answer, the charge follows the request. A
batch may mix depths and is billed per item.
A verdict served from the last hour's cache is free, on verify,
assess and review alike. The one exception is a verify that issues your
business plan a new warranty certificate, charged at the depth you requested.
Errors
Every error subclass is typed and carries a requestId you can quote on
support tickets, and retryable: true when sending the same request again
later can succeed (a network failure, a transport timeout, a 429, a 5xx, a
409 idempotency_conflict or verification_not_ready), false when it
cannot (any other 4xx), null when unknown.
An error from a call that sent an Idempotency-Key carries it as
idempotencyKey (undefined otherwise). Sending the same request again is
safe only with that key: pass idempotencyKey: exc.idempotencyKey back. A
plain new call mints a new key, and if the first one reached the server, the
work runs (and is charged) twice.
import { LenzAuthError, LenzConnectionError, LenzNotFoundError, LenzQuotaExceededError, LenzRateLimitError, LenzUpstreamUnavailableError, LenzValidationError, } from "lenz-io"; try { await client.verifyAndWait({ claim: "..." }); } catch (exc) { if (exc instanceof LenzQuotaExceededError) { // HTTP 402. Out of balance — retrying will not clear it. console.error(exc.remaining); // 0 verifications left, or null if unreported console.error(exc.creditBalance); // 4 credits held, or null if unreported console.error(exc.cost); // 10 — what this call would have taken // `cost` is depth-aware: a rejected depth: "low" verify reports 5, and a // rejected batch mixing depths reports its real summed total. Read it // rather than multiplying `requested` by a price you assumed. console.error(exc.resetsAt); // "2026-09-01T00:00:00+00:00", or null console.error(exc.upgradeUrl); // https://lenz.io/plans } else if (exc instanceof LenzAuthError) { console.error(String(exc)); // Unauthorized // Cause: Invalid api key // Fix: Your credential is missing, invalid or expired. Check the key you passed, or get a new one at https://lenz.io/api-credentials. // Docs: https://lenz.io/docs/auth // Request ID: req_abc123 } else if (exc instanceof LenzRateLimitError) { // Waits up to 60s are already retried for you, so reaching here means // either the ladder ran out or the wait is long. Don't sleep it — the // /extract daily cap can be hours away. scheduleRetryIn(exc.retryAfter); } else if (exc instanceof LenzValidationError) { for (const fieldErr of exc.errors) { console.error(fieldErr["loc"], fieldErr["msg"]); } } else if (exc instanceof LenzNotFoundError) { // HTTP 404: nothing with that id is visible to this key. Check the id; // retrying will not help. } else if (exc instanceof LenzConnectionError) { // No HTTP answer after the automatic retries: a network failure, or // (LenzRequestTimeoutError, a subclass) one attempt ran past timeoutMs. // exc.cause is the underlying fetch error. The request may have reached // the server: resend with the same key so it cannot run twice. await client.verifyAndWait({ claim: "...", idempotencyKey: exc.idempotencyKey }); } else if (exc instanceof LenzUpstreamUnavailableError) { // HTTP 503, code "upstream_unavailable" (model/search providers // exhausted) or "capacity" (submissions shed at the door). Nothing was // charged. Waits up to 60s are already slept through by the automatic // retry ladder; reaching here means the server stated a longer one. scheduleRetryIn(exc.retryAfter ?? 90); // typically 90-120s } else { throw exc; } }
A failed verification (as opposed to a failed HTTP call) throws
LenzPipelineError from verifyAndWait / wait. Since 2.8.0 it carries
failureClass (closed set: upstream_unavailable | insufficient_evidence
| invalid_input | cancelled | internal) and retryable — true means
a transient provider-side exhaustion where resubmitting the same claim is the
right move; older servers leave it null. A verification, review or citation
check cancelled elsewhere (the website's Stop button, another process) ends a
wait the same way: failureClass is "cancelled" and retryable is false.
Reading it with getStatus / getReview / getCitecheck returns the status
"cancelled" and does not throw.
LenzConnectionError and LenzUpstreamUnavailableError are subclasses of
LenzAPIError, so a 2.x handler for it still catches them. A
LenzRequestTimeoutError (one HTTP attempt took too long) is not a
LenzTimeoutError, which means a wait (wait, *AndWait) reached its
deadline while the job kept running on the server: read it later, do not
resubmit it.
wait, verifyAndWait and verifyBatchAndWait stop at once when a poll
answers an error waiting cannot change: wait throws it (401, 403, 404,
LenzApiVersionError). In a batch, a 404 or an answer in another API version
for one claim makes that claim read "failed" while the others keep being
polled (since 3.2 the row's non-enumerable error holds that error, as it
does a LenzGoneError for a purged claim and a LenzInvalidResponseError
for an ended poll that could not be read; undefined on a run that failed on
the server, whose status_detail says why); a 401 or 403 is about the key, so verifyBatchAndWait throws it. A
5xx, a 429 or a network drop is polled through. No poll runs past the wait's
timeoutMs; once it is spent, the claims still running read "timeout"
(wait throws LenzTimeoutError).
A read of a verification removed under its account's retention period throws
LenzGoneError (HTTP 410, code "purged", with purgedAt), and wait /
verifyAndWait stop on it instead of polling to the deadline. See
Retention.
A successful response (status below 400) that names an API version other
than 2026-10-11 in its X-Lenz-API-Version header (for example
2026-05-13, as an older stored replay of an idempotent call can) is not
parsed: the call throws LenzApiVersionError with servedVersion (the
version named), expectedVersion ("2026-10-11", both since 3.2),
apiVersion (the same as servedVersion), statusCode and body (as sent);
its message names the version the API answered. If it persists, contact
support with the request id; lenz-io 2.x reads both versions. An idempotent
request first sent with 2.x (before lenz.io served 2026-10-11) and replayed
with the same key is answered this way: finish such work with 2.x, and never
change the key to get past it, which would run the call again. A response with
no such header is not checked, and neither are webhook events.
An error response (400 or above) in another version throws its own error,
the one its status and body call for (a LenzQuotaExceededError with its
balance, a LenzRateLimitError with its retryAfter, ...), retried like any
other, with the version it named in servedVersion (since 3.2; 3.0 and 3.1
threw LenzApiVersionError for it, hiding the balance or the wait).
verifications.delete still throws a 404 in another version rather than
reading it as already deleted.
body and code. err.body is the parsed JSON body of the error
response exactly as sent (null or {} when there was none): the source of
truth, every other field is read from it. err.code is the server's
machine-readable code, "" when there is none. By default it is the code
lenz-io 2.x reported for that call, which left some codes out (not_found,
not_authenticated on the review calls, ...) and renamed a few (a blank
assess item reads blank_item). On a client made with
legacyAliases: false it is exactly the body's code (since 3.2):
not_found, idempotency_conflict, validation_error, blank_input,
not_authenticated, ... The error's class and its other fields are the same
either way.
retryAfter is the wait the response stated, in whole seconds. Where it
comes from depends on the error:
| Error | retryAfter read from, first match |
Unstated |
|---|---|---|
LenzRateLimitError (429) |
the Retry-After header, then the body's reset_in_seconds, retry_after_seconds, retry_after |
0 |
LenzAPIError (any 5xx), LenzUpstreamUnavailableError (503 upstream_unavailable / capacity) included |
the body's retry_after, then retry_after_seconds, then the Retry-After header |
null |
The client's own retries (429, 5xx and a 409 still in flight) sleep the
Retry-After header first, then the body's reset_in_seconds,
retry_after, retry_after_seconds, whatever the status, up to 60 s. A
longer stated wait on a 429 or a typed 503 throws at once with it on
retryAfter; on any other 5xx the client keeps to its own backoff.
headers (since 3.2): the error response's headers as a plain object,
names in lower case (err.headers?.["retry-after"]); undefined when there
was no HTTP answer or the SDK raised the error itself. servedVersion
(since 3.2): the X-Lenz-API-Version the error response named, "" when it
named none.
A 2xx answer whose body is not JSON (a proxy's or captive portal's page, a
body cut short) throws LenzInvalidResponseError, a LenzAPIError with the
real statusCode, the requestId and bodyText (the first 1000 characters
as received); retryable is null, so resend a paid call only with its
idempotencyKey. Since 3.2 so does every answer below 400 that is not a JSON
object, because every endpoint this client calls answers with one:
- an empty or blank body, a 204, a 205 and a
Content-Length: 0answer included (3.0 and 3.1 read those three as{}); its message says "empty body"; - a body that is JSON but not an object (
null, a list, a number, a string):bodyTextholds it, and its message says "not a JSON object"; - any redirect (3xx, or a browser's opaque redirect,
statusCode0): the API never redirects, so something in between did. The client asksfetchnot to follow redirects (redirect: "manual"), so the key never goes to the new address; the error names theLocationwhen there is one, and is not retried.
A JSON object with a field of the wrong type where the SDK itself reads it
throws LenzInvalidResponseError too (since 3.2), with that answer's
statusCode, headers, requestId, body and bodyText, instead of a
TypeError deeper in or a result of the wrong type: verifyBatchAndWait on a
receipt whose items is not a list of objects, and a cancel answered with
another job's body (a LenzAPIError before, without the answer).
Inside a wait (wait, verifyAndWait, verifyBatchAndWait, reviewAndWait,
citecheckAndWait), a poll that cannot be read is handled by what it says
about the run. One that says the run ended (a completed status whose
result is not an object; a body naming the review or check with status
completed, failed or cancelled but missing what the SDK reads) throws
that LenzInvalidResponseError at once: polling again would only run to a
timeout that hides why. In verifyBatchAndWait it ends only that row, as
"failed" with the poll as received in status_detail and the error in the
row's error, and the other rows complete. Any other unreadable poll (no readable status, a 2xx
that is not JSON, another job's body) is polled again like a 5xx; if the wait
then reaches its timeoutMs, and its last poll was such an answer, the
timeout error (LenzTimeoutError, ReviewTimeoutError,
CitecheckTimeoutError) carries that LenzInvalidResponseError as its
cause, and its message ends "the last polls could not be read". The SDK does
not validate fields it does not read: those are returned as sent.
statusCode 0 otherwise means the request got no HTTP answer at all
(LenzConnectionError). Before 3.2 a body that is not JSON threw the runtime's
SyntaxError, and one that is JSON but not an object failed later with a
TypeError.
Only Lenz errors leave a request (since 3.2): a fetch that rejects (a
TypeError, say) is retried and then thrown as LenzConnectionError, with the
fetch error as cause; a 2xx body that breaks off or does not decode is a
LenzConnectionError too (resend a paid call with err.idempotencyKey), and on
an error status the status stands and the body is lost; a fetch that resolves
to something that is not a Response is a LenzConnectionError. A header
value a request cannot carry (an idempotencyKey or an option header with a
line break or a non-ASCII character) is refused before sending with
LenzValidationError, never left to fetch. Before, these threw the
runtime's own errors. Content-Type: application/json is sent only with a
body (a cancel, a DELETE or a GET sends none).
The rule: the SDK refuses locally only what the API would refuse, in the
API's words. A blank input is refused before anything is sent (since 3.2), with
LenzValidationError: statusCode 0, no body, a local code (blank_input,
empty_list, blank_item), param, and as its message the API's own 422
sentence for that operation, whatever legacyAliases says (the same strings
as the Python SDK):
| Call | Input | code |
param |
Message |
|---|---|---|---|---|
verify, verifyAndWait, assess |
claim (or text) only whitespace |
blank_input |
"claim" |
claim is required. |
verify, verifyAndWait, assess |
no claim or text at all |
blank_input |
"claim" |
claim: Field required |
assess |
claims: [] and no claim (the API reads absent) |
empty_list |
"claims" |
claim: Field required |
assess |
a blank item | blank_item |
"claims[1]" |
claims[1] is blank. |
select |
claims empty, or every item blank |
empty_list |
"claims" |
claims is required. |
select given texts |
texts empty, or every item blank |
empty_list |
"texts" |
claims is required. |
select |
neither claims nor texts |
empty_list |
"claims" |
claims: Field required |
ask.send |
message only whitespace |
blank_input |
"message" |
Message cannot be empty. |
ask.send |
no message |
blank_input |
"message" |
message: Field required |
review, reviewAndWait |
text only whitespace |
blank_input |
"text" |
text: send the draft, or one public http(s) URL. |
review, reviewAndWait |
no text |
blank_input |
"text" |
text: Field required |
citecheck, citecheckAndWait |
neither text nor pairs |
blank_input |
"text" |
payload: Value error, send exactly one of text and pairs |
A field the call does not carry at all (undefined, which JSON leaves out)
gets the API's sentence for a missing field. A 422 the API sends itself is
still read by legacyAliases: by default, a local refusal says the canonical
sentence above while the same input answered by the server (an older
client, a request built by hand) reads the 2.x one ("Text is required.").
An assess item that is not a string is invalid_argument (param "claims[1]"), with the message claims[1] must be a string (got number). in both modes (the JavaScript type name, null for null; the same sentence as the Python SDK, with its type names). As on the API,
the one of claim / text that has content is used, and blank means empty or
whitespace by the API's rule (a BOM is content). A select list with some
blank items is sent unchanged: the API drops the blank ones, and answers
"claims is required." only when none is left, which is all the SDK refuses.
verifyBatch items are not checked locally: the API answers a blank item.
A bad argument is refused before anything is sent, with
LenzValidationError (since 3.2; before, a plain Error, and a TypeError
for a webhook body that is not bytes): statusCode 0, no body, the same
message as before, a code saying what is wrong and param naming the
argument (since 3.2; the codes are the Python SDK's, param in this SDK's
camelCase). It is still an Error, and a wait stops on it at once.
code |
When | param |
|---|---|---|
blank_input |
verify / assess with no claim (or only whitespace); ask.send with no message; review / reviewAndWait with no text (or only whitespace); citecheck with neither text nor pairs |
"claim"; "message"; "text" |
blank_item |
a blank assess item |
"claims[1]" |
empty_list |
select without claims; assess with an empty claims list (and no claim) |
"claims" |
invalid_page_size |
pageSize on verifications.list / listAll that is not a whole number from 1 to 100 |
"pageSize" |
invalid_page |
a listAll start page below 1 |
"page" |
invalid_id |
an id that cannot name one thing (not a string, empty, ., .., a lone surrogate), on every method that puts one in a path, and wait |
"taskId", "reviewId", "citecheckId", "verificationId" |
invalid_header |
an idempotencyKey a header cannot carry; request-option headers that are not an object, or with a bad name, a reserved name, or a value a header cannot carry |
"idempotencyKey"; "headers" |
invalid_option |
request options (timeoutMs, maxRetries, signal, cancelOnAbort, options that are not an object), a wait's timeoutMs that is not a finite number (NaN, Infinity, a string), new Lenz() / new LenzWebhooks() options that are not an object, new Lenz() (timeoutMs, maxRetries, legacyAliases), withOptions() (an unknown option, legacyAliases, an apiKey that is not a string), new LenzWebhooks() without a secret |
the option's name; for an unknown withOptions() option, the name as given (withOptions({ apikey }) → "apikey") |
conflicting_input |
assess given both claim and claims; citecheck given both text and pairs, or maxCitations beside pairs; two spellings of a field that differ (a batch item's sourceUrl / source_url, a citation pair's) |
"claims"; "text", "maxCitations"; "claims[0]", "pairs[2]" |
invalid_argument |
anything else: an input that is not an object (verify(null), select("t"), a verifyBatch claims that is not a list of objects), webhook headers or an unwrap request that are not objects, an assess item that is not a string, library.listAll with sort: "random", verifySignature without a secret, a webhook body that is not bytes |
"input", "claims[1]", "headers", "sort", "secret", "body" |
USAGE_ERROR_CODES lists the SDK's own codes (the UsageErrorCode type
names one); the Python SDK has the same list. These are local codes: the same whatever legacyAliases says, and never the API's
422 codes, which come only from a response. param is undefined on an error from the API
(read its errors instead). A runtime missing what the SDK needs (WebCrypto,
Node's crypto for the synchronous webhook parse) and a webhook request
whose body was already read still throw a plain Error.
LenzInvalidKeyError (since 3.2, a subclass of LenzAuthError): the key
has a character a key never has inside it, so it cannot be sent (see
Configuration); thrown when the client or the copy is made,
statusCode 0.
LenzMissingKeyError (since 3.2, a subclass of LenzAuthError): a call
that needs a key was made with none configured (no apiKey, an empty or
whitespace-only one, no LENZ_API_KEY, or a withOptions copy made without
one); thrown before sending, statusCode 0, message "API key required".
The API's 401 / 403 stay a plain LenzAuthError, so a handler for
LenzAuthError catches all three; tell them apart with instanceof.
LenzQuotaExceededError is a sibling of LenzAuthError, not a subclass —
"fix your key" and "top up your account" are different actions. So if you were
checking LenzAuthError to handle an empty balance, that branch stops firing;
add a LenzQuotaExceededError case.
Resuming a verification
If a verifyAndWait call exceeds its timeoutMs (default 300000) or your
process dies mid-poll, the pipeline keeps running. The exception carries the
taskId:
import { LenzTimeoutError } from "lenz-io"; try { await client.verifyAndWait({ claim: "..." }, { timeoutMs: 30000 }); } catch (exc) { if (exc instanceof LenzTimeoutError) { console.error("resume later via:", exc.taskId); } } // Later (different process / restart) — block on the same task_id: const verification = await client.wait("tsk_abc123"); console.log(verification.verdict, verification.lenz_score); // ...or do a single non-blocking poll yourself (a `completed` status with no // `result` throws LenzInvalidResponseError; an expired one, LenzGoneError): const status = await client.getStatus("tsk_abc123"); if (status.status === "completed") { console.log(status.result?.verdict, status.result?.lenz_score); }
Retention
An account on the Pro or Scale plan can set a retention period on its
API credentials page. Verifications older than the period are removed, and every
read of one — verifications.get, wait / getStatus on its task, related
claims, follow-up questions — throws LenzGoneError:
import { LenzGoneError } from "lenz-io"; try { await client.verifications.get("a1b2c3d4"); } catch (exc) { if (exc instanceof LenzGoneError) { console.error(exc.code, exc.purgedAt); // "purged", "2026-10-01T09:00:00+00:00" } }
It also disappears from verifications.list(). Retrying does not bring a
removed verification back. The certificate of a
covered verification is kept and can still be downloaded.
Idempotency
Every call that runs or charges for work sends an Idempotency-Key by
default: verify, verifyAndWait, verifyBatch, verifyBatchAndWait,
select, assess, extract, ask.send, review and citecheck. The key
is random per call and reused across that call's own retries, so a network
drop or a client timeout doesn't start a duplicate verification or charge a
second credit. It is never derived from the request: the same text sent again
in a new call is a new request. Pin a key with idempotencyKey: "..." (so a
retry from another process replays too), or opt out with
idempotency: false (on review, citecheck and their *AndWait helpers
since 3.2; they always sent one before). A non-empty idempotencyKey wins over
idempotency: false. Without a key, a retried submit can run (and charge)
twice: opt out only when your caller already dedupes, or with maxRetries: 0.
const reply = await client.ask.send(verificationId, { message: "Which source says that?", idempotencyKey: `${conversationId}:turn-4`, });
A resend with the same key within 24 hours replays the first answer (the same
receipt, review or reply) instead of running the call again. A resend while
the first call is still running is answered 409 (body.code
idempotency_conflict); the client waits and asks again with the same key
and body, within the call's retries and timeout. If the first call is still
running after that, it throws that LenzError (statusCode 409,
retryable: true): send it again later with the same key
(idempotencyKey: err.idempotencyKey), never with a new one, which would run
the call a second time.
review and citecheck (and their waits) differ here: a 409
idempotency_conflict that names the job (review_id / citecheck_id)
means the first submit with that key created it, so the call returns it as a
ReviewStarted / CitecheckStarted (status "queued", raw the 409's
body, httpStatus 409 and headers the 409's) instead of waiting or
throwing. Its settledByConflict is true (since 3.2; false on the 202
that accepted a submit), so you can tell the two apart; like raw it is not
an enumerable key. Read or wait for it by its id as usual.
A submit sent without a key never gets such a 409.
cancel, cancelReview and cancelCitecheck send none: cancelling again
returns the run as it stands, so a repeat is harmless.
Every error of a call that sent a key carries it as err.idempotencyKey,
including the timeout of a *AndWait (resending it with that key returns the
work already started). A plain new call mints a new key and can run twice. On
ask.send, the default key is per call: it protects that call's own retries
(a network drop does not ask twice or charge twice), and asking the same
question again in a new call is a new turn with a new key, answered and
charged again. Pin idempotencyKey (one per turn, say
${conversationId}:turn-4) to make a resend from another process replay the
first answer.
Steering extract
extract returns every major factual claim it finds, ranked most-check-worthy
first. On a long document that is often more than you want to verify. Pass
focus to narrow it:
const out = await client.extract({ text: pitchDeck, focus: "market size, growth and competitors", });
A focus can only select from the claims the extractor found. It cannot add a claim, reword one, reorder them, change the output language, or change what counts as a claim — selection runs over the claim list, not over your document, so a claim you get back is one an unfocused call would have returned too, verbatim.
At most 300 characters. A longer focus is rejected with a 422 rather than truncated, so you never get a subset you did not ask for.
When the document has claims but none fall within your focus, status is
"no_match" and claims is empty. The unfocused list is never
substituted — widen the focus and call again.
if (out.status === "no_match") { // nothing in this document matched; broaden the focus }
A focused call costs the same single unit of the daily cap as an unfocused one.
Locating claims in the text
Pass locate: true to keep only the claims that could be traced directly back
to your text, with where the text makes each one:
const out = await client.extract({ text: draft, locate: true }); for (const found of out.claims ?? []) { for (const p of found.positions ?? []) { if (p.start === null || p.end === null) continue; // the text was a URL // Offsets are code points: slice with Array.from, not text.slice. const passage = Array.from(draft).slice(p.start, p.end).join(""); console.log(found.claim, "->", passage); // passage === p.text } }
A claim found nowhere in the text, or found with a different figure, is left
out; if none is left, status is "not_a_claim". claims has one entry
per returned claim, and each entry's positions lists every place the text makes the claim, in text
order (1 to 10). Locating adds a few seconds.
start and end (exclusive) count Unicode code points in the text as you
sent it. JavaScript's text.slice(start, end) counts UTF-16 units, so it
shifts after an emoji; Array.from(text).slice(start, end).join("") is the
correct slice. Both are null when the text was a URL (the page is not
returned, so there is nothing to index); text is always the passage as it
appears. The same Position shape marks a claim in a review and a citation's
statement (where text is null: the row carries the statement).
claims is [] when every claim was left out (status is then
"not_a_claim"). Each claim's positions is null when locate was not set
or when the claims could not be located, in which case the list is returned
unfiltered. locate defaults to false.
Multi-language output
The Lenz API returns prose fields (atomic claim, executive summary, debate, panel
reasoning) in any of 12 languages. Pass language: on verify, verifyAndWait,
verifyBatch, assess, extract, or ask.send. Verdict labels stay English
regardless of language. On extract, language and focus are independent —
a focus written in any language selects claims emitted in language.
const v = await client.verifyAndWait({ claim: "La Tierra es plana", language: "es", // Spanish output }); console.log(v.verdict, v.language); // False es
Supported codes: en (default), es, de, fr, it, pt, nl, sv, da,
no, fi, bg. To ask for another language, contact us at
https://lenz.io/contact.
Answer in the language of the text
Pass language: "auto" on assess, verify, verifyAndWait, review,
reviewAndWait, extract or ask.send and the answer comes back in the
language of the text you submitted (for a review, one language for the whole
draft). A concrete code such as "es" always wins, and omitting language
still means English. On ask.send, "auto" uses the language of the claim
being discussed. On extract, the claims are written in the language of the
text (of the fetched page when text is a single URL); a short or undetectable
text gives English, and the result's language is the code that was used. Pass
it on to assess or verify to keep a chain in one language, rather than
sending "auto" again on short extracted claims. verifyBatch and citecheck
do not accept "auto" (the API answers 422, as for any unsupported language).
A review of a draft that is only a link decides its language once the page is
read: until then its language reads "auto", and a page that cannot be read
leaves English.
const v = await client.verifyAndWait({ claim: "Die Erde ist flach", language: "auto", // answer in German }); console.log(v.language); // de
On assess with a claims list, one language is chosen for the whole request:
the language most items agree on, else English. For a list in mixed languages,
name a code instead.
A runnable version is in examples/core/verify-auto.ts.
Per-item override on verifyBatch:
const batch = await client.verifyBatch({ claims: [ { claim: "Coffee causes cancer." }, // en (batch default) { claim: "El café causa cáncer.", language: "es" }, // overrides ], language: "en", });
Configuration
new Lenz({ apiKey: "lenz_...", // or set LENZ_API_KEY env var baseUrl: "https://lenz.io/api/v1", // override for staging / local timeoutMs: 30000, maxRetries: 3, fetch: customFetch, // inject for tests logger: console, // optional: retries (debug), verifyAndWait's task id (info), a cancelOnAbort cancel that did not cancel (warn); silent without one });
timeoutMs (a finite number of ms above 0) is the timeout of one HTTP attempt;
maxRetries (a whole number, 0 or more) is how many times a failed request is
retried. Any other value throws LenzValidationError (see below) when the client is made.
A custom fetch is called with the attempt's signal in its init. The
timeout and a caller's signal work by aborting it, so a fetch you pass must
honour init.signal (every runtime's own fetch does); one that drops it
cannot be timed out.
A key is visible ASCII with nothing inside it: one with a space, a line break,
another control character or a non-ASCII character inside it throws
LenzInvalidKeyError (a LenzAuthError, statusCode 0) when the client (or a
withOptions copy) is made, before any request (since 3.2; before, every call
failed on it). ASCII whitespace around a key (space, tab, line feed, carriage
return, form feed, vertical tab: a trailing newline read from a file, say) is
trimmed silently, with no warning: " lenz_...\n" sends lenz_.... Anything
else outside visible ASCII is refused, at the ends too (a non-breaking space,
a BOM). The message never contains the key.
A text containing a lone UTF-16 surrogate (half of an emoji cut by a
slice) is sent with each one replaced by U+FFFD; valid pairs are unchanged.
Environment variables:
LENZ_API_KEY— read ifapiKeyis not passed (or isundefined). An explicitapiKey: ""(or one of only whitespace) means no key and never reads the environment: a call that needs a key throwsLenzMissingKeyError(aLenzAuthError). A server holding several users' keys makes its client withapiKey: ""and gives each request its user's key withwithOptions({ apiKey })(see Using lenz-io from a server that forwards per-user credentials).LENZ_BASE_URL— read ifbaseUrlis not passed.
Per-call options
Every method takes request options for one call: in its options argument
(verify(input, options), getStatus(taskId, options), usage(options), …),
or merged into the options object it already takes (the waits' options,
getReview's { view }, verifications.list's { page, pageSize },
verifications.related's { limit }):
await client.assess({ claim }, { timeoutMs: 120_000, maxRetries: 0 }); await client.verify({ claim }, { signal, headers: { "X-Trace-Id": traceId } }); await client.getReview(reviewId, { view: "issues", signal });
| Option | What it does |
|---|---|
signal |
Stops the call: every request, retry sleep and poll it makes. Throws LenzAbortError (see Aborting a call). |
timeoutMs |
The timeout of one HTTP attempt, in ms; each retry gets it again. |
maxRetries |
How many times a failed request is retried. |
headers |
Extra request headers. A User-Agent or Accept here replaces the client's. null removes one a withOptions copy set; undefined is ignored. |
What the options bound, per method:
| Methods | timeoutMs |
maxRetries |
|---|---|---|
Plain calls (verify, verifyBatch, select, getStatus, cancel*, review, citecheck, getReview, getCitecheck, usage, verifications.*, ask.*, library.list) |
each attempt (default: the client's, 30 s) | each request's retries |
extract, assess |
each attempt; used as given (so is a copy's), even below the 150 s / 100 s these wait at least when the timeout is the client's own | each request's retries |
Waits (wait, verifyAndWait, verifyBatchAndWait, reviewAndWait, citecheckAndWait) |
stays the wait's whole budget; a poll's attempt timeout is the client's (or the copy's), cut at what is left | the submit's; wait takes none (it throws). Verification polls keep the client's retries; review and citation-check polls are one attempt each |
verifications.listAll, library.listAll |
each page request (options checked when listAll is called) |
each page request's retries |
withOptions |
every call made through the copy | every call made through the copy |
The waits also take cancelOnAbort: true: when the signal fires after the
run was accepted, the run is cancelled on the server too (see
Cancel the run when the caller goes away).
For one call, the call's value wins, then the deprecated timeoutMs inside an
extract / assess input, then a withOptions copy's, then the client's.
Headers merge (case does not matter; the call's value wins), the others
replace. extract and assess wait at least 150 s and 100 s when the
timeout is the client's own (new Lenz({ timeoutMs }) or the default); a
timeoutMs given for the call or on a withOptions copy is used as given
(3.1 and earlier raised a copy's to the minimum too). A timeoutMs below
100 s on assess or 150 s on extract can end a call the server is still
running, and charging for: /assess can take about 90 s and charges before
its panel runs, so the caller pays and loses the answer. Resend it with the
same idempotency key (err.idempotencyKey) to get that answer rather than
running it twice. Invalid values (a timeoutMs that is
not a finite number above 0, a maxRetries that is not a whole number from 0,
a timeout above 2,147,483,647 ms (the longest a timer can hold), a header name
that is not a valid token, a header value that is not a string or null, or
one that is not visible ASCII with spaces and tabs only between visible
characters) throw LenzValidationError before any request. X-Lenz-API-Version, Idempotency-Key (use idempotencyKey),
Authorization (use apiKey), Content-Type, Content-Length, Host and
Transfer-Encoding cannot be set as options.
client.withOptions(options) returns a copy of the client whose options apply
to every call made through it. The copy is cheap: it shares the fetch, key,
base URL and logger, keeps your subclass and any method you replaced, and
leaves the original untouched. A copy's timeoutMs is also the attempt timeout
of its waits' polls. A copy of a copy starts from the first copy's options: its
headers merge over them, its signal is added to the first copy's, and its
timeoutMs / maxRetries replace them. A call reads its options once, when it
is made: changing the objects you passed afterwards changes nothing, for every
page of a listAll and every poll of a wait too.
A copy is made without running your constructor, so it cannot carry
JavaScript #private fields: a subclass method that reads one throws a
TypeError on a copy (a wait then ends with that error). A subclass that
keeps state in #private fields should not be copied with withOptions;
pass the options per call instead, or keep that state in ordinary properties.
const quick = client.withOptions({ timeoutMs: 10_000, maxRetries: 1 }); // One copy per incoming request: the call stops when the caller goes away. export async function POST(request: Request): Promise<Response> { const { claim } = (await request.json()) as { claim: string }; const perRequest = client.withOptions({ signal: request.signal }); return Response.json(await perRequest.assess({ claim })); }
withOptions also takes apiKey: a copy that sends another key (or OAuth
access token) on the same transport, for a server that calls Lenz for several
accounts. Only when apiKey is absent does the copy keep the client's key;
given as undefined, null, an empty or a whitespace-only string (a tenant
with no key), the copy has no key, and a call that needs one throws
LenzMissingKeyError (a LenzAuthError) before sending. A copy never reads LENZ_API_KEY. A key with
a space, a control character or a non-ASCII character inside it throws
LenzAuthError when the copy is made. An option name withOptions does not take
(apikey, api_key, ...) throws.
const client = new Lenz({ apiKey: process.env.LENZ_API_KEY }); const forTenant = client.withOptions({ apiKey: tenant.lenzKey }); await forTenant.assess({ claim });
An OAuth access token for the Lenz API works wherever the API key goes: pass it as apiKey or in LENZ_API_KEY.
Results as the API sends them (legacyAliases)
3.x results also carry the names and values 2.x returned, computed from the
2026-10-11 response (see Newer field names).
new Lenz({ legacyAliases: false }) turns that off: each result is exactly
the body the API sent. The default is true, so 3.x behaviour is unchanged.
It is set on the client; a withOptions copy keeps it (and throws if given
it). The result types are unchanged; with false, these are not added:
| Result | Not added with legacyAliases: false |
|---|---|
A verification (verifications.get, list / listAll items, library.list items, wait / verifyAndWait, a batch result's verification, a review row's verification) |
modified_at |
getStatus (also a batch result's status_detail and a needs-input error's payload) |
on a pause, each option's text (options carry claim only); on failed, the flat error, failure_reason, failure_class, retryable, docs_url, hint and failure.failure_reason; on cancelled, the same flat fields and the failure block built when the API sent none |
assess |
on a failed row, verdict: "Error" and confidence: "low" (both stay null), and on every row error_code, hint, identified_claims, candidate_claims, failure.failure_reason; at the top, error, error_code, candidate_claims, status, failure.failure_reason |
extract |
claim, identified_claims, candidate_claims, locations, and status stays no_checkable_claim (not not_a_claim) |
verifyBatch / select receipts, verifyBatchAndWait results |
each item's claim_text |
usage |
credits.bonus, quota_resets_at and the verify / ask / assess blocks |
getReview, reviewAndWait, cancelReview |
failure_reason in every failure block (the review's, failures, issues, each claim row's assessment and verification, each citation row's check, citation_issues, citation_failures); on an assessment, identified_claims, error_code, hint; in summary, claim_limit_reached and the second name of citation_limit_reached / citation_limit_exceeded; and the defaults for keys the body lacks ([] for citations, citation_issues, citation_failures; null for more_claims, more_claim_locations, more_citations, rows' positions, suggested_edits, suggested_rewrite, missing_quote, the summary's citation counts and policy.max_citations; 0 for summary.citation_issues; false for policy.suggest_edits) |
getCitecheck, citecheckAndWait, cancelCitecheck |
failure_reason in every failure block, the second name of the citation limit, and the [] / null defaults for citations, citation_issues, citation_failures, more_citations |
Errors (their classes and fields, ReviewFailedError / CitecheckFailedError
included), webhook events and the requests sent are the same either way, with
two exceptions: the partial of a ReviewTimeoutError /
CitecheckTimeoutError (the last body the wait saw) is that body as the API
sent it, and err.code is exactly the code of the response body (since 3.2;
see Errors).
A field sent as null (since 3.2). A few fields keep a 3.x type without
null (an assess row's verdict / confidence, the assess error_code, a
pause option's text, an entity's name, a status's reason / hint /
progress / claims / docs_url / error / failure_class /
failure_reason, and the usage blocks verify / ask / assess), and the
API may send them as null (a stored replay, a value it has none of). By
default that is read as if the field were not sent: absent, or the 2.x value
the client fills there (an option's text is its claim, an entity's name
is ""). With legacyAliases: false the null stays, as sent, and raw
always holds it. Webhook events keep a null as 2.21 delivered it. One more
field can be null and is typed string: a review failure block's
docs_url; check it before use.
The body as received: raw (since 3.2). Whatever legacyAliases says,
each result carries raw: the JSON object it was read from, exactly as
received, with none of the names or defaults the SDK adds. It is a getter, not
an own enumerable key, so it does not show in JSON.stringify, Object.keys,
a spread or a deep-equal, and each read is a fresh deep copy (change it
freely): the result keeps the response text and parses it on each read.
const out = await client.assess({ claims: ["A.", "B."] }); out.raw; // { claims: [...], more_claims: [...], ... } as sent
Nested objects hold their own part of the body (since 3.2, as on the Python SDK):
out.claims[0].raw; // that row's object const status = await client.getStatus(taskId); status.result?.raw; // the verification's object
Covered: every result that is one answer's body (verify, verifyBatch,
select, getStatus, cancel, extract, assess, usage,
verifications.get / list / getCertificate / related, ask.history /
ask.send, library.list, review, citecheck, getReview,
getCitecheck, cancelReview, cancelCitecheck, and what reviewAndWait /
citecheckAndWait return: the final poll's body), and every object nested in
one that the body holds at the same place (assess rows and their failure,
a status's result, review rows, their assessment / verification and the
summary, citation rows, the items of a list page and of listAll, ...;
lists themselves carry none). A wait's verification (wait,
verifyAndWait, and each verification of a verifyBatchAndWait row) holds
the verification as the final poll sent it; a row's status_detail holds that
poll's body. A review / citecheck receipt settled by a 409 that names the
job holds that 409's body. Not covered: a verifyBatchAndWait row itself (the
SDK builds it), and an object the SDK added (a default, a 2.x block). An
object that carries its own raw key keeps it.
The status and headers: httpStatus and headers (since 3.2). Every
result that is one answer's body (the list above) also carries the HTTP
status it came with and its headers, names in lower case, the same record an
error's err.headers is:
const started = await client.review({ text: draft }); started.httpStatus; // 202 started.headers?.["location"]; // the review's URL (review and citecheck receipts send it) started.headers?.["retry-after"]; // a wait, when the answer states one started.headers?.["x-request-id"]; // quote it on a support ticket
Like raw, they are not enumerable keys, so a copy drops them: a spread
({ ...out }), structuredClone, JSON.parse(JSON.stringify(out)) and a
deep-equal do not carry or compare them. Read them from the result itself
(or copy them by name). Each read of headers is a fresh copy. reviewAndWait /
citecheckAndWait report their final poll's. Only the top-level result has
them: an object nested in it (out.claims[0], status.result), a wait's
verification (read from inside the final poll's body) and a
verifyBatchAndWait row (built by the SDK) do not. A review / citecheck
receipt settled by a 409 that names the job carries that 409's (see
Idempotency). An object that carries its own key of either
name keeps it. The ResponseMeta type names them (both optional), and the
response types extend it.
On the result of a top-level call both are required in the types (since
3.2): the methods return Result<T> (T & ResultMeta, where ResultMeta is
{ httpStatus: number; headers: Record<string, string> }), so
out.httpStatus is a number with no guard. Nested objects, a wait's
verification and a verifyBatchAndWait row keep ResponseMeta. getStatus
and verifyBatch, which the waits call through and 2.x code overrides, keep
their plain types (their results carry both at runtime). A subclass that
overrides another of these methods declares Promise<Result<T>> (for example
override async verify(input: VerifyInput): Promise<Result<TaskAccepted>>),
and a stub or mock returning a plain object needs a cast
(as unknown as typeof client.verify).
Using lenz-io from a server that forwards per-user credentials
A gateway, an MCP server or any backend that calls Lenz on behalf of its own users, each with their own Lenz API key or OAuth access token:
import { Lenz, LenzError } from "lenz-io"; // One client for the process. No key of its own, and LENZ_API_KEY is never read. const lenz = new Lenz({ apiKey: "", legacyAliases: false, // results and error codes exactly as sent maxRetries: 0, // you own the retry budget }); export async function check(userToken: string, text: string, signal: AbortSignal) { try { // Inside the try: a malformed key throws LenzInvalidKeyError (a LenzAuthError) here. const client = lenz.withOptions({ apiKey: userToken, signal }); // No timeoutMs: assess keeps its 100 s minimum (see Timeouts below). const out = await client.assess({ claim: text }); return out.raw; // the API's JSON object, as received } catch (err) { if (err instanceof LenzError) { return { error: err.code, status: err.statusCode, body: err.body }; } throw err; } }
- Per-user keys:
withOptions({ apiKey })per request, on the same client. A copy never readsLENZ_API_KEY; an empty or whitespace-only key,undefinedornullgives a copy with no key, and a call that needs one throwsLenzMissingKeyError(aLenzAuthError) before sending. ASCII whitespace around a key is trimmed silently. A key with a space, a control character or a non-ASCII character inside it throwsLenzInvalidKeyError(aLenzAuthError) when the copy is made. Copies are cheap and independent: make one per request. legacyAliases: falseis a constructor option only (withOptionsthrows if given it): every copy reads like its client. Results are what the API sent, anderr.codeis the body'scode.- Headers, highest first: the call's
headers, then the copy's (a copy of a copy merges over the first), then the client's defaults (User-Agent: lenz-io-node/<version>,Accept: application/json), which aUser-AgentorAcceptinheadersreplaces. Names match in any casing. - Reserved headers:
Authorization(from the key),Idempotency-Key(idempotencyKey/idempotency),Content-TypeandX-Lenz-API-Versionare set by the SDK, andheadersrefuses them (withContent-Length,HostandTransfer-Encoding), in any casing. - Timeouts: a
timeoutMson the copy or the call is used as given, also onassessandextract(only the client's own timeout is raised to their 100 s / 150 s minimum). Below that minimum it can end a call the server is still running and charging for (/assesscan take about 90 s and charges before its panel runs): leave it out, or give at least 100 000 / 150 000. A customfetchmust honourinit.signal. - Retries:
maxRetries: 0when your caller has its own deadline or retry budget; the SDK then sends each request once. A resend should reuseerr.idempotencyKey. - Raw bodies:
result.rawis the JSON object a result was read from, andresult.httpStatus/result.headersits status and headers (see Results as the API sends them);err.bodyis an error's,err.headersits response headers, anderr.retryAfterthe parsed wait (see Errors for which wins). - Errors as text:
String(err)is the message plusCause:,Fix:,Docs:andRequest ID:lines; the SDK never puts the key in it, but a 422's text can quote the input. For a structured answer, forwardcode,statusCode,retryable,retryAfterandrequestIdinstead.
Compatibility
- Node 22.12+ (22, 24)
- ESM + CJS dual exports
- TypeScript types included
- Runs on Node 22.12+ and on edge runtimes with no Node built-ins: the
workerd,edge-lightanddenoexport conditions resolve to the main build, which imports none, ahead of thebrowserone (which has no webhook receiver). Tested: Cloudflare Workers (workerd, without the Node compatibility flag), Deno and Bun. Vercel Edge and Next.js edge runtime are supported through theedge-lightcondition but not tested in a real Next.js build. Verify webhooks there withawait webhooks.unwrap(request); the synchronousparseneeds Node. Bun and Node use the main build. The client needsglobalThis.fetch(every runtime above has it; passfetchin the options otherwise)
Contributing
git clone https://github.com/lenzhq/lenz-io-node && cd lenz-io-node npm install git config core.hooksPath scripts/hooks # one-time: enables pre-commit
The pre-commit hook mirrors CI exactly (npm run lint, npm run type,
npm test, npm run build). Runs ~10s per commit on a warm cache. Skip
once with git commit --no-verify when you must.
Bug reports + feature requests
github.com/lenzhq/lenz-io-node/issues
For commercial use, volume pricing, or onboarding support, get in touch.
License
MIT. See LICENSE.
Maintainer
来源:GitHub · github.com