ZeroLLM

API reference

Check text for AI writing, one document or a whole batch, and rewrite drafts in your own voice over HTTP. Every endpoint returns JSON and uses the same models as the website.

ZeroLLM is in beta. Scores are estimates, and response fields may grow. We won't remove fields without notice.

Authentication

Every request needs an API key. Sign in with Google, then create a key on your API keys page. Keys start with zl_live_ and are shown once, so store them somewhere safe. Send the key as a bearer token:

header
Authorization: Bearer zl_live_…

Keep keys on your server. Anyone with a key can spend your allowance, and you can revoke one at any time from the API keys page.

Limits and headers

API calls count against the same monthly allowance as the website. Allowances reset on the first of the month (UTC).

PlanCheckingRewritingRequests
Free100,000 words a month5,000 words a month20 a minute
Pro (coming soon)1,000,000 words a month100,000 words a month60 a minute
Plus (coming soon)3,000,000 words a month200,000 words a month120 a minute

Every metered response carries these headers:

HeaderMeaning
x-planYour plan: free, pro, plus or student.
x-ratelimit-limitRequests allowed per minute.
x-quota-limitWords in this period's allowance for the endpoint.
x-quota-remainingWords left after this request.
x-quota-resetWhen the allowance resets, as an ISO 8601 timestamp.
retry-afterSeconds to wait, sent with a 429 when you hit the per-minute limit.

Check text

POST https://zerollm.app/api/v1/detect

Send { "text": string } with at least 30 words and at most 20,000 characters. The response scores the whole text and every sentence. score runs from 0 (reads as human) to 1 (reads as AI); sentences scoring 0.5 or more are the ones the website marks. start and end are character offsets into the text you sent.

request.sh
curl https://zerollm.app/api/v1/detect \
  -H "Authorization: Bearer zl_live_…" \
  -H "Content-Type: application/json" \
  -d '{"text": "Paste at least 30 words of the text you want checked."}'
response.json
{
  "score": 0.82,
  "label": "ai",
  "attribution": {
    "family": "openai",
    "confidence": 0.72,
    "probabilities": { "openai": 0.72, "anthropic": 0.12, "google": 0.09, "xai": 0.04, "other": 0.03 }
  },
  "edit_level": {
    "level": "ai_generated",
    "probabilities": { "human": 0.06, "ai_edited": 0.21, "ai_generated": 0.73 }
  },
  "confidence": "high",
  "ai_fraction": 0.6,
  "signals": {
    "classifier": { "probability": 0.84, "logit": 1.66 },
    "binoculars": { "score": null, "threshold": 0.9 },
    "style": { "aiism_per_100w": 1.4, "contractions_per_100w": 0.2 }
  },
  "contributions": { "classifier_logit": 2.1, "aiism_per_100w": 0.6 },
  "reasons": [
    { "signal": "classifier", "leans": "ai", "text": "The classifier recognizes patterns common in model output." }
  ],
  "sentences": [
    { "start": 0, "end": 118, "score": 0.91 },
    { "start": 119, "end": 204, "score": 0.34 }
  ],
  "phrases": [
    { "start": 0, "end": 8, "text": "Moreover", "category": "transition" }
  ],
  "stats": { "words": 212, "sentences": 9, "ms": 640, "calibration": "v3" }
}
FieldTypeDescription
scorenumberOverall likelihood the text is AI-written, 0 to 1.
label"ai" | "mixed" | "human"The verdict the score falls into.
attributionobject | nullWhich model family the text most likely came from (openai, anthropic, google, xai or other), with a confidence and each family's probability. null when the text reads as human. An estimate, not proof.
edit_levelobject | nullHow much AI touched the text: human, ai_edited (a person's writing polished by AI) or ai_generated, with each probability.
confidence"high" | "medium" | "low"How firm the verdict is. Short texts are low.
ai_fractionnumberShare of the text in sentences that read as AI.
sentencesarrayEach sentence's start, end and score.
phrasesarrayStock phrases models overuse, with their offsets and category.
reasonsarrayPlain-language reasons, each leaning ai, human or neutral.
contributionsobjectHow much each signal moved the score. Keys appear only for signals that ran.
signalsobjectRaw signal values behind the score.
statsobjectWord and sentence counts and processing time in milliseconds.
detect.ts
const res = await fetch("https://zerollm.app/api/v1/detect", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZEROLLM_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error((await res.json()).error);
const detection = await res.json();

Rewrite text

POST https://zerollm.app/api/v1/humanize

Rewrites a draft so it reads like your own writing, keeping every fact, name and number. With refine on, the result is checked and sentences that still read as AI are reworked, up to twice. The call returns once the rewrite is finished.

ParameterTypeDefaultDescription
textstringrequiredThe draft, up to 20,000 characters.
modestringstandardThe voice to write in. See the list below.
strengthstringbalancedHow much to change.
refinebooleantrueRecheck and rework flagged sentences.
request.sh
curl https://zerollm.app/api/v1/humanize \
  -H "Authorization: Bearer zl_live_…" \
  -H "Content-Type: application/json" \
  -d '{"text": "Your draft here.", "mode": "professional", "strength": "balanced", "refine": true}'
response.json
{
  "text": "The rewritten draft, in the voice you asked for.",
  "score": 0.21,
  "label": "human",
  "refine_passes": 1,
  "detection": { "score": 0.21, "label": "human", "sentences": [] },
  "notices": [],
  "engine": "humanizer"
}

detection is the full check of the final text, in the same shape as the detect response. notices lists anything worth knowing, such as the detector being unavailable for the final check. engine says which model wrote it: humanizer is ZeroLLM's own rewrite model, which drafts several versions and keeps the one that reads most human, and anthropic is the hosted model it falls back to when it's busy.

Check a batch

POST https://zerollm.app/api/v1/batch

Checks up to 50 documents in one call. Upload PDF, DOCX, TXT or Markdown files as multipart/form-data (2 MB each, 25 MB per request), or send their text as JSON. Each document is read, then checked exactly like a call to detect: its words count against your allowance, and the whole batch counts once against the per-minute limit.

files.sh
curl https://zerollm.app/api/v1/batch \
  -H "Authorization: Bearer zl_live_…" \
  -F "name=Period 3 essays" \
  -F "files=@essay-1.pdf" \
  -F "files=@essay-2.docx" \
  -F "files=@notes.txt"
json.sh
curl https://zerollm.app/api/v1/batch \
  -H "Authorization: Bearer zl_live_…" \
  -H "Content-Type: application/json" \
  -d '{"documents": [{"name": "intro", "text": "…"}, {"name": "conclusion", "text": "…"}]}'
response.json
{
  "id": "4f0c2a8e-1b7d-4c55-9a51-6e2f0c9d8b13",
  "name": "Period 3 essays",
  "status": "done",
  "created_at": "2026-10-13T16:21:54.961Z",
  "counts": { "total": 3, "pending": 0, "done": 2, "failed": 1 },
  "documents": [
    {
      "name": "essay-1.pdf",
      "status": "done",
      "words": 512,
      "score": 0.82,
      "label": "ai",
      "ai_fraction": 0.6,
      "detection": { "score": 0.82, "label": "ai", "sentences": [] }
    },
    {
      "name": "notes.txt",
      "status": "error",
      "words": 9,
      "score": null,
      "label": null,
      "ai_fraction": null,
      "error": "Too short to judge: 9 words, and a check needs at least 30.",
      "code": "too_short",
      "detection": null
    }
  ]
}

When everything is checked within about 25 seconds you get 200 with every result. A bigger batch answers 202 with status: "running" and keeps going; fetch GET /api/v1/batch/:id with the same key until it says done.

poll.ts
let batch = await res.json();
// a big batch answers 202 and keeps running; poll until it's done
while (batch.status === "running") {
  await new Promise((r) => setTimeout(r, 5000));
  batch = await (await fetch(`https://zerollm.app/api/v1/batch/${batch.id}`, { headers })).json();
}
FieldTypeDescription
status"running" | "done"Whether any document is still waiting or being checked.
countsobjectHow many documents in total, still pending, checked and failed.
documents[].status"queued" | "running" | "done" | "error"Where that document is.
documents[].detectionobject | nullThe full detect response once it's checked.
documents[].errorstringWhy it wasn't checked, as a sentence. Only on errors.
documents[].codestringunsupported, too_large, unreadable, too_short, too_long, quota, rate_limited or detector.

A document that can't be read or checked fails on its own; the rest of the batch carries on. API batches keep the results so you can poll them, never the text. Batches you run on the website live on the Batches page, where you can also share a read-only report for any document.

Errors

Errors come back with a JSON body of { "error": string } written to be shown to a person as is.

StatusWhen
400Unknown mode or strength, or a body that isn't valid JSON or form data.
401The API key is missing, invalid or revoked.
413The text is over 20,000 characters, or a batch upload is over 25 MB.
422Not enough text: detect needs 30 words, humanize a few sentences. A batch needs 1 to 50 documents.
429Per-minute limit (with retry-after) or the period's allowance is used up.
502The rewrite model returned an error. Safe to retry.
503The detector or the rewrite model is briefly unavailable (code model_busy), or rewriting is paused for the day on Free (code daily_budget). Wait the retry-after seconds, then retry.

Voices and strengths

modeVoice
standardClear, natural, everyday voice
academicMeasured and precise, for papers
professionalDirect and concise, for work
casualRelaxed, like talking to a friend
simpleShort sentences and everyday words
creativeMore voice and rhythm
strengthChange
lightKeep most of your wording
balancedRebuild sentences, keep the order
strongRewrite from scratch, keep every point