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
file✓file 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.
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:

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.

On this page