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.
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.
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.
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.
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 two optional notes. rationale is the
reasoning of a reviewer who agrees with the panel's verdict; dissent, when
set, is the reasoning of the reviewer farthest from it. Both are reviewers'
notes, not checked sources; for sourced evidence, call verify. Read them as
optional: either can be null or absent.
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.
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.
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
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 an Error 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.listAll()iterates every page (for await (const v of client.verifications.listAll()) …), one request a page. 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.
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.
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 itcancel(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.
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. Work the server accepted keeps
running and is charged if it completes. Stop it with
cancel,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.
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). |
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.
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.
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);
}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.
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.
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); // 1014You 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.
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; 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 response 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 apiVersion (the version named), statusCode and
body (as sent). 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.
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.
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:
const status = await client.getStatus("tsk_abc123");
if (status.status === "completed") {
console.log(status.result?.verdict, status.result?.lenz_score);
}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.
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 (not available on review and citecheck, which always
send one).
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.
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,
asking the same question again in a new call is a new turn.
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.
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.
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 esSupported 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.
Pass language: "auto" on assess, verify, verifyAndWait or ask.send and
the answer comes back in the language of the text you submitted. 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. extract,
verifyBatch, citecheck and review do not accept "auto" (the API answers
422, as for any unsupported language).
const v = await client.verifyAndWait({
claim: "Die Erde ist flach",
language: "auto", // answer in German
});
console.log(v.language);
// deOn 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",
});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) and verifyAndWait's task id (info); 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 an Error when the client is made.
Environment variables:
LENZ_API_KEY— read ifapiKeyis not passedLENZ_BASE_URL— read ifbaseUrlis not passed
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 },
verifications.related's { limit }):
await client.assess({ claim }, { timeoutMs: 20_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, even below the 150 s / 100 s these wait at least when the timeout is inherited | 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 |
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. A per-call timeoutMs below the extract / assess floor can end a
call the server is still running; a retry with the same idempotency key then
replays it 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 an Error 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 }));
}An OAuth access token for the Lenz API works wherever the API key goes: pass it as apiKey or in LENZ_API_KEY.
- 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)
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-commitThe 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.
github.com/lenzhq/lenz-io-node/issues
For commercial use, volume pricing, or onboarding support, get in touch.
MIT. See LICENSE.