Authentication
Every request carries an API key as a bearer token. Create keys on the dashboard; the secret is shown once and stored only as a SHA-256 hash, so it cannot be recovered later.
Authorization: Bearer rk_live_3f9a2c8b…
A missing key returns 401 missing_key; an unknown or revoked one
returns 401 invalid_key. Keys are not scoped — any key can call any
endpoint your plan allows.
POST /v1/ocr
Send one image, get one transcription. The call is synchronous: there is no job id to poll and no webhook to configure.
Request
Either a multipart form with the image under the field file, or the
raw image bytes as the request body. Options may be passed as query parameters
or as multipart fields.
| Parameter | Default | Notes |
|---|---|---|
| file | — | The image. Multipart only; omit when posting raw bytes. |
| engine | auto | auto, fast or precise. See below. |
| lang | eng | Tesseract language code, fast engine only. Multiple as eng+deu. |
| fields | 0 | Set to 1 to infer form fields — label/value pairs and ticked boxes — from the page layout. |
Accepted formats: PDF, PNG, JPEG, HEIC/HEIF, GIF, WebP, TIFF and BMP.
A PDF is rasterised at 200 dpi and every page is recognised through the same
engine, so the response shape never depends on what you sent. Each page
costs one page of quota on auto and fast, and twenty on
precise. A document longer than your plan's per-request
limit is rejected with 402 document_too_long before any of it is
charged, so you are never billed for a partial job.
HEIC is transcoded server-side, and EXIF orientation is baked into the pixels
before recognition. Phone photos therefore come back upright, and
width, height and every word box are in that upright
space — the same space the image occupies when a browser displays it.
curl -X POST https://api.recto.dev/v1/ocr \
-H "Authorization: Bearer $RECTO_KEY" \
-F "file=@receipt.png" \
-F "engine=fast"
Response
200 OK with the transcription under result. The shape is the
same for both engines; fields an engine cannot produce come back empty rather
than missing.
{
"id": 4217,
"pages_billed": 2,
"page_multiplier": 1,
"pages_used": 38,
"quota": 5000,
"result": {
"engine": "fast",
"source": "pdf",
"page_count": 2,
"text": "STATEMENT\nAshgrove Analytics Ltd…",
"markdown": "",
"word_count": 191,
"mean_conf": 93.4,
"duration_ms": 940,
"pages": [
{
"page": 1,
"width": 1700, "height": 2200,
"text": "STATEMENT\nAshgrove Analytics Ltd…",
"word_count": 138,
"mean_conf": 94.1,
"words": [
{ "text": "STATEMENT", "x": 148, "y": 96,
"w": 286, "h": 37, "conf": 96.1,
"block": 1, "line": 1 }
]
}
]
}
}
result.pages is always an array, even for a single image — one shape
for every input, so no client needs to branch. Document-level totals sit on
result; per-page geometry sits on each entry.
| Field | Notes |
|---|---|
| source | image or pdf. |
| page_count | Pages recognised, and the number billed. |
| text | The whole document in reading order, pages separated by a blank line. |
| markdown | Layout-aware Markdown. Precise engine only; empty string on fast. |
| mean_conf | Word-weighted mean across pages, so a sparse page cannot skew it. |
| pages[].words[] | Word geometry in that page's pixels. Fast engine only; empty array on precise. |
| pages[].conf | Per-word confidence, 0–100. Words tesseract rejected outright are dropped. |
| pages[].block, line | Grouping indices — words sharing both are on the same rendered line. |
| pages[].width, height | That page's raster size, so boxes can be scaled to any display size. |
Two response headers report metering on every successful call:
X-Recto-Pages-Used and X-Recto-Pages-Quota.
Engines
fast runs Tesseract 5. It is the right default: fast enough to sit in a
request path, and the only engine that returns geometry.
auto is the default. For a PDF that already contains
its text there is no reason to photograph it and read it back. If you need
per-word boxes on every page, ask for engine=fast explicitly —
that is the escape hatch, and it is the only thing that changed for existing
callers.
auto is for PDFs. It inspects each page in about a millisecond and
reads the ones that already carry a text layer directly, sending only the rest
to OCR. On a text-based PDF that is roughly 50× faster and
character-exact — no recognition, so no recognition errors. The response
includes a routing block saying which pages went which way.
The trade is geometry: a text layer is stored as styled runs, not words, so
those pages return roughly half as many boxes and one box may span a whole
phrase. They are marked "source": "text" and
"word_level": false so you never have to guess. Pages that went
through OCR in the same document keep per-word boxes and are marked
"source": "ocr". Non-PDF input falls back to fast.
precise runs a local vision model. It costs seconds instead of
milliseconds and returns no coordinates, but it reads handwriting, skewed
phone photos and dense multi-column layouts that classical OCR turns into
noise — and it returns structure as Markdown, including tables.
The precise engine requires the Pro plan or above; asking for it on Free returns 402 plan_required.
It also costs more allowance: one precise page draws 20, where
auto and fast draw one. The rate is returned as page_multiplier on
every response, and pages_billed is already multiplied —
result.page_count is always the number of pages actually read. A
request that would exceed the remaining quota is refused with
402 quota_exceeded before the model is called.
Working with boxes
Coordinates are in that page's pixels with the origin at the top left. To draw
over a scaled display, multiply by displayWidth / page.width. Each
page carries its own dimensions, because pages in a PDF need not be the same size.
// black out anything that looks like a card number
const page = result.pages[0];
const scale = canvas.width / page.width;
for (const w of page.words) {
if (/^\d{4}$/.test(w.text) && w.conf > 80) {
ctx.fillRect(w.x * scale, w.y * scale, w.w * scale, w.h * scale);
}
}
Words arrive in reading order, so grouping by block then line
reconstructs paragraphs without any sorting of your own.
Errors
Every failure returns the same envelope, with a stable code worth branching on:
{ "error": { "code": "quota_exceeded",
"message": "Monthly quota of 500 pages used. Upgrade to continue." } }| Status | Code | Meaning |
|---|---|---|
| 400 | bad_image | Not a decodable image, or over your plan's size limit. |
| 400 | unknown_engine | engine was neither fast nor precise. |
| 401 | missing_key | No Authorization header. |
| 401 | invalid_key | Key unknown or revoked. |
| 402 | plan_required | The engine is not on your plan. |
| 402 | quota_exceeded | The request needs more pages than the quota has left (precise draws 20 a page). |
| 402 | document_too_long | PDF exceeds your plan's pages-per-request limit. |
| 422 | ocr_failed | Readable request, but recognition failed. Not billed. |
| 429 | rate_limited | Per-minute rate exceeded. Honour Retry-After. |
| 503 | engine_unavailable | Precise engine temporarily down. Retry or fall back to fast. |
Limits
| Plan | Pages / mo | Req / min | Max file | Pages / PDF | Keys |
|---|---|---|---|---|---|
| Loading… | |||||
Quotas reset at 00:00 UTC on the first of each month and do not roll over. An image costs one page; a PDF costs one per page. Failed recognitions are recorded in your activity log but never counted against the quota.
Health
Unauthenticated. Reports which engines are currently able to serve requests.
{ "status": "ok",
"engines": { "fast": true, "precise": true } }