Errors
Every non-2xx response, on every endpoint, is exactly one shape. There are no exceptions —
including 413, 429 and 503. No HTML error page ever escapes to a customer.
{ "error": { "type": "invalid_request_error", "code": "page_range_reversed", "message": "Page range \"7-3\" is reversed. Ranges must be ascending; write \"3-7\".", "param": "options.pages", "retryable": false, "retry_after_ms": null, "doc_url": "https://docs.kaho.ai/errors/page_range_reversed", "request_id": "req_01JQ8Z5T7B9KX2W4M6N0P3R5S7", "details": { "value": "7-3" } }}| Field | Notes |
|---|---|
type | Coarse class. Ten values, listed below |
code | Match on this. Never match on message |
message | Human-readable. Names the offending input, what was wrong, and the fix |
param | Path of the offending field, e.g. options.pages. Null when not field-specific |
retryable | Whether retrying this exact request could succeed |
retry_after_ms | How long to wait, when we know. Null otherwise |
doc_url | A page on this site for this code |
request_id | Present on every response, including successes. Quote it to support |
details | Code-specific, additive. Ignore keys you do not recognise |
Nothing here bills. Every 4xx and every 5xx is billed 0 credits, and any credit hold is released within 60 seconds.
type is a coarse class, so a client can branch sensibly on a code it has never seen before.
type | HTTP | What it means for you |
|---|---|---|
invalid_request_error | 400, 405, 406, 409, 413, 415, 422 | You sent something we cannot accept. Fix and resend |
authentication_error | 401 | The key is missing, malformed, unknown, revoked or expired |
permission_error | 403 | The key authenticated but is not allowed to do this |
not_found_error | 404, 410 | The thing does not exist, or no longer does |
payment_required_error | 402 | Out of credits, or spending is frozen |
rate_limit_error | 429 | Slow down. Retry-After says how much |
document_error | 422 | The document is the bad input, not the request |
timeout_error | 408, 504 | Something took too long |
overloaded_error | 503 | We are saturated. Retry with backoff |
api_error | 500 | Our bug. Always carries request_id, never bills |
document_error sharing a status code with invalid_request_error is deliberate: both are 422,
and type is what tells you whether to fix your call or to look at the file.
Retrying
Section titled “Retrying”retryable is a first-class boolean because “should I retry this?” is otherwise a status-code
guessing game. Read the field. As a rule:
internal_errorandoverloadedare retryable, andoverloadedcarriesRetry-After.- Every
429is retryable after theRetry-Afterinterval. idempotency_in_progressis retryable afterRetry-After: 1— your first attempt is still running.- Everything else that is a
4xxwill fail the same way until you change the request.
400 — malformed request
Section titled “400 — malformed request”| Code | Meaning |
|---|---|
malformed_json | The JSON body did not parse |
multipart_malformed | The multipart body did not parse |
missing_source | No PDF was supplied |
multiple_sources | More than one encoding was used at once. The three may not be mixed |
unsupported_source_type | base64 (refused permanently) or url (not in v1) source type |
unknown_parameter | An unrecognised field. param names its exact path |
invalid_parameter_type | A field was the wrong type — for example pages as an array |
query_options_not_allowed | Query parameters sent alongside a multipart or JSON body |
metadata_too_large | Client metadata exceeded its size limit |
401 — authentication
Section titled “401 — authentication”All four carry WWW-Authenticate: Bearer realm="kaho", error="invalid_token".
| Code | Meaning |
|---|---|
missing_api_key | No bearer credential, or a key sent in a query parameter or other header |
invalid_api_key | Malformed, checksum failed, or unknown |
revoked_api_key | The key was revoked. Revocation is irreversible |
expired_api_key | Past expires_at. The message names the replacement key’s hint |
402 — payment required
Section titled “402 — payment required”| Code | Meaning |
|---|---|
insufficient_credits | details carries credits_required, credits_available and top_up_url |
account_delinquent | An open dispute or chargeback froze spending. details always carries debt_credits and credits_required_to_resume |
403 — permission
Section titled “403 — permission”| Code | Meaning |
|---|---|
insufficient_scope | The key lacks the scope this endpoint needs |
ip_not_allowed | The caller’s address is outside the key’s allowlist |
account_suspended | The organization is suspended |
pdf_extraction_not_permitted | The document’s permission bits forbid text extraction. Billed 0 |
debug_not_permitted | debug: true on an organization whose debug policy is disabled — the default for any organization with a signed DPA |
404 and 410 — not found
Section titled “404 and 410 — not found”| Code | Status | Meaning |
|---|---|---|
file_not_found | 404 | No such file, or it belongs to another organization or environment |
engine_not_found | 404 | No such engine identifier |
unknown_endpoint | 404 | No such route under /v1 |
file_expired | 410 | The file existed and its retention window has passed |
result_expired | 410 | The cached result existed and its 24-hour window has passed |
An identifier belonging to another organization, or to the other environment, returns 404 and
never 403. A 403 there would be an existence oracle.
405, 406, 409 and 415
Section titled “405, 406, 409 and 415”| Code | Status | Meaning |
|---|---|---|
method_not_allowed | 405 | Known path, wrong method. Carries an Allow header |
unsupported_accept | 406 | Accept was neither application/json nor text/markdown |
idempotency_conflict | 409 | Same idempotency key, different request |
idempotency_in_progress | 409 | The first attempt is still running. Carries Retry-After: 1 |
unsupported_content_type | 415 | Not one of the three accepted encodings |
413 — too large
Section titled “413 — too large”| Code | Meaning |
|---|---|
file_too_large | Above 32 MiB on /v1/convert, or 128 MiB on /v1/files |
too_many_pages | The selection exceeds the endpoint’s ceiling — 200 pages on /v1/convert |
storage_quota_exceeded | The organization’s stored-bytes limit is full |
pdf_too_complex | The document’s structure exceeds what we will parse |
There is no result_too_large. An oversized result is a 200 carrying result_truncated and
the pages we did deliver: killing the conversion mid-write would throw away work you would have
accepted and bill nothing for work we performed.
422 — the request cannot be processed
Section titled “422 — the request cannot be processed”type is invalid_request_error. Fix the request.
| Code | Meaning |
|---|---|
page_range_invalid | The pages expression does not parse. See Page ranges |
page_range_zero | Page 0. Pages are 1-based |
page_range_reversed | A descending range such as 7-3 |
page_range_out_of_bounds | The selection lies entirely beyond the document. Billed 0 |
page_range_too_complex | Over 2,048 characters or over 1,000 ranges |
unsupported_option_value | A valid option with a value not available in v1 — flavor: "commonmark", tables: "auto", fallback: "headings" |
option_conflict | Two options that cannot both be honoured |
ocr_not_available | ocr was set to anything other than "off" |
feature_not_enabled | The feature exists but is not enabled for this organization |
422 — the document cannot be processed
Section titled “422 — the document cannot be processed”type is document_error. The request was fine; look at the file.
| Code | Meaning |
|---|---|
pdf_invalid | Not a PDF |
pdf_corrupt | A PDF whose structure is damaged beyond recovery |
pdf_read_error | The bytes could not be read to completion |
pdf_parse_failed | Parsing failed on a structure we understand but the file gets wrong |
pdf_encrypted | The document needs a password and none was supplied |
pdf_password_incorrect | A password was supplied and it is wrong |
pdf_unsupported_security | An encryption handler we do not support |
pdf_no_pages | A valid PDF with zero pages |
pdf_is_xfa | An XFA form, which is a different document model |
document_has_no_text_layer | No page in the document carries extractable text. There is no OCR in v1 |
document_too_complex | The document exhausted its work budget |
429 — rate limited
Section titled “429 — rate limited”All carry Retry-After and the full x-ratelimit-* header set. All bill 0. See
Rate limits for what each meter measures and how to pace against it.
| Code | The meter that denied you |
|---|---|
rate_limit_requests | Requests per minute |
rate_limit_pages | Pages per minute |
rate_limit_concurrency | Concurrent in-flight requests for your organization |
rate_limit_uploads | Upload bytes per minute |
rate_limit_inspect | /v1/inspect requests per minute, or its daily sample page budget |
rate_limit_toc | /v1/toc requests per minute |
rate_limit_no_value_conversions | Conversions per hour that produced nothing at all |
rate_limit_unbilled_work | Compute per hour consumed by requests that billed zero |
rate_limit_new_account | The new-account burn cap |
408 and 504 — timeouts
Section titled “408 and 504 — timeouts”| Code | Status | Meaning |
|---|---|---|
request_timeout | 408 | Your upload did not finish inside the 20-second total-body deadline |
conversion_timeout | 504 | Conversion exceeded the wall-clock limit. details.pages_completed says how far it got |
Both bill 0. A request_timeout usually means a slow link rather than a large file — the deadline
is on the whole body from the first byte, so a 30 MiB upload on a slow connection can hit it. Upload
once with POST /v1/files and convert from the file_id afterwards.
500 and 503 — ours
Section titled “500 and 503 — ours”| Code | Status | Meaning |
|---|---|---|
internal_error | 500 | Our bug. Retryable, always carries request_id, never bills |
overloaded | 503 | The fleet is saturated. Retryable, carries Retry-After |
503 overloaded and 429 rate_limit_concurrency are different situations and the split matters:
429 means your organization exceeded its own concurrency limit and the fix is on your side;
503 means the fleet is full and the fix is to back off and retry.
Quote the request_id when you contact support. It is on every response, including successful
ones.
Message discipline
Section titled “Message discipline”Error messages name the offending input, state what was wrong, and state the fix. They never contain internal paths, hostnames, addresses, stack traces or SQL. Any string you supplied that appears in a message is capped at 128 characters with control characters stripped, so an error message can never be used to inject a header or a log line.