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. |
| 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 can't be OCRd in one pass at this size — split it. |
| 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
- 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 Extract API
Send a document to /api/extract from your application code and get back structured JSON keyed to a Request Schema Version you've already benchmarked.
Manage LLM Provider API Keys
Register your OpenAI, Anthropic, Azure, AWS, or Vertex credentials so manual and scheduled runs can call those providers on your behalf.