{ "detail": "<message>" }.
The status code carries the meaning; some statuses include extra fields
(call those out below).| Status | detail | When it fires | Extra response fields | Retry? |
400 | "key must be valid base64" | The BYOK key field isn't strict standard base64 | — | no |
400 | "key must decode to 32 bytes (256 bits), got {n}" | The BYOK key decoded, but to the wrong length | — | no |
400 | "unknown_judge_model" | judge_model isn't in the operator's allowlist | — | no |
401 | "Missing authentication" | Neither a bearer nor a session cookie was presented | — | no |
401 | "Missing authentication token" | No Authorization header on a Bearer-only endpoint | — | no |
401 | "Malformed authentication token" | The bearer isn't a well-formed mk_live_<prefix>_<secret> | — | no |
401 | "Invalid authentication token" | Unknown, revoked, or wrong-secret key. Revocation propagates in up to 60s per replica (auth cache). | — | no |
401 | "Missing session cookie" | A session-auth endpoint was called with no cookie | — | no |
401 | "Invalid or expired session" | The session cookie is unknown or past its TTL | — | no |
402 | "insufficient_tokens" | Paid balance + free quota both exhausted | needed_tokens (int), available_tokens (int), topup_url (string) | no |
403 | (no body at all) | Geo-block: the API is served from Riyadh and reachable only from Saudi IP addresses. Blocked at the edge, before the app. Not an auth failure. | — | no |
404 | "Glossary not found" | The glossary_id you sent doesn't exist or isn't yours. Checked before billing, so it isn't charged. | — | no |
404 | "Not Found" | Endpoint typo | — | no |
413 | "file_too_large" | File upload exceeds the 100 MB cap (see Files) | — | no |
413 | {"error": "request_too_large"} | An LLM Proxy request body exceeds the proxy body cap; note the proxy's error body shape | — | no |
422 | "<pydantic validation message>" | Schema validation (wrong type, invalid enum, empty text) | detail is an array of pydantic error objects | no |
429 | "Rate limit exceeded" | The per-key (or per-user) sliding 60-second window is full | Retry-After header (seconds) | yes |
429 | "too_many_files_today" | The per-user daily file-upload cap is reached | — | no |
500 | "Masking operation failed" | Unhandled error on /api/v1/mask. Deliberately generic; it never echoes your text. | — | yes |
500 | "Redact operation failed" | Unhandled error on /api/v1/redact | — | yes |
503 | "masker_unavailable" | The detection service is unreachable or shedding load | Retry-After header when the cause is overload | yes |
403 with an empty body is the geo-block, not an authentication
problem. api.kindi.me, dashboard.kindi.me, and admin.kindi.me only
accept traffic from Saudi IP addresses; the block happens at the edge
proxy, so there is no {"detail": …} to parse. Retrying, re-minting the
key, or refreshing the session will not help; the call has to originate
inside Saudi Arabia (for example from your own KSA-region backend rather
than an end-user's browser abroad). kindi.me and docs.kindi.me are
world-readable.503 masker_unavailable request is refunded — the gateway reverses
the exact free/paid split it debited before calling the masker, on a
best-effort basis. Retrying therefore doesn't double-charge you.detail as a human-readable hint. The only detail strings stable
enough to branch on are the machine codes: insufficient_tokens,
masker_unavailable, unknown_judge_model, too_many_files_today, and
the file error_code values. Prose messages like
"Invalid authentication token" may be reworded.429, 500, 503. Concretely:123456789101112def kindi_with_retry(client, body, max_attempts=4): for attempt in range(max_attempts): r = client.post("/api/v1/mask", json=body) if r.status_code < 400: return r.json() if r.status_code == 429: time.sleep(int(r.headers.get("Retry-After", "1"))) continue if r.status_code in (500, 503) and attempt < max_attempts - 1: time.sleep(2 ** attempt) # 1s, 2s, 4s continue r.raise_for_status()
400 / 401 / 402 / 403 / 404 / 422; those are
caller-side issues. Retrying them either burns rate-limit quota (401) or
just wastes round-trips (400/422). 402 only resolves with a billing
top-up or the next Asia/Riyadh midnight free-quota refill, and 403 (the
geo-block) resolves only by calling from a Saudi IP address.503 masker_unavailable is the one server-side failure that is safe to
retry aggressively: the request is refunded, so a retry costs you the same
as the first attempt did.123456789HTTP/1.1 402 Payment Required Content-Type: application/json { "detail": "insufficient_tokens", "needed_tokens": 142, "available_tokens": 38, "topup_url": "/dashboard/billing" }
topup_url is a dashboard-relative path, not an absolute URL.
Resolve it against https://dashboard.kindi.me before linking: a
verbatim hyperlink would resolve against your API host and 404.12345HTTP/1.1 429 Too Many Requests Retry-After: 17 Content-Type: application/json {"detail":"Rate limit exceeded"}
Retry-After is 60 − the age of the oldest request still in
the window: the number of seconds until one slot frees up. It is the only
rate-limit header KINDI sends; there is no X-RateLimit-* family. See
Rate limits.429 purely because of an
infrastructure issue.POST /api/v1/files/redact):| Status | detail | When |
400 | "unsupported_format" | Not a supported type, or a corrupt / password-protected file |
413 | "file_too_large" | Over the 100 MB cap |
429 | "too_many_files_today" | 100 uploads per user per Asia/Riyadh day reached |
402 | "insufficient_tokens" | Balance exhausted before the job is enqueued |
201, state: "queued"), but the
job can later finish state: "failed" with an error_code. KINDI
fails closed: a file that can't be proven clean is never returned as
ready. See the file error_code table
for the full list (redaction_incomplete, verify_failed,
image_redact_failed, too_many_pages, …). masker_unavailable and
internal_error are transient; safe to re-upload.