Skip to content

Authentication

Every /v1 endpoint except /v1/health and /v1/openapi.json is authenticated with a bearer token.

The only accepted form
Authorization: Bearer kaho_sk_live_JhK2mQ9xT4vBn7RcW1sYdF6gPzL8aE3uH5iO0jN2kM4_x7Kp2q

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

Every key is exactly 63 characters, live and test alike.

kaho_issuer · 5
sk_type · 3
live_env · 5
JhK2mQ9xT4vBn7RcW1sYdF6gPzL8aE3uH5iO0jN2kM443 base62 random · 256 bits
_x7Kp2qCRC-32 · 6
SegmentValueNotes
Issuerkaho_Unique prefix, so scanners can recognise our keys specifically
Typesk_Secret key. Console session tokens use kaho_cs_ and are not accepted here
Environmentlive_ or test_Part of the checksum, so a live/test transposition fails offline
Random43 charactersbase62, 256.03 bits from a CSPRNG with rejection sampling
Checksum6 charactersCRC-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.

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:

Force a 402 without spending anything
curl -sS https://api.kaho.ai/v1/convert \
-H "Authorization: Bearer $KAHO_TEST_KEY" \
-H "X-Kaho-Simulate: insufficient_credits" \
-F "file=@small.pdf"

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 map one-to-one onto endpoints.

ScopeGrants
convertPOST /v1/convert
files:writePOST /v1/files, DELETE /v1/files/{id}
files:readGET /v1/files/{id}
inspectPOST /v1/inspect
account:readGET /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 is a first-class operation with a deliberate overlap window, not a delete followed by a create.

  1. Mint the replacement with the same name, project, scopes and IP allowlist.
  2. The old key’s expires_at is set to now plus the overlap — 7 days by default, or 1 hour, 24 hours, or immediate.
  3. The console shows a live traffic split across the two keys, so you know when the old one has gone quiet instead of guessing.
  4. 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.

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.

All four carry WWW-Authenticate: Bearer realm="kaho", error="invalid_token".

CodeMeaning
missing_api_keyNo Authorization: Bearer header, or a key sent somewhere else
invalid_api_keyWell-formed but unknown, or malformed, or the checksum failed
revoked_api_keyThe key was revoked
expired_api_keyThe 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.

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

  2. 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"
  3. Check the ledger, not just usage. GET /v1/credits/ledger reads the authoritative record of every credit movement; /v1/usage is an indexed reporting copy that can lag it.

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

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