Apply OCR to a Document
Flatten a scanned PDF into one with a selectable text layer via POST /api/ocr, then send the result to the Extract API.
OCR and extraction are two separate steps. POST /api/extract never runs OCR — if a schema version requires text and you hand it a raw scan, it rejects with text_layer_required. POST /api/ocr is the step that turns that scan into a PDF the Extract API (and the model behind it) can actually read.
You only need this for scans and image-only PDFs. Born-digital PDFs (exported from a word processor, generated by a report engine, etc.) already carry a text layer and can go straight to /api/extract.
When you need it
- Your schema version has Requires Text set, and
- The PDF you're submitting is a scan / image-only (no selectable text).
If both are true, /api/extract returns HTTP 422 text_layer_required. The fix is the two-step flow:
POST /api/ocrwith the scan → get back the same PDF with a text layer applied.POST /api/extractwith the OCRd PDF → extraction proceeds.
OCR once, reuse the result. The flattened PDF can be sent to /api/extract as many times as you like (different schema versions, different models) without re-OCRing.
The endpoint at a glance
| Method | POST |
| URL | https://evals.thelibrari.com/api/ocr (or your own host) |
| Auth | Authorization: Bearer <extraction-api-key> |
| Body | multipart/form-data |
| Response | application/pdf — the OCRd document, as a binary body (or text/plain with return_text_only=true), plus text-layer headers |
Request fields
| Field | Required | Type | Notes |
|---|---|---|---|
file | ✓ | file part | The PDF to OCR. Must be application/pdf — other content types return 400. |
return_text_only | text part | When set to true (also accepts 1/on), the response is the OCRd document's plain-text layer (text/plain) instead of the PDF. Omit it — or send any other value — for the default PDF response. |
Response (200 OK)
By default the response body is the OCRd PDF — a binary application/pdf stream, not JSON. Save it to disk, hold it in memory, or pipe it straight into /api/extract. Content-Disposition echoes the uploaded filename.
With return_text_only=true, the response body is instead the extracted text layer of the OCRd document as a text/plain stream — handy when you only need the text and don't want to round-trip a PDF. The text is pulled from the same OCRd output, so it reflects exactly what /api/extract would read.
Response headers: did OCR actually find text?
Here's the catch with scans: OCR can succeed — HTTP 200, PDF returned — and still find nothing to read. A photo, a blank page, handwriting, an illegible scan: the OCR engine runs, recognizes no words, and hands you back a PDF with no more text than it started with. Send that to /api/extract and you'll get the very text_layer_required 422 you were trying to avoid.
So the response tells you up front. Every 200 carries two headers:
| Header | Value | Meaning |
|---|---|---|
X-Ocr-Has-Text-Layer | true / false | Whether the OCRd PDF passes the same text-layer check /api/extract runs for schema versions with Requires Text. false means a follow-up extract will be rejected with text_layer_required. |
X-Ocr-Text-Chars | integer | How many characters of text the check sampled from the OCRd document (it reads the first few pages, so this is a sample, not a total). 0 means the document yielded no text at all. |
Because it's the same check, X-Ocr-Has-Text-Layer: false isn't a guess — it's exactly what /api/extract will decide. Use it to short-circuit: mark the document as having no readable text and skip the doomed extract call entirely.
resp.raise_for_status()
if resp.headers.get("X-Ocr-Has-Text-Layer") == "false":
# photo / blank page / unreadable scan — nothing to extract
handle_unreadable_document()Walkthrough
1. Mint and stash a bearer token
/api/ocruses the same Extraction API Key as/api/extract— one token for the whole API surface. See Manage Extraction API Keys. Copy the full key the moment it's shown; afterwards only the last 4 characters are stored.2. POST the scan as multipart/form-data
curl(note-o— the response is a binary PDF, not JSON):curl -X POST https://evals.thelibrari.com/api/ocr \ -H "Authorization: Bearer $LIBRARI_EXTRACT_KEY" \ -F "file=@/path/to/scan.pdf" \ -o scan.ocr.pdfPython (
requests):import os, requests with open("/path/to/scan.pdf", "rb") as f: resp = requests.post( "https://evals.thelibrari.com/api/ocr", headers={"Authorization": f"Bearer {os.environ['LIBRARI_EXTRACT_KEY']}"}, files={"file": ("scan.pdf", f, "application/pdf")}, timeout=260, # the service caps OCR runtime at 220s; +40s for upload/transfer ) resp.raise_for_status() ocr_pdf_bytes = resp.content # the OCRd PDF, ready to POST to /api/extractTypeScript / Node 20+ (
fetch+FormData):import { readFile } from "node:fs/promises" const fileBuf = await readFile("/path/to/scan.pdf") const form = new FormData() form.append("file", new Blob([fileBuf], { type: "application/pdf" }), "scan.pdf") const res = await fetch("https://evals.thelibrari.com/api/ocr", { method: "POST", headers: { Authorization: `Bearer ${process.env.LIBRARI_EXTRACT_KEY!}` }, body: form, }) if (!res.ok) throw new Error(`OCR failed: ${res.status} ${await res.text()}`) const ocrPdf = Buffer.from(await res.arrayBuffer()) // pass this to /api/extract3. Check the text-layer headers, then send to /api/extract
Before you spend the extract call, glance at
X-Ocr-Has-Text-Layeron the response. If it'sfalse, OCR found no readable text (a photo, a blank page, an illegible scan) — extraction against a Requires Text schema will be rejected, so route the document to your "unreadable" handling instead.If it's
true, take the bytes you got back and submit them toPOST /api/extractexactly as you would any PDF. Because the document now has a text layer, thetext_layer_requiredcheck passes and extraction runs.
Errors
| HTTP | Body | Cause | Fix |
|---|---|---|---|
| 401 | { "error": "Authentication required" } | Missing/invalid bearer token (and no admin session). | Check the Authorization header, or mint a new key in Manage Extraction API Keys. |
| 400 | { "error": "PDF file required" } | No file part, or the upload wasn't application/pdf. | Send a single PDF as the file multipart part. |
| 422 | { "error": { "type": "pdf_password_protected", "status": 422, "retryable": false, "message": "..." } } | The PDF requires a user password to open, and we don't have it. (PDFs with permissions-only restrictions — copy/print locked but opening normally — are unlocked automatically and OCR normally.) | Open the file with its password, save an unlocked copy (e.g. Print to PDF), and resubmit that. Retrying the locked file returns the same 422. |
| 503 | { "error": { "type": "ocr_error", "status": 503, "message": "...at capacity..." } } | Every OCR worker is busy. The service fast-fails in ~5s rather than holding your connection. | Retry with backoff — this is the expected signal during a burst, not a failure. See Handling overload below. |
| 504 | { "error": { "type": "ocr_error", "status": 504, "message": "...timed out..." } } | The OCR exceeded the service's 220-second runtime cap — a very large or complex scan. The job is killed server-side, so the worker is freed immediately. | Retry once. If it persists, the document needs more OCR time than a synchronous request allows — resubmit it via POST /api/ocr-async, which runs the same OCR with a 15-minute budget. |
| 500 | { "error": "<message>" } | Unexpected failure. A message mentioning signal 15 / killed means a worker was recycled mid-job (retryable); any other message is a genuine document failure. | Retry once only if the message looks like a worker recycle; otherwise report it — retrying a real failure just re-burns the work. |
Handling overload: the retry strategy
The OCR service runs one job per worker and scales workers out on demand up to a
ceiling. When every worker is busy it returns 503 within ~5 seconds instead of
holding your connection — a deliberate fast-fail so that your retry logic, not a
parked HTTP request, owns the waiting. New workers take roughly 30–70 seconds to come
online during a burst, so a well-behaved client rides that window out with backoff and
never surfaces a 503 to the end user.
Because OCR is idempotent (same PDF in → same PDF out, nothing persisted), retrying is always safe — no idempotency key needed.
Recommended policy — exponential backoff with full jitter:
| Parameter | Value |
|---|---|
| Retry on | 503, 502, a 500 that mentions signal 15/killed, and one 504 |
| Never retry | 400, 401, 413, 422, and any other 500 |
| Max attempts | 5 (1 initial + 4 retries) |
| Backoff | base 4s, double each time (4s → 8s → 16s → 32s), capped at 30s |
| Jitter | full — wait a random duration between 0 and the computed delay |
| Capacity deadline | stop retrying 503/502 after ~120s total — beyond that it's real overload, not scale-out |
| Per-request timeout | ~260s — the service kills any job at 220s and answers 504, so there's nothing to wait for beyond that plus transfer time |
Notes
- Cost: OCR is a non-LLM operation and is not tracked in Explore Costs, which reports LLM token spend only.
- Admin uploads: Ground Truth Documents you upload through the admin are OCRd automatically on save — you don't call this endpoint for those. See Upload Ground Truth Documents. This endpoint is for your own application's runtime documents.
Related
- OCR Large Documents with Async Jobs — the async variant for scans that outgrow the 220s runtime cap.
- Call the Extract API — the extraction step that consumes the OCRd PDF.
- Manage Extraction API Keys — the bearer token, shared with
/api/extract. - Build a Request Schema — where Requires Text vs Requires Vision is set.
- Upload Ground Truth Documents — admin uploads, which OCR automatically.
Call the Embed API
Turn batches of text into vector embeddings through the gateway with POST /api/embed — same key, error envelope, and cost reporting as the Extract API.
OCR Large Documents with Async Jobs
Submit big scans to /api/ocr-async, poll a job id, and download an OCRd PDF that would time out the synchronous OCR call.