Authentication
Every /v1 endpoint except /v1/health and /v1/openapi.json is authenticated with a bearer
token.
Authorization: Bearer kaho_sk_live_JhK2mQ9xT4vBn7RcW1sYdF6gPzL8aE3uH5iO0jN2kM4_x7Kp2qA key presented in a query parameter, or in any other header, is 401 missing_api_key. Query
strings end up in access logs, browser history and referrer headers, so there is no second accepted
location and there never will be.
CORS is off for every authenticated endpoint. No Access-Control-Allow-Origin header is emitted
for them and preflight requests are refused. Secret keys must not live in browsers. /v1/health
and /v1/openapi.json are the only CORS-open endpoints, and neither accepts a key.
Key format
Section titled “Key format”Every key is exactly 63 characters, live and test alike.
| Segment | Value | Notes |
|---|---|---|
| Issuer | kaho_ | Unique prefix, so scanners can recognise our keys specifically |
| Type | sk_ | Secret key. Console session tokens use kaho_cs_ and are not accepted here |
| Environment | live_ or test_ | Part of the checksum, so a live/test transposition fails offline |
| Random | 43 characters | base62, 256.03 bits from a CSPRNG with rejection sampling |
| Checksum | 6 characters | CRC-32/ISO-HDLC over everything before the final underscore |
The alphabet is base62 in the order 0-9, then A-Z, then a-z. base62 rather than base64url
because _ inside the payload makes a naive split('_') on a key non-deterministic, and -
breaks double-click selection in a terminal.
We store HMAC-SHA-256(pepper, key) and nothing else. There is no reveal button in the console
because there is nothing to reveal: the plaintext exists exactly once, in the 201 response that
created the key.
Test keys
Section titled “Test keys”kaho_sk_test_ keys work on every endpoint, bill 0 credits, and cap at 5 selected pages per
request. They run on separate infrastructure, so test traffic can never displace paid traffic, and
they draw on a per-organization test allowance of 5,000 pages per month.
Test keys honour X-Kaho-Simulate for a named subset of failures, so you can exercise your error
handling without constructing a hostile PDF:
curl -sS https://api.kaho.ai/v1/convert \ -H "Authorization: Bearer $KAHO_TEST_KEY" \ -H "X-Kaho-Simulate: insufficient_credits" \The simulatable codes are insufficient_credits, account_delinquent, rate_limit_requests,
rate_limit_pages, rate_limit_concurrency, pdf_encrypted, pdf_password_incorrect,
pdf_corrupt, pdf_extraction_not_permitted, document_has_no_text_layer, conversion_timeout,
overloaded, too_many_pages and page_has_no_text_layer. X-Kaho-Simulate is ignored, not
rejected, on a live key, so a live key can never zero its own pages by accident.
Test keys cannot see live resources. A file uploaded with a live key is invisible to a test
key and vice versa: the ownership check includes the environment, and a mismatch returns 404.
Test keys are the ones customers paste into CI and into public repositories, and a leaked test key
must not be able to read live documents.
Scopes
Section titled “Scopes”Scopes map one-to-one onto endpoints.
| Scope | Grants |
|---|---|
convert | POST /v1/convert |
files:write | POST /v1/files, DELETE /v1/files/{id} |
files:read | GET /v1/files/{id} |
inspect | POST /v1/inspect |
account:read | GET /v1/account, ledger and usage |
Four presets at creation: Full access (the default), Restricted (everything except
account:read, so a leaked key cannot read your balance), Read only, and Custom. A call
made without the required scope is 403 insufficient_scope.
Several scope names — jobs:read, jobs:write, keys:read, keys:write, billing:read,
billing:write, webhooks:write, org:admin — are reserved and documented as reserved. They are
never issued in v1. An unknown scope string in a stored record is ignored, never fatal.
Limits are 100 active keys per organization and 1,000 over its lifetime.
Rotation
Section titled “Rotation”Rotation is a first-class operation with a deliberate overlap window, not a delete followed by a create.
- Mint the replacement with the same name, project, scopes and IP allowlist.
- The old key’s
expires_atis set to now plus the overlap — 7 days by default, or 1 hour, 24 hours, or immediate. - The console shows a live traffic split across the two keys, so you know when the old one has gone quiet instead of guessing.
- At expiry the old key returns
401 expired_api_key, and the message names the replacement’s hint so an on-call engineer reading a log line knows which key to look for.
Rotate on a schedule, when someone with access leaves, and immediately on any suspicion of exposure. Because idempotency keys are scoped to the organization rather than to the key, a client rotating in the middle of a retry cannot be billed twice.
Revocation is irreversible. There is no un-revoke. A key once considered compromised is never trusted again, and an undo button would erode exactly that discipline.
How fast a change takes effect
Section titled “How fast a change takes effect”Revoking an API key or suspending an account takes effect on the very next request. Changes to a key’s scopes, expiry or IP allowlist propagate within 10 seconds.
The split is not a hedge; it is where the data lives. Revocation and account status are read from a strongly consistent, single-writer store that every authenticated request already calls, so there is no cache to wait out. Scope, expiry and the IP allowlist are read from a 10-second isolate cache. If you are tightening a compromised key’s scopes as a remediation, that change is stale for up to ten seconds; if you are revoking, it is not.
401 codes
Section titled “401 codes”All four carry WWW-Authenticate: Bearer realm="kaho", error="invalid_token".
| Code | Meaning |
|---|---|
missing_api_key | No Authorization: Bearer header, or a key sent somewhere else |
invalid_api_key | Well-formed but unknown, or malformed, or the checksum failed |
revoked_api_key | The key was revoked |
expired_api_key | The key passed its expires_at; the message names the replacement hint |
422 pdf_encrypted deliberately does not map to 401. Your credentials were fine; the
document needs a password. Conflating the two breaks every client’s auth-retry logic.
If a key leaks
Section titled “If a key leaks”What we do
Section titled “What we do”We are enrolled in GitHub secret scanning, including push protection, so a key pushed to a public repository is usually blocked before the commit lands. When a leak is confirmed:
- We revoke the key immediately. It stops working on the next request.
- We do not suspend the organization or freeze the balance. That punishes the victim.
- We auto-issue a replacement with the same name, scopes and allowlist, into a single-use reveal that expires in 7 days — so a working key is already waiting when you read the email.
- We notify every owner and admin, plus the key’s creator, with what leaked, where, and when.
- We publish a 90-day blast-radius report for that key, flagging any network the key had not been used from before.
- We do not auto-refund. An automatic refund on a self-reported leak is itself a fraud vector. Instead you get a one-click “dispute usage since <time>” that opens a support case.
What you should do
Section titled “What you should do”-
Rotate now, overlap later. If you know the leaked key is not in production, revoke it outright. If you are not sure, rotate with a 1-hour overlap and watch the traffic split.
-
Find out what it did. Every ledger and usage row carries
api_key_id, so the blast radius is a query, not an investigation:Everything one key did, per request curl -sS -G https://api.kaho.ai/v1/usage/records \-H "Authorization: Bearer $KAHO_API_KEY" \--data-urlencode "start=2026-08-01T00:00:00Z" \--data-urlencode "end=2026-08-17T00:00:00Z" \--data-urlencode "api_key_id=kak_01JQ8Z5T7B9KX2W4M6N0P3R5S7" -
Check the ledger, not just usage.
GET /v1/credits/ledgerreads the authoritative record of every credit movement;/v1/usageis an indexed reporting copy that can lag it. -
Look at the security page in the console: the key audit log, recent authentication failures by network prefix, and a “revoke all keys” button for the case where you would rather start over.
-
Narrow the replacement. If the key only ever converted, issue the replacement as Restricted so a future leak cannot even read your balance. If your callers have stable egress addresses, add an IP allowlist — but remember that allowlist changes take up to ten seconds to propagate.