lenz-io
v2.16.0
Published
Official Node SDK for the Lenz Fact Checking API for AI Product Teams
Maintainers
Readme
lenz-io
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 multi-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-ioQuickstart — the canonical integration
import { 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);
if (c.rationale) console.log(" ", c.rationale);
}
// 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, framing_failed,
upstream_unavailable, or timeout — an open set; the last two are the ones
worth resending as-is) and hint says what to send next. 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 }), takes one text and answers with a row per
claim found in it, up to 20, at 1 credit each; 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.
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.
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 ~10s 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 (~60-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
client.extract({ text })→ExtractedClaims. Free, capped at 1000/account/day. Addfocusto narrow the list — see Steering extract. Each attempt waits up to 90s by default (a timeout is retried like any transport error);timeoutMsoverrides it for that call.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. Both forms take a per-calltimeoutMs(default 45s).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.
Polling without webhooks
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
}
}A failed item with no status_detail is a verification its account's
retention period has removed (HTTP 410, see Retention); 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.
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.
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). |
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.
Webhooks
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.
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; it disappears from the API on 2026-11-29. So does the
per-capability credits field, which was always that capability's one-off
top-up balance and is now bonus.
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); // 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.
Errors
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: 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 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 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.
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 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);
}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
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. assess does the same.
ask.send takes a key too, but only pins one you choose:
const reply = await client.ask.send(verificationId, {
message: "Which source says that?",
idempotencyKey: `${conversationId}:turn-4`,
});With a key, a retry of a question that already got a reply replays that reply
instead of spending a second credit and leaving the question plus a second
answer in the conversation. A retry sent while the first call is still running
gets a 409 (LenzError, statusCode 409) — there is no reply to replay yet.
No key is ever generated for you here, and none is derived from the message: a reply depends on the conversation so far, so asking the same question again is a normal thing to do. Without a key the call behaves exactly as before — a retry asks again, and pays again.
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 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.
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 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",
});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
});Environment variables:
LENZ_API_KEY— read ifapiKeyis not passedLENZ_BASE_URL— read ifbaseUrlis not passed
An OAuth access token for the Lenz API works wherever the API key goes: pass it as apiKey or in LENZ_API_KEY.
Compatibility
- Node 18, 20, 22
- ESM + CJS dual exports
- TypeScript types included
- Works in Cloudflare Workers / edge runtimes — pass a
fetchpolyfill ifglobalThis.fetchisn't available
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-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.
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.
