Skip to content

Repository files navigation

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 a focus. 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-io

Quickstart — 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);
}

// 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.

How verification works

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.

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 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

What you get on the client

  • client.extract({ text })ExtractedClaims. Free, capped at 1000/account/day. Add focus to narrow the list — see Steering extract.
  • client.assess({ claim })AssessResponse. Sync, ~10s, returns one entry per identified claim. (text is accepted as an alias: a document is text, a claim is claim.)
  • 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 as verdict: "Error" with error_code and hint. Takes a per-call timeoutMs (default 45s for a list).
  • client.verify({ claim })TaskAccepted. Async submit; returns a task_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 to wait(verify(...)).
  • client.wait(task)Verification. Block on a task_id (or a TaskAccepted) 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.content uses 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 by verification_id. Cache-hit on another customer's claim is transparent — you always see your own verification_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 (costsverify 10, assess 1, ask 1, extract 0 — plus cost_options for parameter-dependent prices such as depth), and that balance projected into each capability's unit (verify / ask / assess), plus the daily extract rate limit. Also reports has_webhook_secret — whether this key can receive signed webhook callbacks (verify with a webhook_url needs 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
  }
}

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).

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.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.

Depth pricing

cost_options.verify.depth.low is the price of a depth: "low" verification — half a standard one. low caps research breadth (fewer discovery queries, a hard extraction ceiling, no recovery fetch tiers) while every reasoning step runs the same models; it is not a model downgrade.

It is a price, not a capability, which is why it is nested under cost_options rather than sitting in costs beside the four capability names. There is deliberately no u.verify_low block beside u.verify — it would report the same balance in a second unit. Divide the balance yourself when you want the count:

// Every level is optional: a server predating this field sends `{}`, and
// the capability's default price in `costs` is the right fallback.
const low = u.cost_options.verify?.depth?.low ?? u.costs["verify"];
const lowDepthLeft = Math.floor(u.credits.remaining / low); // 1014

You are charged for the depth you requested, not the one you were served. 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:    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 retryabletrue 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.

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);
}

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.

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 es

Supported 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 if apiKey is not passed
  • LENZ_BASE_URL — read if baseUrl is not passed

Compatibility

  • Node 18, 20, 22
  • ESM + CJS dual exports
  • TypeScript types included
  • Works in Cloudflare Workers / edge runtimes — pass a fetch polyfill if globalThis.fetch isn'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-commit

The pre-commit hook mirrors CI exactly (npm run lint, npm run type, npm test, npm run build). Runs ~10s per commit on a warm cache. Skip once with git commit --no-verify when you must.

Bug reports + feature requests

github.com/lenzhq/lenz-io-node/issues

For commercial use, volume pricing, or onboarding support, get in touch.

License

MIT. See LICENSE.

Maintainer

@Pavel12431432

About

Official Node/TypeScript SDK for the Lenz fact-checking API — extract claims from AI output, assess or verify them, and get sourced verdicts with citations.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages