LibrariEval System
LeaderboardDocsPricingSign UpLogin
Librari Evals — Docs

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 forScans that OCR in under ~3 minutes — most documents.Very large or complex scans, batch pipelines, anything that has ever hit the sync 504.
LatencyOne round trip.Submit + poll + download — a few seconds of overhead on top of the OCR itself.
Time budgetHard 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 deliveryResponse 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

MethodPOST
URLhttps://evals.thelibrari.com/api/ocr-async (or your own host)
AuthAuthorization: Bearer <extraction-api-key>
Bodymultipart/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:

statusMeaningExtra fields
queuedAccepted, 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
runningThe OCR is in progress. Very large scans can stay here for many minutes.suggested_poll_secs
succeededDone. Terminal.result_url, has_text_layer, text_chars
failedDone, 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:

typeretryableMeaning
ocr_timeoutfalseThe OCR exceeded its 15-minute budget. The same document gets the same budget on resubmission — don't retry; split the document instead.
ocr_errorfalseOCR 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_unavailabletrueThe OCR service couldn't be reached after server-side retries. Resubmit with backoff.
job_timeouttrueThe 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_protectedfalseThe 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. 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 202 means the job is queued. A 400/422 is your request — same fixes as the sync endpoint.

  2. 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. 3. Feed the result to extraction

    Check has_text_layer first: if it's false, the scan produced no readable text and a Requires Text extraction will still reject it. Otherwise the downloaded PDF goes straight into POST /api/extract — or POST /api/extract-async, since a document big enough to need async OCR is usually big enough to need async extraction too.

On this page