Developers
Business API
Check images, videos, text and documents for signs of AI generation from your own servers - for example, everything a user posts to your platform, or every essay submitted to your course - without anyone opening the CESOERA website.
Getting access
Create an account, open Business API → API keys and create a key. The secret is shown once, with a request that works when pasted. API calls use the same credits as scans on the website: one credit per image or text and, for video, 2-18 credits by length (measured by us, never taken from the request), drawn from your plan's monthly balance.
| Plan | Content | Credits a month | Per key | Active keys |
|---|---|---|---|---|
| Free (API trial) | image, text, document | 25, shared with the website | 10 requests/min | 2 |
| Business | image, video, text, document | 2,000 | 60 requests/min | 5 |
The free trial is for building and testing an integration: it never exceeds your account's own monthly credits and needs no card. Paid plans are not on sale yet; if you need more volume, contact us. There is no service-level agreement unless one is agreed with you separately.
Whose balance. A personal key draws on your own plan. A key created inside an organization draws on the organization's plan and credits - never on the personal plan of the person who created it, so it keeps working, and keeps being billed to the organization, if that person leaves. An organization without an active plan gets 403 organization_plan_required and is not charged. GET /api/v1/usage says which balance a key uses (payer).
Authentication
Send your key in the Authorization header. Keys start with ces_live_. Keep them on your servers - never in a browser, mobile app or public repository. We store only a hash of each key, so a lost key cannot be shown again: revoke it and create another.
Authorization: Bearer ces_live_XXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXReplacing a key. In API keys, Replace creates a new key with the same name while the old one keeps working; switch your systems over, then revoke the old key. Revoke takes effect immediately. A revoked, unknown or malformed key all get the same 401 invalid_api_key, so a response never reveals which keys exist.
Quick start
Put your key in an environment variable, then run one of these. The same requests are in the console's Overview. Node needs version 18 or later; Python needs requests.
export CESOERA_API_KEY="ces_live_..."Image file
JPEG, PNG or WebP up to 15 MB, sent as multipart field `file`.
curl
curl -X POST https://cesoera.com/api/v1/detect/image \
-H "Authorization: Bearer $CESOERA_API_KEY" \
-F "file=@photo.jpg" \
-F "reference=my-first-image"JavaScript
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("file", new Blob([await readFile("photo.jpg")], { type: "image/jpeg" }), "photo.jpg");
form.append("reference", "my-first-image");
const res = await fetch("https://cesoera.com/api/v1/detect/image", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.CESOERA_API_KEY}` },
body: form,
});
const detection = await res.json();
if (!res.ok) throw new Error(`${detection.error.code}: ${detection.error.message}`);
console.log(detection.verdict, detection.ai_likelihood, detection.credits_charged);Python
import os
import requests
with open("photo.jpg", "rb") as f:
r = requests.post(
"https://cesoera.com/api/v1/detect/image",
headers={"Authorization": f"Bearer {os.environ['CESOERA_API_KEY']}"},
files={"file": ("photo.jpg", f, "image/jpeg")},
data={"reference": "my-first-image"},
timeout=60,
)
detection = r.json()
if not r.ok:
raise RuntimeError(detection["error"])
print(detection["verdict"], detection["ai_likelihood"], detection["credits_charged"])Image URL
A public http(s) link to a JPEG, PNG or WebP image; we fetch it once and discard it.
curl
curl -X POST https://cesoera.com/api/v1/detect/image \
-H "Authorization: Bearer $CESOERA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", "reference": "my-first-url"}'JavaScript
const res = await fetch("https://cesoera.com/api/v1/detect/image", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CESOERA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg",
reference: "my-first-url",
}),
});
const detection = await res.json();
if (!res.ok) throw new Error(`${detection.error.code}: ${detection.error.message}`);
console.log(detection.verdict, detection.ai_likelihood, detection.credits_charged);Python
import os
import requests
r = requests.post(
"https://cesoera.com/api/v1/detect/image",
headers={"Authorization": f"Bearer {os.environ['CESOERA_API_KEY']}"},
json={
"url": "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg",
"reference": "my-first-url",
},
timeout=60,
)
detection = r.json()
if not r.ok:
raise RuntimeError(detection["error"])
print(detection["verdict"], detection["ai_likelihood"], detection["credits_charged"])Text
200 to 20,000 characters in the JSON field `text`.
curl
curl -X POST https://cesoera.com/api/v1/detect/text \
-H "Authorization: Bearer $CESOERA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Our quarterly review found that warehouse throughput rose steadily through the season while returns processing stayed flat, which points to intake rather than handling as the real constraint on growth next year.", "reference": "my-first-text"}'JavaScript
const res = await fetch("https://cesoera.com/api/v1/detect/text", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CESOERA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text: "Our quarterly review found that warehouse throughput rose steadily through the season while returns processing stayed flat, which points to intake rather than handling as the real constraint on growth next year.",
reference: "my-first-text",
}),
});
const detection = await res.json();
if (!res.ok) throw new Error(`${detection.error.code}: ${detection.error.message}`);
console.log(detection.verdict, detection.ai_likelihood, detection.credits_charged);Python
import os
import requests
r = requests.post(
"https://cesoera.com/api/v1/detect/text",
headers={"Authorization": f"Bearer {os.environ['CESOERA_API_KEY']}"},
json={
"text": "Our quarterly review found that warehouse throughput rose steadily through the season while returns processing stayed flat, which points to intake rather than handling as the real constraint on growth next year.",
"reference": "my-first-text",
},
timeout=90,
)
detection = r.json()
if not r.ok:
raise RuntimeError(detection["error"])
print(detection["verdict"], detection["ai_likelihood"], detection["credits_charged"])Detect an image
POST /api/v1/detect/image accepts either an image URL (JSON) or the image itself (multipart). JPEG, PNG and WebP up to 15 MB. reference is optional - your own id for the item, returned with the result. Every endpoint also takes the optional customer context fields shown below.
By URL
curl -X POST https://cesoera.com/api/v1/detect/image \
-H "Authorization: Bearer $CESOERA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: post-8841" \
-d '{
"url": "https://images.example.com/uploads/8841.jpg",
"external_user_id": "usr_48291",
"external_content_id": "post_8841",
"source": "marketplace_listing",
"event_type": "content_moderation"
}'By upload
curl -X POST https://cesoera.com/api/v1/detect/image \
-H "Authorization: Bearer $CESOERA_API_KEY" \
-F "file=@photo.jpg" \
-F "external_user_id=usr_48291" \
-F "external_content_id=post_8841" \
-F "source=marketplace_listing" \
-F "event_type=content_moderation"Node.js
const res = await fetch("https://cesoera.com/api/v1/detect/image", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CESOERA_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": postId,
},
body: JSON.stringify({
url: imageUrl,
external_user_id: userId,
external_content_id: postId,
source: "marketplace_listing",
}),
});
if (res.status === 503 || res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 10);
// retry after `wait` seconds
}
const detection = await res.json();Python
import os, requests
r = requests.post(
"https://cesoera.com/api/v1/detect/image",
headers={"Authorization": f"Bearer {os.environ['CESOERA_API_KEY']}",
"Idempotency-Key": post_id},
json={
"url": image_url,
"external_user_id": user_id,
"external_content_id": post_id,
"source": "marketplace_listing",
},
timeout=60,
)
detection = r.json()The response
A new detection answers 201 Created; repeating the same Idempotency-Key answers 200 with Idempotent-Replayed: true and the stored result. Every detection has the same shape - fields that do not apply are null (duration_seconds is for video, characters for text). Text responses add analysis; images and video add verdict_basis, visual and provenance.
{
"id": "4f0c1c7e-9b0a-4d1e-9a51-2d7f3c1b8e20",
"object": "detection",
"created_at": "2026-09-14T18:21:04.512+00:00",
"status": "completed",
"content_type": "image",
"input": "url",
"reference": "post-8841",
"context": {
"external_user_id": "usr_48291",
"external_content_id": "post_8841",
"source": "marketplace_listing",
"event_type": "content_moderation",
"metadata": {"community": "gaming"}
},
"verdict": "strong_indication",
"ai_likelihood": 0.91,
"confidence": "high",
"model": "CESOERA Detection Engine 1.0",
"duration_seconds": null,
"characters": null,
"credits_charged": 1,
"verdict_basis": "visual_model",
"visual": {"ai_likelihood": 0.91, "verdict": "strong_indication"},
"provenance": {"status": "none_found"},
"processing_ms": 412,
"started_at": "2026-09-14T18:21:04.100+00:00",
"completed_at": "2026-09-14T18:21:04.100+00:00",
"error_code": null,
"webhook_status": null,
"request_id": "…",
"disclaimer": "AI likelihood is an estimate based on CESOERA's detection models."
}| verdict | Meaning |
|---|---|
strong_indication | Strong signs of AI generation. |
some_indication | Some signs of AI generation. |
inconclusive | Not enough evidence either way. |
low_indication | Little sign of AI generation. |
ai_likelihood is on the same 0-1 scale as the website, aligned with the verdict bands: up to and including 0.25 is low, above 0.25 and below 0.5 inconclusive, from 0.5 and below 0.75 some, 0.75 and above strong. The verdict is decided by the model and is authoritative; the website rounds the percentage so it always stays inside its verdict's range (0-25%, 26-49%, 50-74%, 75-100%). It is a position on that scale, not a calibrated probability. Results are probabilistic: use them to prioritise human review or label content, not as proof about a specific person.
For images and video, verdict_basis says what decided the verdict: visual_model, or content_credentials when the file carried a signed C2PA declaration that it was made with AI (the verdict is then strong_indication and ai_likelihood is 0.99, as before). visual is the visual model's own score in both cases, and provenance.status is ai_declared or none_found - which is not evidence of human origin. These three fields were added on 23 September 2026 and are absent from earlier detections.
confidence is kept for compatibility. It is derived from how far the score sits from the middle of the scale and how much input there was (frames sampled, image size). It has not been measured against outcomes, so it is not an accuracy estimate: do not use it to filter or trust results.
Your own ids and context
Every detect endpoint accepts five optional fields that describe the request in your system. They are stored with the result, returned with it, and are what the Business console groups and searches by - so you can open a result and see which of your users submitted it, which item it was, and where in your product it came from, without reading your own server logs.
These are your identifiers. external_user_id is not a CESOERA user id and external_content_id is not a detection id - CESOERA never resolves them against anything of ours. Requests that send none of them keep working exactly as before.
| Field | Limit | What it is |
|---|---|---|
external_user_id | 128 chars | Your id for the user who submitted the content. |
external_content_id | 128 chars | Your id for the item - a post, listing, essay or upload. |
source | 64 chars, slug | Where in your product it came from: marketplace_listing, avatar, school_submission. |
event_type | 64 chars, slug | Why you called us: content_moderation, assignment_review, signup_check. |
metadata | 10 keys, 2 KB | Flat key/value tags. Strings, numbers and booleans only - no nested objects or arrays. |
source and event_type are grouped and charted, so they take a short slug: lowercase letters, digits and . _ : -. They are lowercased for you, so Marketplace_Listing and marketplace_listing are one source rather than two. Anything invalid is refused with invalid_context before the detector runs, so a rejected request costs nothing.
Multipart
curl -X POST https://cesoera.com/api/v1/detect/document \
-H "Authorization: Bearer $CESOERA_API_KEY" \
-F "file=@essay.pdf" \
-F "external_user_id=usr_48291" \
-F "external_content_id=essay_2291" \
-F "source=school_submission" \
-F "event_type=assignment_review" \
-F 'metadata={"course":"ENG-201","term":"autumn"}'Multipart cannot carry a nested object, so metadata travels as a JSON string in that transport. On JSON endpoints it is a normal object.
Send ids, not people
CESOERA does not need personally identifiable values and we would rather not hold them. Send an opaque internal id you can look up on your side:
usr_48291rather thanjohn.smith@example.comor a real name.post_9281rather than the post's title or its text.- Tags in
metadata, never content, credentials or tokens. It is not storage: 10 keys, 200 characters a value, 2 KB in total, and nested objects are refused.
Whatever you send here is stored with the detection metadata and shown in your console, so it follows your account's history retention rather than the content rules - the image, video, document or text itself is still discarded as soon as it has been scored.
Detect a video (asynchronous)
POST /api/v1/detect/video takes an MP4, MOV, WebM or M4V file (multipart file) or a direct video URL (JSON url), up to 100 MB and 10 minutes. Videos are analysed frame by frame, which takes from a few seconds to a few minutes, so the call returns 202 Accepted immediately with a detection in status queued. The credits shown are held, measured from the video's real duration; they settle to the actual cost on completion and are fully refunded on failure.
curl -X POST https://cesoera.com/api/v1/detect/video \
-H "Authorization: Bearer $CESOERA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: video-5521" \
-d '{"url": "https://media.example.com/v/5521.mp4",
"reference": "video-5521",
"callback_url": "https://api.example.com/cesoera/webhook"}'HTTP/1.1 202 Accepted
Location: /api/v1/detections/0d6b…
{ "id": "0d6b…", "object": "detection", "status": "queued", "content_type": "video",
"duration_seconds": 42.5, "credits_charged": 5, "verdict": null, "queue_position": 1, … }Then either poll GET /api/v1/detections/{id} every few seconds until status is completed or failed, or supply callback_url (public https://) and we will POST the finished detection to you. Statuses: queued → processing → completed | failed. Videos are processed one at a time; if the queue is full the call returns 503 with Retry-After and nothing is charged.
How to read video results. Video AI detection is probabilistic. Re-encoding, compression, resizing and social-media processing can materially affect detection confidence. Content Credentials and other provenance metadata may be removed during transcoding or platform processing, and their absence does not imply that a video is human-created. Treat a video result as one signal, not definitive proof.
Detect text
POST /api/v1/detect/text takes JSON with text (200 to 20,000 characters) and an optional reference. It answers synchronously with the same detection object as images, plus characters - the length that was scored. One credit per call. Text under 200 characters is refused with 422 text_too_short and is not charged: short passages do not carry enough signal for any text detector to judge fairly.
Every accepted text is analysed in full. The model reads text as overlapping passages and CESOERA scores all of them - it never scores the beginning and reports that as the whole. The response's analysis object shows it: passages, characters_received, characters_analysed and coverage (1.0 means all of it).
One analysis covers at most 32 passages. Ordinary prose reaches the 20,000-character limit long before that, but very token-dense input can reach it first - for example tables of codes or numbers, some source code, or text written mostly in Chinese, Japanese or Korean characters or in emoji. That is a processing-capacity limit, not a judgement about the content or the language. Such text is refused before anything is charged, with 422 text_too_dense and details giving characters, passages_required and maximum_passages. Split it into shorter parts and send each one; each part is then analysed in full.
curl -X POST https://cesoera.com/api/v1/detect/text \
-H "Authorization: Bearer $CESOERA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: caption-8841" \
-d '{"text": "Full post or article text…", "reference": "post-8841"}'Text detection is less reliable than image detection. It is weakest on short, heavily edited, translated or non-English writing, and it can mistake formulaic human writing for AI. Use it to decide what a person should review, never as the sole basis for penalising someone. An Idempotency-Key is scoped to its content type, so the same post id can be used for a post's image, video and caption.
Analyse a document (asynchronous)
POST /api/v1/detect/document takes a PDF, DOCX, XLSX or TXT file (multipart file, up to 25 MB), with optional reference and callback_url. A document is not one verdict: it is read in sections - pages, paragraphs or spreadsheet cells - and each section is scored separately, so the report says where indicators were found rather than judging the whole file. It answers 202 with a detection id and the exact price; poll GET /api/v1/detections/{id} or wait for the webhook.
curl -X POST https://cesoera.com/api/v1/detect/document \
-H "Authorization: Bearer $CESOERA_API_KEY" \
-H "Idempotency-Key: submission-4471" \
-F "file=@essay.pdf" \
-F "reference=submission-4471"The 202 tells you what it will cost before any work runs:
{
"id": "6f1c…", "object": "detection", "status": "queued",
"content_type": "document", "credits_reserved": 3,
"document": {
"kind": "pdf", "pages": 12, "sheets": null,
"characters": 18420, "sections": 12, "analysable_characters": 17980
},
"pricing": "1 credit per 6,000 analysed characters, minimum 1"
}The completed detection carries a document report: overall (an indication of none, isolated, mixed or widespread, plus the share of analysed text carrying indicators), distribution and characterShare across verdicts, and sections - each with its location ("Pages 3-4", "Sheet Answers B7"), character count, likelihood and verdict. There is no single document-level percentage, deliberately: a number like that reads as authority the evidence does not support.
Price. One credit per 6,000 analysed characters, minimum one credit. Sections too short for the detector to judge are reported as not analysed and are not charged for, so the final charge can be lower than the quote - never higher.
Refusals cost nothing. Extraction happens before anything is reserved, so a document that is encrypted, malformed, not really a document, has no readable text, or exceeds a processing limit is refused for zero credits. Nothing is ever silently truncated to fit: a document over a limit is refused with 422 processing_limit_exceeded and details naming the limit, what was observed and what is allowed. The limits are 300 sections, 250,000 analysed characters, 5,000 text cells and an estimated 240 seconds of analysis.
What is read, and what is not. Documents are parsed in an isolated container with no network and no database access. Macros, formulas, scripts, embedded objects and external references are never executed or fetched - a spreadsheet is read as the text in its cells and nothing else. Only extractable text is analysed: scanned pages, images and handwriting are not read, and a file with no readable text is refused rather than returned as an empty report. Spreadsheets are assessed one natural-language cell at a time; cells holding numbers, dates, identifiers or formulas are skipped.
Document requests are limited to a fifth of the key's per-minute limit (at least 2 per minute), and one document is analysed at a time across the service. A long document can take minutes, which is why the route is asynchronous - do not hold a request open waiting for it.
Please read the report as evidence for a human reviewer, never as a finding about a person. AI-content detection is probabilistic; non-native English, technical writing, quotations and heavily edited passages raise false-positive rates. Every response carries this disclosure in its payload so it cannot be rendered without it.
Webhooks
When a video or document with a callback_url finishes, we send POST with JSON { "type": "detection.completed" | "detection.failed", "data": { …detection… } }. Answer with any 2xx within 10 seconds. Failed deliveries are retried up to six attempts in total over about six hours (after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours), and webhook_status on the detection shows whether delivery succeeded. Every attempt carries the same CESOERA-Delivery-Id header, so you can discard duplicates. Polling always works as a fallback.
Every delivery carries CESOERA-Signature: t=<unix time>,v1=<hex>: an HMAC-SHA256 of <t>.<raw body> using, as the secret, the SHA-256 hex digest of your API key. Verify it on the raw body before trusting the payload, and reject timestamps older than 5 minutes. If verification fails - including a missing or malformed header - answer 401 and do not process the payload; the verifier below returns false and never throws.
import crypto from "node:crypto";
const secret = crypto.createHash("sha256").update(process.env.CESOERA_API_KEY).digest("hex");
function verify(rawBody, header) {
if (typeof header !== "string" || typeof rawBody !== "string" || header.length > 1024) return false;
const parts = header.split(",").map((p) => p.trim());
const t = parts.find((p) => p.startsWith("t="))?.slice(2) ?? "";
if (!/^\d{1,12}$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return parts
.filter((p) => p.startsWith("v1="))
.some((p) => {
const sig = p.slice(3);
if (!/^[0-9a-fA-F]{64}$/.test(sig)) return false; // wrong length or not hex: reject, do not throw
const got = Buffer.from(sig, "hex");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
// In your handler: if (!verify(rawBody, req.headers["cesoera-signature"])) return res.status(401).end();Webhook endpoints
Instead of passing callback_url on every request, you can register up to five HTTPS endpoints in the Business console. Each endpoint receives detection.completed and detection.failed for every asynchronous detection on your account and has its own signing secret (whsec_…), shown once when you create or rotate it. We do not keep a copy of the secret. Send test delivers a signed webhook.test event immediately and shows the response code.
The signature is the same HMAC-SHA256 of <t>.<raw body>, keyed with the endpoint secret as a string. When you rotate a secret, the previous one keeps working for 24 hours and each delivery carries one v1 per valid secret - accept the delivery if any v1 matches.
import crypto from "node:crypto";
function verifyEndpoint(rawBody, header, secret) {
if (typeof header !== "string" || typeof rawBody !== "string" || typeof secret !== "string" || header.length > 1024) return false;
const parts = header.split(",").map((p) => p.trim());
const t = parts.find((p) => p.startsWith("t="))?.slice(2) ?? "";
if (!/^\d{1,12}$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return parts
.filter((p) => p.startsWith("v1="))
.some((p) => {
const sig = p.slice(3);
if (!/^[0-9a-fA-F]{64}$/.test(sig)) return false;
const got = Buffer.from(sig, "hex");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}Fetch a result again
curl https://cesoera.com/api/v1/detections/4f0c1c7e-9b0a-4d1e-9a51-2d7f3c1b8e20 \
-H "Authorization: Bearer $CESOERA_API_KEY"Sending the same Idempotency-Key again returns the stored result with the header Idempotent-Replayed: true, without running the model or charging again. Use your own item id as the key so retries after timeouts are always safe.
Usage and credits
curl https://cesoera.com/api/v1/usage -H "Authorization: Bearer $CESOERA_API_KEY"Returns the plan the key draws on (payer: personal or organization), credits used and remaining this calendar month (UTC), and the key's rate limit. Checking usage is free. A failed detection is never charged.
Errors and retries
Errors share one shape:
{ "error": { "code": "rate_limit_reached", "message": "…", "requestId": "…", "details": {} } }| Status | code | What to do |
|---|---|---|
| 400 | validation_failed, file_required, text_required | Fix the request; details.issues names the field for validation_failed. Malformed JSON is reported as the missing field it hides. |
| 415 | unsupported_content_type | Send multipart/form-data (field file) or application/json (field url or text). |
| 400 | invalid_url, url_not_allowed | The URL must be a public http(s) address. Private, local and internal addresses are refused before any connection is made. |
| 400 | url_unresolvable, url_unreachable, url_not_found, url_forbidden, url_rate_limited, url_remote_error, url_empty | We could not fetch the file from that URL; the message says why. Check the link works publicly, or upload the file instead. Not charged. |
| 401 | invalid_api_key | Missing, unknown, malformed or revoked key - deliberately one answer for all. Check the key, or create a new one in API keys. |
| 404 | detection_not_found | No detection with that id exists for this key's account (or organization). |
| 413 | request_too_large | The whole request is over the endpoint's size limit (details.maxBytes). Not charged. |
| 413 / 415 | file_too_large, url_too_large, unsupported_type, invalid_file_signature | Images: JPEG, PNG or WebP up to 15 MB. Videos: MP4, MOV, WebM or M4V up to 100 MB. |
| 413 / 422 | video_too_long, video_unreadable | Videos must be readable and 10 minutes or shorter. |
| 413 / 422 | text_too_long, text_too_short | Text must be 200 to 20,000 characters. Neither is charged. |
| 422 | text_too_dense | The text needs more than 32 analysis passages (very token-dense input). Split it and resend the parts. Not charged. |
| 403 | organization_plan_required | The key belongs to an organization with no active plan. An owner or admin can activate one. Not charged. |
| 413 / 422 | file_too_large, unsupported_document, malformed_document | Documents: PDF, DOCX, XLSX or TXT up to 25 MB. Not charged. |
| 422 | processing_limit_exceeded | The document is past a processing limit; details names it. Split it and resend. Not charged. |
| 422 | no_analysable_text | Nothing readable in the document (for example a scan). Not charged. |
| 422 | encrypted_document | Password-protected documents are refused; we do not attempt passwords. |
| 400 | invalid_callback_url | callback_url must be a public https:// address. |
| 429 | rate_limit_reached | Retry after the Retry-After header (seconds). |
| 429 | scan_limit_reached | The key's credits for this month are used up - the organization's for an organization key, yours for a personal one. details.payer says which. Not charged; do not retry until they renew. |
| 502 | detection_failed | Retry later with the same Idempotency-Key. Not charged. |
| 503 | capacity_exhausted | The service is busy. Retry after Retry-After. |
Limits and privacy
- Each key has a per-minute request limit: 60 on the Business plan, 10 on the free API trial (a key issued by us keeps the limit it was issued with). Every detect request made with the key counts, including ones refused for their content; fetching results and usage has its own, higher limit. Over the limit you get
429 rate_limit_reachedwithRetry-After. - Detection capacity is shared across all customers. Under load, requests queue briefly and may receive 503 with
Retry-After. Contact us before sending sustained high volume. - Images typically take a second or two and text a few seconds - long texts longer, because every passage is scored (about 20 seconds for 20,000 characters of ordinary prose, up to about 40 seconds for the densest text we accept); allow 90 seconds before timing out. A long text that arrives while another is being analysed may get 503 with
Retry-After: 30. Videos are asynchronous. - Video requests are limited to a tenth of the key's per-minute limit (at least 2 per minute), and document requests to a fifth (at least 2 per minute).
- Content you send is analysed and discarded. Images and text are never written to disk; a queued video is held in a private temporary file only until its analysis ends, and the text extracted from a document the same way. We do not store the media, the document, the text, a filename or a URL, and API content is never used to train our models. We keep the result (verdict, likelihood, duration or length, credits, time and your reference) so you can fetch it again. For a document we also keep its section report - where each section was, how long it was and what the detector said - which contains none of the document's text.