Extract Large Documents with Async Jobs
Submit long-running extractions to /api/extract-async, poll a job id, and collect results that would time out a synchronous call.
A synchronous POST /api/extract call has a hard ceiling: the platform's ingress cuts any HTTP request at ~240 seconds, no matter what the server is doing. Most extractions finish in seconds and never notice. But a large document on a vision-path schema — think a 100+ page PDF where the model reads every page image — can legitimately need more model time than a single HTTP request is allowed to live. Retrying doesn't help; the same wall is waiting every time.
POST /api/extract-async is the same extraction without the ceiling. You submit the same fields, get back a job id immediately, and poll until the job lands on a result. The extraction runs server-side on a queue with its own retry handling, so a connection reset halfway through a five-minute model call becomes the server's problem, not yours.
Sync or async?
/api/extract (sync) | /api/extract-async | |
|---|---|---|
| Best for | Documents that extract in seconds to ~3 minutes — the vast majority. | Large documents, vision-heavy schemas, batch pipelines that don't need an answer in-request. |
| Latency | One round trip. | Submit + poll — a few seconds of overhead on top of the extraction itself. |
| Time budget | Hard ~240s ceiling (gateway). | Up to 15 minutes of extraction time per attempt, retried on transient provider failures. |
| Transient provider errors | Surfaced to you as retryable errors — you resubmit. | Retried server-side (up to 3 attempts with backoff) before the job is marked failed. |
A reasonable integration strategy: call the sync endpoint by default, and route documents to async when they're big (page count is a good heuristic), or after a sync attempt dies with a gateway 504.
What you'll need
Exactly what the sync endpoint needs — same bearer token, same Request Schema Version UUID, same Model Deployment UUID with a tenant LLM API key assigned. If you already call /api/extract, you have everything.
Submit: POST /api/extract-async
| Method | POST |
| URL | https://evals.thelibrari.com/api/extract-async (or your own host) |
| Auth | Authorization: Bearer <extraction-api-key> |
| Body | multipart/form-data — same fields as /api/extract: file, requestSchemaVersionId, modelDeploymentId, and the optional enumOverrides. Overrides are validated at submit time (a bad one is a synchronous 400, no job is created) and stored with the job, so the worker applies exactly what you sent. |
Every deterministic rejection still happens synchronously at submit time, with the same status codes and bodies as the sync endpoint: bad or cross-tenant UUIDs (404), missing fields (400), an embedding deployment instead of a chat deployment (400 deployment_modality_mismatch), no tenant LLM key on the deployment (400), text-layer requirement (422), and deployment-limit prechecks (400). A job is only created for a request that could plausibly succeed — you'll never poll your way to a "you forgot the file" error.
On acceptance you get 202 Accepted:
{
"success": true,
"job_id": "58c6ebca-7546-45b6-957c-0ecbdf91a9a5",
"status": "queued",
"status_url": "/api/extract-jobs/58c6ebca-7546-45b6-957c-0ecbdf91a9a5",
"suggested_poll_secs": 5,
"expires_at": "2026-08-09T00: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/extract-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 | A worker is extracting. Large vision documents can stay here for many minutes. | suggested_poll_secs |
succeeded | Done. Terminal. | result — same { data, usage, cost, durationMs } shape as the sync 200 body |
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": "58c6ebca-7546-45b6-957c-0ecbdf91a9a5",
"status": "succeeded",
"attempts": 1,
"created_at": "2026-08-07T00:21:00.000Z",
"completed_at": "2026-08-07T00:26:12.000Z",
"expires_at": "2026-08-09T00:21:00.000Z",
"result": {
"data": { "effective_date": "2024-03-01" },
"usage": { "inputTokens": 48210, "outputTokens": 812, "totalTokens": 49022, "cachedInputTokens": 0 },
"cost": { "inputCost": 0.14463, "outputCost": 0.01218, "totalCost": 0.15681, "inputPricePerMillion": 3.0, "outputPricePerMillion": 15.0 },
"durationMs": 291384
}
}Failed:
{
"job_id": "58c6ebca-7546-45b6-957c-0ecbdf91a9a5",
"status": "failed",
"attempts": 3,
"created_at": "2026-08-07T00:21:00.000Z",
"completed_at": "2026-08-07T00:29:41.000Z",
"expires_at": "2026-08-09T00:21:00.000Z",
"error": {
"type": "upstream_unavailable",
"retryable": true,
"message": "Google Vertex AI request failed (...): request to ... failed, reason: read ECONNRESET"
}
}job_timeout — the async-only error
job_timeout means the job didn't reach a normal conclusion within its time budget. It covers three distinct situations, and retryable tells you which side of the line you're on:
- The extraction genuinely exceeded its per-attempt budget (15 minutes of model time) —
retryable: false. A document that needs more than that will need it again; resubmitting won't change the outcome. - The worker processing the job died mid-run (replica restart, out-of-memory kill) —
retryable: true. Resubmit; a healthy worker will very likely finish it. - The job was never picked up within an hour of submission —
retryable: true. This indicates a service-side problem; resubmit, and if it recurs, something is wrong on our end.
Job expiry
A job row — result included — lives for 48 hours after submission, then it and its stored document are purged. Polling a purged (or never-existent, or another tenant's) job id returns 404 with Extraction job not found (unknown id, different tenant, or past its retention window). Collect your results within the window; the extraction itself is not re-runnable from an expired job.
Walkthrough
1. Submit the job
Identical to the sync call except for the path:
curl -X POST https://evals.thelibrari.com/api/extract-async \ -H "Authorization: Bearer $LIBRARI_EXTRACT_KEY" \ -F "file=@/path/to/very-long-contract.pdf" \ -F "requestSchemaVersionId=20448b1b-87d1-4e99-b521-80360b676035" \ -F "modelDeploymentId=9f7e3c4d-b8a2-4e1f-8c6d-2a5b9c4d3e7f"A
202means the document passed every precheck and is queued. Anything else is the same synchronous error you'd get from/api/extract— handle it the same way.2. Poll until terminal
TypeScript / Node 20+:
async function pollExtractJob(jobId: string): Promise<Record<string, unknown>> { const url = `https://evals.thelibrari.com/api/extract-jobs/${jobId}` const headers = { Authorization: `Bearer ${process.env.LIBRARI_EXTRACT_KEY!}` } for (;;) { const res = await fetch(url, { headers }) if (res.status === 404) throw new Error("Job unknown or expired") const body = await res.json() if (body.status === "succeeded") return body.result.data if (body.status === "failed") { const err = new Error(`Extraction 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 whole poll loop a generous deadline on your side — a worst-case job (15-minute attempt, retried) can take ~45 minutes before the server declares it failed. If you need a tighter bound, stop polling early; the job finishes (and stays collectable for 48h) regardless.
3. Handle a failed job
Branch on
error.retryable, exactly like the sync endpoint:retryable: true— resubmit the document as a new job (there's no in-place retry of a failed job). The server already retried transient provider errors up to 3 attempts before giving up, so add your own backoff before resubmitting.retryable: false— terminal for this document + schema + deployment combination. Thetypetells you what to change: a different deployment, a smaller document, an OCR pass first.
Related
- Call the Extract API — the synchronous endpoint, shared request shape, and the full error taxonomy.
- Apply OCR to a Document — scans still need their text layer before a text-path extraction, async or not.
- OCR Large Documents with Async Jobs — the same submit-and-poll pattern for the OCR step, when the scan itself is the thing that times out.
- Manage Extraction API Keys — the bearer token both endpoints use.
- Browse Supported LLM Models — vision-capable deployments are the usual reason a document needs async in the first place.
- Explore Costs — per-attempt spend for async jobs.
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.
Call the Embed API
Turn batches of text into vector embeddings through the gateway with POST /api/embed — same key, error envelope, and cost reporting as the Extract API.