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.
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:
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).
| Plan | Checking | Rewriting | Requests |
|---|---|---|---|
| Free | 100,000 words a month | 5,000 words a month | 20 a minute |
| Pro (coming soon) | 1,000,000 words a month | 100,000 words a month | 60 a minute |
| Plus (coming soon) | 3,000,000 words a month | 200,000 words a month | 120 a minute |
Every metered response carries these headers:
| Header | Meaning |
|---|---|
x-plan | Your plan: free, pro, plus or student. |
x-ratelimit-limit | Requests allowed per minute. |
x-quota-limit | Words in this period's allowance for the endpoint. |
x-quota-remaining | Words left after this request. |
x-quota-reset | When the allowance resets, as an ISO 8601 timestamp. |
retry-after | Seconds 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.
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."}'{
"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" }
}| Field | Type | Description |
|---|---|---|
score | number | Overall likelihood the text is AI-written, 0 to 1. |
label | "ai" | "mixed" | "human" | The verdict the score falls into. |
attribution | object | null | Which 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_level | object | null | How 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_fraction | number | Share of the text in sentences that read as AI. |
sentences | array | Each sentence's start, end and score. |
phrases | array | Stock phrases models overuse, with their offsets and category. |
reasons | array | Plain-language reasons, each leaning ai, human or neutral. |
contributions | object | How much each signal moved the score. Keys appear only for signals that ran. |
signals | object | Raw signal values behind the score. |
stats | object | Word and sentence counts and processing time in milliseconds. |
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.
| Parameter | Type | Default | Description |
|---|---|---|---|
text | string | required | The draft, up to 20,000 characters. |
mode | string | standard | The voice to write in. See the list below. |
strength | string | balanced | How much to change. |
refine | boolean | true | Recheck and rework flagged sentences. |
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}'{
"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.
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"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": "…"}]}'{
"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.
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();
}| Field | Type | Description |
|---|---|---|
status | "running" | "done" | Whether any document is still waiting or being checked. |
counts | object | How many documents in total, still pending, checked and failed. |
documents[].status | "queued" | "running" | "done" | "error" | Where that document is. |
documents[].detection | object | null | The full detect response once it's checked. |
documents[].error | string | Why it wasn't checked, as a sentence. Only on errors. |
documents[].code | string | unsupported, 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.
| Status | When |
|---|---|
400 | Unknown mode or strength, or a body that isn't valid JSON or form data. |
401 | The API key is missing, invalid or revoked. |
413 | The text is over 20,000 characters, or a batch upload is over 25 MB. |
422 | Not enough text: detect needs 30 words, humanize a few sentences. A batch needs 1 to 50 documents. |
429 | Per-minute limit (with retry-after) or the period's allowance is used up. |
502 | The rewrite model returned an error. Safe to retry. |
503 | The 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
| mode | Voice |
|---|---|
standard | Clear, natural, everyday voice |
academic | Measured and precise, for papers |
professional | Direct and concise, for work |
casual | Relaxed, like talking to a friend |
simple | Short sentences and everyday words |
creative | More voice and rhythm |
| strength | Change |
|---|---|
light | Keep most of your wording |
balanced | Rebuild sentences, keep the order |
strong | Rewrite from scratch, keep every point |