Quickstart
Key, credits, first conversion. Three steps and a working curl for each.
$ curl -sS https://api.kaho.ai/v1/convert \
-H "Authorization: Bearer $KAHO_API_KEY" \
-H "Accept: text/markdown" \
-H "Content-Type: application/pdf" \
--data-binary @annual-report.pdf
## Chairman's letter
Trading conditions in the second half were
materially better than we guided in March.
Kaho converts PDF documents to Markdown over HTTP. You upload bytes, you get back text with structure, and you pay one credit for every page we successfully converted.
The base URL is https://api.kaho.ai. TLS 1.2 or later. All JSON is UTF-8. All Markdown is UTF-8
with no byte-order mark, LF line endings, and exactly one trailing newline.
Anything else under /v1 is 404 unknown_endpoint. A known path with the wrong method is
405 method_not_allowed with an Allow header.
/v1/health is a contract, not a ping. It returns 200 only when, in order, the edge is running,
the account store answers a no-op read, the metadata database answers an indexed read, the
converter reports a contract hash equal to ours, and the converter forks a no-op child that exits
cleanly. Any failure returns 503 naming the component that failed.
Two version axes, never three. /v1 versions the wire contract and the engine option
versions the output bytes. There is no dated version header. A default is never changed inside
/v1; new behaviour arrives as a new option value or a new engine.
Determinism is the product. For fixed source bytes, normalized options and a pinned engine, the
output is byte-identical across time and machines. Every response carries
document.content_sha256, so the claim is testable in one line.
We bill for correct output, never for failure. Every 4xx and 5xx is free. So is every timeout, and so is every page we could not convert inside a request that otherwise succeeded.
Silence is forbidden. Every degradation produces a machine-readable warning at page granularity, with a severity, the pages affected, and whether those pages were billed.
Strict on input, additive on output. Unknown request fields are rejected with
400 unknown_parameter naming the path. Unknown response fields, warning codes and error codes
must be ignored by your client — that is how new capability ships without a major version.
Quickstart
Key, credits, first conversion. Three steps and a working curl for each.
Authentication
Key format, the header, rotation with overlap, and exactly what happens when a key leaks.
Pricing and credits
What bills, what does not, how holds and settlement work, and how to check your balance.
Errors
The full taxonomy as a reference table, and what retryable actually means.
Rate limits
The published limits, the headers, and what to do when you hit one.
Page ranges
The grammar, what is rejected and why, and how a selection turns into a bill.
Being specific about the edges is cheaper for both of us than letting you find them in production.
| Capability | v1 status |
|---|---|
| Markdown conversion | Shipped, flavor: "gfm" |
| Table of contents from bookmarks | Shipped |
| Table of contents by inference | fallback: "headings" — 422 unsupported_option_value until M2 |
| Tables | Tagged documents only, tables: "tagged_only"; "auto" rejected |
| Images | omit and placeholder only; link and embed rejected |
| OCR | Not available. ocr accepts only "off" |
flavor: "commonmark" | Rejected with 422 unsupported_option_value |
| Supplying a PDF by URL | Rejected with 400 unsupported_source_type |
| Supplying a PDF as base64 JSON | Rejected with 400 unsupported_source_type, permanently |
| Asynchronous jobs above 200 pages | Not in v1 |
| Chunking and provenance blocks | Not in v1 |
Two of those are worth a sentence rather than a row.
Base64 sources are refused permanently, not temporarily. Base64 is 33% more bytes on the wire
for a product priced per page, and a fully buffered base64 JSON body measured 2.7 times the peak
memory of the equivalent multipart upload. multipart/form-data, or a two-line pre-upload to
/v1/files, answers every ergonomic argument for it.
URL sources are refused for now, and the reason is not laziness. Fetching a URL you supply makes us an authenticated HTTP client that strangers aim at arbitrary destinations. When it ships it will be its own process with its own network namespace, resolve-then-pin addressing, and a firewall that default-denies private ranges and the cloud metadata address. Until then, upload the bytes.
The Markdown we return is a security control, not a formatting preference.
<, > and & in extracted text are
escaped so they cannot form tags or entities.http, https, mailto and tel. Anything else — including
javascript:, which is real and reachable in PDF link annotations — is dropped, the anchor text
is kept as plain text, and link_scheme_blocked is emitted.Rendering our output in a browser cannot execute document-supplied script. That is a guarantee, not a best effort.
The output also never contains a filename, an upload path, a URL, or any server-side identifier. The only place a document title may appear is opt-in YAML front matter sourced from the PDF’s own metadata, and no synthetic heading is ever invented.