Official Node SDK for the Lenz Fact Checking API for AI Product Teams.
Four API primitives, one research-depth ladder.
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 ~10s; one claim, or up to 20 claims in one call. Sync, paid.verify— full 8-model pipeline with citations in ~90s. Async, paid.ask— follow-up questions grounded on a verification.
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-ioimport { Lenz } 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.identified_claims?.length ? out.identified_claims : [out.claim!];
// 2. assess — ONE call over the extracted claims (up to 20), one row per
// claim, in the same order (~10-25s, sync)
const quick = (await client.assess({ claims })).claims;
for (const c of quick) {
console.log(c.verdict, c.confidence, c.claim);
}
// 3. verify — escalate the low-confidence rows to the full panel + citations
const doubtful = quick
.filter((c) => c.verdict !== "Error" && c.confidence === "low")
.map((c) => ({ claim: c.claim! }));
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 — follow-up grounded on a verification
const deep = results.find((r) => r.status === "completed")?.verification;
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 verdict === "Error" had no
verdict: error_code says why (no_claim, ambiguous, framing_failed, or
the retryable upstream_unavailable), hint says what to send next, and
candidate_claims carries the specific readings when it was ambiguous. Error
rows are free. A compound item is assessed on its main claim and lists the
rest in identified_claims — send those as their own items to check them.
The single form, assess({ claim }), is unchanged; the two are mutually
exclusive.
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.
Framing → Research → Debate (2 models, 2 rounds) → Panel Review
(3 reviewers: source quality, logical structure, claim precision) → Conclusion. ~90 seconds wall-clock
per claim. assess runs a leaner 3-model panel against the same
framing for the ~10s 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 pre-cached so this returns in ~1.5s. Your own claims hit the full pipeline (~60-90s) — use webhooks for production async flows.
Get your webhook secret here → lenz.io/api-credentials
client.extract({ text })→ExtractedClaims. Free, capped at 1000/account/day. Addfocusto narrow the list — see Steering extract.client.assess({ claim })→AssessResponse. Sync, ~10s, 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 asverdict: "Error"witherror_codeandhint. Takes a per-calltimeoutMs(default 45s for a list).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. 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).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 (~60-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" }],
});
for (const r of results) {
if (r.status === "completed") {
console.log(r.claim_text, "→", r.verification!.verdict);
} else {
console.log(r.claim_text, "→", r.status); // needs_input | failed | timeout
}
}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.
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). |
import { LenzWebhooks } from "lenz-io";
import type { VerificationCompleted, VerificationFailed, VerificationNeedsInput } 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>);
switch (event.event) {
case "verification.completed": {
const completed = event as VerificationCompleted;
const r = completed.result as Record<string, unknown>;
// r.verdict, r.lenz_score, r.confidence, ...
break;
}
case "verification.needs_input": {
const ni = event as VerificationNeedsInput;
// …surface candidate claims, call client.select(taskId, ...) to resolve
break;
}
case "verification.failed": {
const failed = event as VerificationFailed;
// failed.error is WHERE the pipeline stopped; failed.failureClass is
// WHY (closed set) and failed.retryable tells you what to do about it.
if (failed.retryable) {
resubmitLater(failed.taskId); // transient provider outage
} else {
logPermanentFailure(failed.taskId, failed.error);
}
break;
}
}
res.status(200).send();
});Signature verification is HMAC-SHA256 over the raw bytes; the SDK does it for you and rejects tampered or replayed payloads.
See examples/core/express-webhook.ts
for a runnable receiver 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.bonus; // 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.
The per-capability credits field is deprecated — it was always that
capability's one-off top-up balance, which is now bonus. It disappears from
the API on 2026-11-29; read bonus.
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.
A low request answered from a cached standard verdict still costs 5. 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.
Every error subclass is typed and carries a requestId you can quote on
support tickets:
import {
LenzAuthError,
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: Generate a new key 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 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.
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 120000) 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);
}verifyAndWait sends an auto-generated Idempotency-Key on every call by
default, so a network drop after submit doesn't spawn a duplicate verification
or charge a second credit. Override with idempotencyKey: "..." to pin a
specific key, or idempotency: false to opt out.
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 identified_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.
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. 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
});Environment variables:
LENZ_API_KEY— read ifapiKeyis not passedLENZ_BASE_URL— read ifbaseUrlis not passed
- Node 18, 20, 22
- ESM + CJS dual exports
- TypeScript types included
- Works in Cloudflare Workers / edge runtimes — pass a
fetchpolyfill ifglobalThis.fetchisn't available
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.