npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

lenz-io

v2.16.0

Published

Official Node SDK for the Lenz Fact Checking API for AI Product Teams

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 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 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-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);
  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. Add focus to narrow the list — see Steering extract. Each attempt waits up to 90s by default (a timeout is retried like any transport error); timeoutMs overrides it for that call.
  • 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. Both forms take a per-call timeoutMs (default 45s).
  • 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 (costs — verify 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
  }
}

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); // 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:    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 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

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