Skip to content
shell 5 pages · 5 credits · $0.0025

$ 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.

PDF to Markdown, by the page

One HTTP call in, Markdown out. Prepaid credits at $0.50 per thousand pages, byte-identical output for a pinned engine, and every error free.

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.

$0.50per 1,000 converted pages, prepaid
1 creditone converted page — no fractional credits, ever
$0billed for any 4xx, 5xx or timeout
0per-request fee and no minimum charge
POST/v1/convertSynchronous conversion to Markdown
POST/v1/tocTable of contents, authoritative or inferred
POST/v1/inspectStructural probe and cost quote
POST/v1/filesUpload once, convert many times
GET/v1/files/{id}File metadata
DELETE/v1/files/{id}Delete now
GET/v1/accountBalance, limits, storage, features
GET/v1/credits/ledgerAuthoritative credit history
GET/v1/usageAggregated usage
GET/v1/usage/recordsPer-request usage detail
POST/v1/usage/exportAsync CSV or NDJSON export
GET/v1/enginesEngine catalogue
GET/v1/deprecationsMachine-readable deprecation feed
GET/v1/healthUnauthenticated liveness
GET/v1/openapi.jsonUnauthenticated OpenAPI 3.1

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.

Start here

Authentication

Key format, the header, rotation with overlap, and exactly what happens when a key leaks.

Read it

Pricing and credits

What bills, what does not, how holds and settlement work, and how to check your balance.

Read it

Errors

The full taxonomy as a reference table, and what retryable actually means.

Read it

Rate limits

The published limits, the headers, and what to do when you hit one.

Read it

Page ranges

The grammar, what is rejected and why, and how a selection turns into a bill.

Read it

Being specific about the edges is cheaper for both of us than letting you find them in production.

Capabilityv1 status
Markdown conversionShipped, flavor: "gfm"
Table of contents from bookmarksShipped
Table of contents by inferencefallback: "headings"422 unsupported_option_value until M2
TablesTagged documents only, tables: "tagged_only"; "auto" rejected
Imagesomit and placeholder only; link and embed rejected
OCRNot available. ocr accepts only "off"
flavor: "commonmark"Rejected with 422 unsupported_option_value
Supplying a PDF by URLRejected with 400 unsupported_source_type
Supplying a PDF as base64 JSONRejected with 400 unsupported_source_type, permanently
Asynchronous jobs above 200 pagesNot in v1
Chunking and provenance blocksNot 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.

  • Output contains no raw HTML derived from the PDF. <, > and & in extracted text are escaped so they cannot form tags or entities.
  • Markdown structural characters extracted from the document are backslash-escaped wherever they would create structure the document did not have.
  • Link destinations are filtered to 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.