Log in Sign up
Docs/AI/OCR — Advanced & Handwriting

OCR — Advanced & Handwriting v1.0 60 credits / call

GET|POST /api/v1/ocr

Read printed or handwritten text off an image — full transcription, or named fields off a form, with illegible marks flagged rather than guessed.

At a glance
Base path
/api/v1/ocr
Methods
GET or POST — parameters are read identically from the query string or a JSON body
Auth
X-Api-Key header (never Authorization: Bearer)
Price
60 credits per call · about $0.0600 at 1,000 credits/USD
Free tier
No per-API allowance; calls draw on your account balance
Test mode
Not supported; this API requires a live key
Rate limit
60 calls/minute per key by default, raisable per key
Pricing mode
Dynamic — the price depends on the request, so it is estimated up front and the difference refunded once the work is done

OCR — Advanced & Handwriting

Send a picture, get the text. Handles printed documents, screenshots, photographed pages, and handwriting — the case classical OCR engines are worst at.

POST /api/v1/ocr
X-Api-Key: ksty_live_...
Content-Type: application/json

{"image": "iVBORw0KGgoAAAANSUhEUg..."}
{
  "text": "Meeting notes — 14 March\nRevenue down 12% [?] to 4.1M\nFreeze hiring until Q1",
  "lines": ["Meeting notes — 14 March", "Revenue down 12% [?] to 4.1M", "Freeze hiring until Q1"],
  "language": "en",
  "handwritten": true,
  "legibility": "partial",
  "unreadable": ["One word between '12%' and 'to' on line 2 — heavily overwritten"],
  "usage": { "input_tokens": 1620, "output_tokens": 180 }
}

The thing to understand before you trust it

Classical OCR fails visibly: it returns Rev3nue d0wn and you can see it went wrong. A model reading bad handwriting fails invisibly — it returns a fluent, sensible word that simply is not what was on the page, and nothing downstream can distinguish that from a correct read.

This API is built to make that failure visible instead. Three signals, in every response:

Signal Read it as
[?] in the text a mark at this position could not be read
unreadable one short note per such spot, describing where
legibility clear, partial or poor for the page overall

A poor page with four unreadable notes is the API doing its job, not failing at it. Route on legibility rather than treating every response as equally good — that one branch is the difference between an automated pipeline you can trust and one that quietly corrupts records.

Endpoints

Transcribe — POST /api/v1/ocr

The whole page, in reading order, with original spelling, punctuation and line breaks preserved. Nothing is corrected, expanded or translated — a transcription that improves on the original has stopped being a transcription. lines is the same text split per visual line, which is usually what you want for tables and forms.

Named fields — POST /api/v1/ocr.fields

Give it the values you actually want and skip the transcription:

{"image": "...", "fields": ["invoice_no", "date", "total", "vat_number"]}

Every field comes back with found and value. Values are copied verbatim£1,240.00 stays as written rather than becoming 1240.0, because you can normalise a faithful value and cannot recover a mangled one. A field that isn't on the document, or can't be read, answers found: false with an empty value rather than a plausible-looking invention.

This endpoint is also cheaper: it holds and spends far less than a full transcription, since it only ever produces a handful of short strings.

Images

PNG, JPEG, GIF or WebP, sent as base64 in image — a bare base64 string or a data: URL both work. Up to 5 MB decoded, which is roughly 6.7 MB of base64 once encoded.

PDFs are not accepted here. Convert the pages to images first. A scanned PDF is the obvious next thing this API should take, and it is on the list — it is left out of v1 because per-page pricing is a real decision rather than a plumbing change.

Resolution is worth thinking about: text needs to be legible to a human at 100% zoom for the model to read it reliably, but a 12-megapixel phone photo of a receipt costs several times what a sensible crop does, for no accuracy gain. Crop to the document and downscale so the smallest text is still comfortably readable.

hint is optional and helps on ambiguous documents — "a UK VAT invoice" or "a handwritten prescription" gives the model context for what the marks are likely to be, without telling it what they say.

Pricing

Per token, like text-ai, because that is how the model bills — see the rate table on that page. An image is charged as input tokens by its pixel area, not its file size, so cropping a photo cuts the cost where compressing it does not.

The gateway pre-charges an estimate and refunds the difference once the call finishes. That estimate assumes the full output ceiling, so the hold on a transcription is substantially larger than what a typical page actually costs — expect the refund, and don't budget against the hold. X-Credits-Charged is the real figure.

Live keys only, no free allowance

No sandbox and no monthly grant, for the same reason as text-ai: every call spends real money on a metered model upstream. A ksty_test_ key gets 403 sandbox_disabled. Calls draw on your normal balance, including the credits granted at signup.

When to use it

  • Digitising handwritten forms, notes, or delivery dockets that a classical OCR engine mangles.
  • Pulling a fixed set of values off receipts or invoices, with found: false telling you which documents need a human instead of silently producing a wrong total.
  • Reading screenshots and photographed pages where layout and line breaks matter.

Endpoints

POST /api/v1/ocr 60 credits

Transcribe all text in an image, printed or handwritten

Preserves the original spelling, punctuation, casing and line breaks. Illegible marks become [?] in the text with a matching note in unreadable — the model is instructed never to substitute a plausible word for one it cannot read.

Parameters
ParameterTypeDescription
image required string The picture as base64, or a data: URL. PNG, JPEG, GIF or WebP, up to 5 MB decoded.
hint optional string ≤ 300 chars Optional context for what the document is, e.g. "a handwritten delivery docket".
Request
curl -X POST "https://ksty.ch/api/v1/ocr" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"image":"iVBORw0KGgoAAAANSUhEUgAA...","hint":"example"}'
<?php
$ch = curl_init('https://ksty.ch/api/v1/ocr');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-Api-Key: YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => '{    "image": "iVBORw0KGgoAAAANSUhEUgAA...",    "hint": "example"}',
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
const response = await fetch('https://ksty.ch/api/v1/ocr', {
  method: 'POST',
  headers: {
    'X-Api-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    "image": "iVBORw0KGgoAAAANSUhEUgAA...",
    "hint": "example"
})
});
const data = await response.json();
import requests

response = requests.post(
    'https://ksty.ch/api/v1/ocr',
    headers={'X-Api-Key': 'YOUR_API_KEY'},
    json={
    "image": "iVBORw0KGgoAAAANSUhEUgAA...",
    "hint": "example"
},
)
data = response.json()
Response 200 OK
{
    "text": "Meeting notes — 14 March\nRevenue down 12% [?] to 4.1M",
    "lines": [
        "Meeting notes — 14 March",
        "Revenue down 12% [?] to 4.1M"
    ],
    "language": "en",
    "handwritten": true,
    "legibility": "partial",
    "unreadable": [
        "One word on line 2 — heavily overwritten"
    ],
    "usage": {
        "input_tokens": 1620,
        "output_tokens": 180
    }
}
Response fields
FieldTypeDescription
text string The full transcription with original line breaks. [?] marks a spot that could not be read.
lines array The same text, one entry per visual line — usually what you want for forms and tables.
language string Detected language of the text, or unknown.
handwritten boolean True when a substantial part of the content is handwritten.
legibility string clear, partial or poor. Branch on this rather than trusting every response equally.
unreadable array One short note per unreadable spot. Empty on a clean page.
usage.input_tokens integer Tokens read, dominated by the image's pixel area.
usage.output_tokens integer Tokens produced — what you are billed for on the output side, not the ceiling that was held.
POST /api/v1/ocr.fields 60 credits

Read named values off a form, receipt or invoice

Send the values you want in fields and get back one {found, value} per name — nothing else. Values are copied verbatim rather than normalised, and a field that is absent or illegible answers found: false instead of an invention.

Cheaper than a full transcription, because it only ever produces a handful of short strings.

Parameters
ParameterTypeDescription
image required string The picture as base64, or a data: URL.
fields required array of string Field names to read, 1 to 25. Also accepts a comma-separated string.
hint optional string ≤ 300 chars Optional context for what the document is.
Request
curl -X POST "https://ksty.ch/api/v1/ocr.fields" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"image":"iVBORw0KGgoAAAANSUhEUgAA...","fields":["invoice_no","date","total"],"hint":"example"}'
<?php
$ch = curl_init('https://ksty.ch/api/v1/ocr.fields');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'X-Api-Key: YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => '{    "image": "iVBORw0KGgoAAAANSUhEUgAA...",    "fields": [        "invoice_no",        "date",        "total"    ],    "hint": "example"}',
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
const response = await fetch('https://ksty.ch/api/v1/ocr.fields', {
  method: 'POST',
  headers: {
    'X-Api-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    "image": "iVBORw0KGgoAAAANSUhEUgAA...",
    "fields": [
        "invoice_no",
        "date",
        "total"
    ],
    "hint": "example"
})
});
const data = await response.json();
import requests

response = requests.post(
    'https://ksty.ch/api/v1/ocr.fields',
    headers={'X-Api-Key': 'YOUR_API_KEY'},
    json={
    "image": "iVBORw0KGgoAAAANSUhEUgAA...",
    "fields": [
        "invoice_no",
        "date",
        "total"
    ],
    "hint": "example"
},
)
data = response.json()
Response 200 OK
{
    "fields": {
        "invoice_no": {
            "found": true,
            "value": "INV-2291"
        },
        "date": {
            "found": true,
            "value": "14/03/2026"
        },
        "total": {
            "found": true,
            "value": "£1,240.00"
        },
        "vat_number": {
            "found": false,
            "value": ""
        }
    },
    "handwritten": false,
    "legibility": "clear",
    "usage": {
        "input_tokens": 1450,
        "output_tokens": 64
    }
}
Response fields
FieldTypeDescription
fields object One entry per name you asked for, in the same names you sent.
fields.{name}.found boolean false when the field is absent or illegible. Check before reading value.
fields.{name}.value string The value exactly as written on the document — not normalised.
handwritten boolean True when a substantial part of the document is handwritten.
legibility string clear, partial or poor for the document overall.

Errors

Errors are JSON with a stable error.code — branch on the code, never on the message. Everything below carries the X-Request-Id of the failed call. Requests rejected by this API are not charged, and a 502 after a charge is refunded automatically.

CodeHTTPCause & what to do
invalid_request this API 400 No "image" was sent, it was not valid base64, it decoded to almost nothing, or "fields" was missing or unusable on the fields endpoint. Send the file bytes base64-encoded — not a path, not a URL. A data: URL is accepted too. For ocr.fields, send a non-empty list of field names.
unsupported_image this API 415 The bytes are not a PNG, JPEG, GIF or WebP. Convert first. The type is read from the file's own header rather than what you label it, so a mislabelled JPEG still works — an actual PDF does not.
image_too_large this API 413 The decoded image exceeded 5 MB. Downscale or crop. Text stays legible well below this ceiling, and a smaller image is cheaper as well as faster.
content_refused this API 422 The model declined to process the image. Nothing is charged. Retrying the identical image will refuse again — this is a judgment about the content, not a transient failure.
provider_busy this API 503 The upstream model was rate-limited, overloaded, or did not respond in time. Retry after a short backoff. Nothing is charged, so a retry costs no credits.
provider_unavailable this API 503 This deployment has no working model credentials configured. An operator problem rather than a request problem. Nothing is charged; contact support.
provider_error this API 502 The model answered in a form this API could not read. Nothing is charged. This is logged for us to investigate; a retry is worth one attempt.
output_truncated this API 502 The transcription hit its output ceiling before it was complete. Split a very dense page into sections and read them separately. Nothing is charged for a truncated answer.
invalid_key platform 401 The X-Api-Key header is missing, malformed, revoked or expired. Send an active key. Repeated bad keys from one address are throttled, so fix the header rather than retrying.
forbidden_scope platform 403 The key is valid but not scoped to this API, or it is a test key on an API without sandbox support. Add this API to the key's scopes on the Keys page, or use a key scoped to all APIs.
insufficient_credits platform 402 The balance cannot cover the call. Top up. Nothing is charged and the request never reaches the API, so no work is half-done.
rate_limited platform 429 The key exceeded its per-minute limit. Back off and retry. The limit is per key, so a second key does not share the budget.
upstream_error platform 502 The API failed unexpectedly after being charged. Safe to retry — credits are refunded automatically whenever this is returned.

Response headers

Every successful call reports its own cost, so you never have to guess what a request spent or reconcile it later.

HeaderMeaning
X-Credits-Charged Credits taken for this call. Zero on test-mode calls and on errors raised before the work started.
X-Credits-Remaining Balance left afterwards, across per-API grants, promotional and paid credits.
X-Request-Id Identifier for this call. It appears in your usage log and in every error body — quote it in support requests.