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.
The synchronous POST /api/ocr endpoint kills any OCR run at 220 seconds — it has to, because the platform's ingress cuts the HTTP connection at ~240s and a job that outlives its connection produces a result nobody can receive. Most scans finish well inside that. But a large scan — hundreds of pages, high DPI, complex layouts — can legitimately need more OCR time than a single HTTP request is allowed to live, and on the sync endpoint it will 504 every single time.
POST /api/ocr-async is the same OCR without the connection. You submit the PDF, get back a job id immediately, and poll until the job lands. The OCR runs server-side with a 15-minute budget per attempt — roughly 4× the sync ceiling — and the result waits in storage for you to download when it's done.
Sync or async?
/api/ocr (sync) | /api/ocr-async | |
|---|---|---|
| Best for | Scans that OCR in under ~3 minutes — most documents. | Very large or complex scans, batch pipelines, anything that has ever hit the sync 504. |
| Latency | One round trip. | Submit + poll + download — a few seconds of overhead on top of the OCR itself. |
| Time budget | Hard 220s runtime cap. | Up to 15 minutes of OCR time per attempt. |
| Overload (503) | You retry with backoff. | The queue absorbs the burst; capacity waits happen server-side. |
| Result delivery | Response body is the PDF. | Separate download from result_url once succeeded. |
A reasonable strategy: call the sync endpoint by default, and route a document to async when it's big, or after a sync attempt dies with a 504.
What you'll need
Just a bearer token — the same Extraction API Key the other public endpoints use. Note that unlike sync /api/ocr, the async endpoint does not accept an admin session: jobs are tenant-scoped, and the token's tenant is what the poll and download endpoints authorize against.
Submit: POST /api/ocr-async
| Method | POST |
| URL | https://evals.thelibrari.com/api/ocr-async (or your own host) |
| Auth | Authorization: Bearer <extraction-api-key> |
| Body | multipart/form-data — a single file part, application/pdf |
Bad submissions fail synchronously: a missing or non-PDF file part returns 400 PDF file required, an empty file returns 400 Empty file, and a file the upload layer rejects outright returns a non-retryable 422 invalid_document. A job is only created for a request that could plausibly succeed.
On acceptance you get 202 Accepted:
{
"success": true,
"job_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"status": "queued",
"status_url": "/api/ocr-jobs/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"suggested_poll_secs": 5,
"expires_at": "2026-08-22T00:21:00.000Z"
}The Location header carries the same status_url. The uploaded document is stored server-side for the job's lifetime and deleted when the job expires (expires_at, 48 hours after submission).
Poll: GET /api/ocr-jobs/:id
Same bearer auth. Polling is a pure read — it never affects the job, so poll as often as you like (every suggested_poll_secs is plenty; the response also sets a Retry-After header while the job is in flight). Jobs are tenant-scoped: another tenant's key gets a 404, not your job.
The response is always 200 OK with a status field — branch on it:
status | Meaning | Extra fields |
|---|---|---|
queued | Accepted, waiting for a worker. Pickup takes a few seconds while a worker is warm; if the worker pool has scaled to zero, allow a minute or two for one to spin up. | suggested_poll_secs |
running | The OCR is in progress. Very large scans can stay here for many minutes. | suggested_poll_secs |
succeeded | Done. Terminal. | result_url, has_text_layer, text_chars |
failed | Done, unsuccessfully. Terminal. | error — see below |
Every response also carries job_id, attempts, created_at, completed_at (null until terminal), and expires_at.
Succeeded:
{
"job_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"status": "succeeded",
"attempts": 1,
"created_at": "2026-08-20T00:21:00.000Z",
"completed_at": "2026-08-20T00:29:47.000Z",
"expires_at": "2026-08-22T00:21:00.000Z",
"result_url": "/api/ocr-jobs/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d/result",
"has_text_layer": true,
"text_chars": 51842
}has_text_layer and text_chars are the same advisory probe the sync endpoint reports in its X-Ocr-Has-Text-Layer / X-Ocr-Text-Chars headers: a scan of photographs OCRs "successfully" but yields no readable text, and it's better to learn that here than from a text_layer_required 422 on the extract call you make next.
Error types
The failed-state error object is { type, retryable, message }. Branch on retryable first, type second:
type | retryable | Meaning |
|---|---|---|
ocr_timeout | false | The OCR exceeded its 15-minute budget. The same document gets the same budget on resubmission — don't retry; split the document instead. |
ocr_error | false | OCR itself failed on this document (corrupt file, unsupported construction). Deterministic for the same bytes. Interrupted runs (a worker recycled mid-OCR) are not reported here — those are retried server-side automatically. |
ocr_unavailable | true | The OCR service couldn't be reached after server-side retries. Resubmit with backoff. |
job_timeout | true | The worker died mid-job or the job was never picked up within an hour. Resubmit; if it recurs, something is wrong on our end. |
pdf_password_protected | false | The PDF requires a user password to open, and we don't have it. Open the file with its password, save an unlocked copy (e.g. Print to PDF), and resubmit that. (PDFs with permissions-only restrictions — copy/print locked but opening normally — are unlocked automatically and never get this error.) |
Download: GET /api/ocr-jobs/:id/result
Same bearer auth, same tenant scoping. Once the poll reports succeeded, this streams the OCRd document as a binary application/pdf body — Content-Disposition echoes the uploaded filename. Add ?text_only=true to get the extracted plain-text layer (text/plain) instead, mirroring the sync endpoint's return_text_only field.
Calling it early returns 409 with the job's current status; after the retention window (or if the stored result has been removed) it returns 410 — resubmit the document if you still need it.
Walkthrough
1. Submit the job
curl -X POST https://evals.thelibrari.com/api/ocr-async \ -H "Authorization: Bearer $LIBRARI_EXTRACT_KEY" \ -F "file=@/path/to/huge-scan.pdf"A
202means the job is queued. A400/422is your request — same fixes as the sync endpoint.2. Poll until terminal, then download
TypeScript / Node 20+:
async function ocrAsync(jobId: string): Promise<Buffer> { const base = "https://evals.thelibrari.com/api/ocr-jobs/" + jobId const headers = { Authorization: `Bearer ${process.env.LIBRARI_EXTRACT_KEY!}` } for (;;) { const res = await fetch(base, { headers }) if (res.status === 404) throw new Error("Job unknown or expired") const body = await res.json() if (body.status === "succeeded") { const pdf = await fetch(base + "/result", { headers }) return Buffer.from(await pdf.arrayBuffer()) } if (body.status === "failed") { const err = new Error(`OCR failed [${body.error.type}]: ${body.error.message}`) ;(err as any).retryable = body.error.retryable throw err } // queued or running — wait and go again await new Promise((r) => setTimeout(r, (body.suggested_poll_secs ?? 5) * 1000)) } }Give the poll loop a generous deadline — a worst-case job can spend most of an hour queued behind a deep backlog plus its own 15-minute attempt before the server declares an outcome. If you need a tighter bound, stop polling early; the job finishes (and stays downloadable for 48h) regardless.
3. Feed the result to extraction
Check
has_text_layerfirst: if it'sfalse, the scan produced no readable text and a Requires Text extraction will still reject it. Otherwise the downloaded PDF goes straight intoPOST /api/extract— orPOST /api/extract-async, since a document big enough to need async OCR is usually big enough to need async extraction too.
Related
- Apply OCR to a Document — the synchronous endpoint, response headers, and the overload/retry playbook.
- Extract Large Documents with Async Jobs — the same submit-and-poll pattern for the extraction step.
- Call the Extract API — where the OCRd PDF ultimately goes.
- Manage Extraction API Keys — the bearer token all of these share.
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.
Connect Claude via MCP
Plug Claude into Librari Evals over MCP — browse schemas, edit prompts, run benchmarks, and extract documents from a chat, with your own personal key.