CESOERA

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.

PlanContentCredits a monthPer keyActive keys
Free (API trial)image, text, document25, shared with the website10 requests/min2
Businessimage, video, text, document2,00060 requests/min5

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_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Replacing 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."
}
verdictMeaning
strong_indicationStrong signs of AI generation.
some_indicationSome signs of AI generation.
inconclusiveNot enough evidence either way.
low_indicationLittle 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.

FieldLimitWhat it is
external_user_id128 charsYour id for the user who submitted the content.
external_content_id128 charsYour id for the item - a post, listing, essay or upload.
source64 chars, slugWhere in your product it came from: marketplace_listing, avatar, school_submission.
event_type64 chars, slugWhy you called us: content_moderation, assignment_review, signup_check.
metadata10 keys, 2 KBFlat 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:

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": {} } }
StatuscodeWhat to do
400validation_failed, file_required, text_requiredFix the request; details.issues names the field for validation_failed. Malformed JSON is reported as the missing field it hides.
415unsupported_content_typeSend multipart/form-data (field file) or application/json (field url or text).
400invalid_url, url_not_allowedThe URL must be a public http(s) address. Private, local and internal addresses are refused before any connection is made.
400url_unresolvable, url_unreachable, url_not_found, url_forbidden, url_rate_limited, url_remote_error, url_emptyWe could not fetch the file from that URL; the message says why. Check the link works publicly, or upload the file instead. Not charged.
401invalid_api_keyMissing, unknown, malformed or revoked key - deliberately one answer for all. Check the key, or create a new one in API keys.
404detection_not_foundNo detection with that id exists for this key's account (or organization).
413request_too_largeThe whole request is over the endpoint's size limit (details.maxBytes). Not charged.
413 / 415file_too_large, url_too_large, unsupported_type, invalid_file_signatureImages: JPEG, PNG or WebP up to 15 MB. Videos: MP4, MOV, WebM or M4V up to 100 MB.
413 / 422video_too_long, video_unreadableVideos must be readable and 10 minutes or shorter.
413 / 422text_too_long, text_too_shortText must be 200 to 20,000 characters. Neither is charged.
422text_too_denseThe text needs more than 32 analysis passages (very token-dense input). Split it and resend the parts. Not charged.
403organization_plan_requiredThe key belongs to an organization with no active plan. An owner or admin can activate one. Not charged.
413 / 422file_too_large, unsupported_document, malformed_documentDocuments: PDF, DOCX, XLSX or TXT up to 25 MB. Not charged.
422processing_limit_exceededThe document is past a processing limit; details names it. Split it and resend. Not charged.
422no_analysable_textNothing readable in the document (for example a scan). Not charged.
422encrypted_documentPassword-protected documents are refused; we do not attempt passwords.
400invalid_callback_urlcallback_url must be a public https:// address.
429rate_limit_reachedRetry after the Retry-After header (seconds).
429scan_limit_reachedThe 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.
502detection_failedRetry later with the same Idempotency-Key. Not charged.
503capacity_exhaustedThe service is busy. Retry after Retry-After.

Limits and privacy