LibrariEval System
LeaderboardDocsPricingSign UpLogin
Librari Evals — Docs

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:

  1. POST /api/ocr with the scan → get back the same PDF with a text layer applied.
  2. POST /api/extract with 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

MethodPOST
URLhttps://evals.thelibrari.com/api/ocr (or your own host)
AuthAuthorization: Bearer <extraction-api-key>
Bodymultipart/form-data
Responseapplication/pdf — the OCRd document, as a binary body (or text/plain with return_text_only=true), plus text-layer headers

Request fields

FieldRequiredTypeNotes
filefile partThe PDF to OCR. Must be application/pdf — other content types return 400.
return_text_onlytext partWhen 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:

HeaderValueMeaning
X-Ocr-Has-Text-Layertrue / falseWhether 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-CharsintegerHow 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. 1. Mint and stash a bearer token

    /api/ocr uses 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. 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.pdf

    Python (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/extract

    TypeScript / 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/extract
  3. 3. Check the text-layer headers, then send to /api/extract

    Before you spend the extract call, glance at X-Ocr-Has-Text-Layer on the response. If it's false, 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 to POST /api/extract exactly as you would any PDF. Because the document now has a text layer, the text_layer_required check passes and extraction runs.

Errors

HTTPBodyCauseFix
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:

ParameterValue
Retry on503, 502, a 500 that mentions signal 15/killed, and one 504
Never retry400, 401, 413, 422, and any other 500
Max attempts5 (1 initial + 4 retries)
Backoffbase 4s, double each time (4s → 8s → 16s → 32s), capped at 30s
Jitterfull — wait a random duration between 0 and the computed delay
Capacity deadlinestop 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.