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.
You've built a Request Schema Version, benchmarked it with manual and scheduled runs, and picked a model deployment you trust. Now you want your application to actually use it. That's POST /api/extract — bearer-authenticated, multipart, returns structured JSON.
The same Schema Version your benchmarks ran against is the one the API uses. What you measured is what production gets — no separate "production schema" to drift.
What you'll need
- A published Extraction API Key (the full key, copied at creation — only the last 4 characters are recoverable afterwards).
- The UUID of a published Request Schema Version. Open the version in the admin; the ID is in the URL after
/request-schema-versions/. - The UUID of a Model Deployment. Make sure a tenant LLM API key is assigned to it, or the call returns 400.
- A document to extract from — PDF is the supported path; other formats work to the extent the model deployment accepts them.
The endpoint at a glance
| Method | POST |
| URL | https://evals.thelibrari.com/api/extract (or your own host) |
| Auth | Authorization: Bearer <extraction-api-key> |
| Body | multipart/form-data |
Request fields
| Field | Required | Type | Notes |
|---|---|---|---|
file | ✓ | file part | The document to extract from. PDF is the primary supported type. |
requestSchemaVersionId | ✓ | string (UUID) | A Request Schema Version belonging to your tenant. Published is the typical pick; Draft works too if you're testing. |
modelDeploymentId | ✓ | string (UUID) | A deployment you can see in Supported LLM Models. Must have a tenant LLM API key assigned. |
Response shape (200 OK)
{
"success": true,
"data": {
"effective_date": "2024-03-01",
"renewal_term_months": 12,
"is_contract": true
},
"usage": {
"inputTokens": 4821,
"outputTokens": 312,
"totalTokens": 5133,
"cachedInputTokens": 0
},
"cost": {
"inputCost": 0.01446,
"outputCost": 0.00468,
"totalCost": 0.01914,
"inputPricePerMillion": 3.0,
"outputPricePerMillion": 15.0
},
"durationMs": 4218
}data is the structured extraction keyed by the field names you defined on the schema version. usage, cost, and durationMs are diagnostic — useful for client-side logging, surface metrics, or showing your users what an extraction cost and how long it took.
The cost object
cost breaks the spend for the call into its parts, all in US dollars, computed from the token counts in usage and the deployment's configured per-million-token prices.
| Field | Type | Notes |
|---|---|---|
inputCost | number | Dollar cost of the input (prompt) tokens. |
outputCost | number | Dollar cost of the output (completion) tokens. |
totalCost | number | inputCost + outputCost — the per-call spend. |
inputPricePerMillion | number | null | The deployment's input price per million tokens used for the calculation. null if the deployment has no input price set. |
outputPricePerMillion | number | null | The deployment's output price per million tokens. null if unset. |
Walkthrough
1. Mint and stash a bearer token
Create an Extraction API Key — see Manage Extraction API Keys. Copy the full key the moment it's shown; after the create screen, only the last 4 characters are stored.
Stash it in your application's secret manager (env var, AWS Secrets Manager, Vault — whatever you already use). Never check it into source.
2. Grab the Schema Version and Model Deployment IDs
Open the Request Schema Version you want production to use. The UUID is the last segment of the URL, after
/admin/collections/request-schema-versions/.For the model deployment, open Browse Supported LLM Models. The deployment ID isn't shown directly in the table — open the underlying record from Model Deployments (Models → Model Deployments in the sidebar) to copy it, or pull it from a
/api/model-deploymentsquery if you're scripting setup.3. POST multipart/form-data
curl:curl -X POST https://evals.thelibrari.com/api/extract \ -H "Authorization: Bearer $LIBRARI_EXTRACT_KEY" \ -F "file=@/path/to/contract.pdf" \ -F "requestSchemaVersionId=20448b1b-87d1-4e99-b521-80360b676035" \ -F "modelDeploymentId=9f7e3c4d-b8a2-4e1f-8c6d-2a5b9c4d3e7f"Python (
requests):import os, requests with open("/path/to/contract.pdf", "rb") as f: resp = requests.post( "https://evals.thelibrari.com/api/extract", headers={"Authorization": f"Bearer {os.environ['LIBRARI_EXTRACT_KEY']}"}, files={"file": ("contract.pdf", f, "application/pdf")}, data={ "requestSchemaVersionId": "20448b1b-87d1-4e99-b521-80360b676035", "modelDeploymentId": "9f7e3c4d-b8a2-4e1f-8c6d-2a5b9c4d3e7f", }, timeout=120, ) resp.raise_for_status() extraction = resp.json()["data"]TypeScript / Node 20+ (
fetch+FormData):import { readFile } from "node:fs/promises" const fileBuf = await readFile("/path/to/contract.pdf") const form = new FormData() form.append("file", new Blob([fileBuf], { type: "application/pdf" }), "contract.pdf") form.append("requestSchemaVersionId", "20448b1b-87d1-4e99-b521-80360b676035") form.append("modelDeploymentId", "9f7e3c4d-b8a2-4e1f-8c6d-2a5b9c4d3e7f") const res = await fetch("https://evals.thelibrari.com/api/extract", { method: "POST", headers: { Authorization: `Bearer ${process.env.LIBRARI_EXTRACT_KEY!}` }, body: form, }) if (!res.ok) throw new Error(`Extract failed: ${res.status} ${await res.text()}`) const { data } = await res.json() as { data: Record<string, unknown> }4. Read the response
Happy path:
{ "success": true, "data": { "...": "..." }, "usage": { "inputTokens": 4821, "outputTokens": 312, "totalTokens": 5133, "cachedInputTokens": 0 }, "cost": { "inputCost": 0.01446, "outputCost": 0.00468, "totalCost": 0.01914, "inputPricePerMillion": 3.0, "outputPricePerMillion": 15.0 }, "durationMs": 4218 }datais the only field most production code touches.cost.totalCostis per-call spend in US dollars — handy if you want to surface cost-per-extraction to your end users, or shed load when a customer is over quota. (If the deployment has no pricing configured,totalCostis0and the…PricePerMillionfields arenull— check the price, not the cost, to detect that.)
Text-layer requirement
OCR and extraction are two separate calls. /api/extract never runs OCR — it only extracts. OCR (flattening a scan into a PDF with a selectable text layer) is its own endpoint: Apply OCR to a Document (POST /api/ocr).
If the Request Schema Version has Requires Text set and you upload a PDF, /api/extract first probes the PDF for a text layer:
- Has a text layer (born-digital PDF, or already OCRd) → extraction proceeds normally.
- No text layer (a raw scan), or the probe can't confirm one → the call is rejected with
text_layer_required(HTTP 422) before the model is ever called. The model is never sent a scan it can't read.
When you get a text_layer_required back, the fix is a two-step flow:
POST /api/ocrwith the scan → you get back the same PDF with a text layer applied.POST /api/extractwith the OCRd PDF → extraction proceeds.
One caveat: OCR can succeed and still find no text — a photo, a blank page, an illegible scan. The /api/ocr response tells you which case you're in via its X-Ocr-Has-Text-Layer header (it runs this same probe). If that header says false, resubmitting to /api/extract will just produce this 422 again — treat the document as unreadable instead.
This split lets you OCR once and reuse the flattened PDF across many extractions, and keeps OCR-service slowness or outages from being charged against — or confused with — your extraction calls. Schema versions that use Requires Vision (the model reads the page images directly) don't need a text layer and skip this check entirely.
How limit rejections are reported
"This document is too big" can be caught in two different places, and which one fires decides the shape you get back. Understanding the split is the key to handling size/page/format rejections cleanly.
Layer 1 — pre-flight, before the model is ever called. The API probes your file (page count, byte size, image dimensions and format) and checks it against the limits declared on the chosen Model Deployment. Any violation comes back as a single precheck_failed 400 that lists every failed limit at once. No provider round-trip, no token spend, fully deterministic. This only covers limits you've actually set on the deployment — a null limit is skipped.
Layer 2 — the provider's own rejection, echoed back. If a limit wasn't declared on the deployment (or can't be known until the model tokenises the input, like the context window), the document goes to the provider and the provider rejects it. The API catches that raw vendor error, parses it, and re-emits it in the same structured shape — translating each provider's idiosyncratic wording into a stable type (page_cap_exceeded, payload_too_large, context_overflow, images_per_request_exceeded) with typed numeric fields like pagesUsed / tokensLimit.
The practical consequence: the same physical limit can surface under two different type values. A PDF that's over the page cap returns precheck_failed if you set maxPages on the deployment, or page_cap_exceeded if you didn't and the provider caught it. Tell them apart by shape — precheck_failed carries a failures[] array and may list several problems together; the provider-echoed types are single-issue with their own numeric fields. Both are documented below.
Errors
The endpoint returns two different error shapes depending on where the failure happened.
Setup / auth errors — flat string
Returned for problems the API can detect before it ever calls the LLM provider: missing bearer token, missing required fields, wrong UUID, no provider key wired up. The body is { "error": "<message>" }.
| HTTP | error body | Cause | Fix |
|---|---|---|---|
| 401 | Unauthorized | Missing/invalid bearer token, or the token doesn't match any active API key. | Check the Authorization header. If you can't find the full key value, mint a new one in Manage Extraction API Keys and roll your app. |
| 400 | Missing required fields: requestSchemaVersionId, modelDeploymentId, file | One of the three required multipart parts is absent. Most often: sent as JSON instead of multipart, or file was attached as pdf / document / some other name. | Use multipart/form-data. Field names are case-sensitive. |
| 400 | No tenant LLM API key is configured for model deployment <id>... | Your tenant doesn't have an LLM provider key wired up to this deployment. | In the admin, open the right Tenant LLM API Key and add the deployment to its modelDeployments relation. |
| 404 | Request schema version not found for this tenant | Wrong UUID, or the version belongs to a different tenant than your API key. | Re-check the UUID from the admin URL. Don't try to call against another tenant's schemas. |
| 404 | Model deployment not found | Wrong UUID. | Re-check from Supported LLM Models or the Model Deployments admin list. |
Provider / extraction errors — structured object
Once the request reaches the LLM provider, any failure (rate limit, context overflow, bad credentials, timeout, …) comes back as a structured object so your client can branch on it without parsing prose:
{
"error": {
"type": "context_overflow",
"status": 400,
"retryable": false,
"message": "Anthropic request failed (model=claude-haiku-4-5-20251001): 400 ... prompt is too long: 204004 tokens > 200000 maximum",
"provider": "Anthropic",
"providerStatus": 400,
"tokensUsed": 204004,
"tokensLimit": 200000
}
}The HTTP status matches error.status. Fields:
| Field | Always present | Notes |
|---|---|---|
type | ✓ | Stable identifier (see table below). Branch on this, not on the human message. |
status | ✓ | HTTP status code; equals the response's status. |
retryable | ✓ | true if retrying the same request with backoff might succeed. |
message | ✓ | The raw underlying provider message, truncated to 1000 chars. Useful for logs; don't show verbatim to end users. |
provider | optional | Inferred provider name when the wrapper made it identifiable: Anthropic, OpenAI, AWS, Google, xAI. Azure OpenAI errors don't carry a prefix and won't have this set. |
providerStatus | optional | The upstream provider's own HTTP status, when we could parse it out. |
tokensUsed / tokensLimit | optional | Set on context_overflow when the provider reported both. |
pagesUsed / pagesLimit | optional | Set on page_cap_exceeded (PDF page-count cap). |
imagesUsed / imagesLimit | optional | Set on images_per_request_exceeded (Azure OpenAI image-block cap). |
Pre-flight limit validation (type: "precheck_failed")
Before the document is sent to the LLM, the API checks it against every limit declared on the chosen Model Deployment. If anything is out of bounds, you get a precheck_failed response with every failed limit listed in one shot — no need to fix-and-retry one at a time:
{
"error": {
"type": "precheck_failed",
"status": 400,
"retryable": false,
"message": "Document violates 2 model-deployment limit(s); see failures for details.",
"failures": [
{
"limit": "maxPages",
"observed": 250,
"allowed": 100,
"unit": "pages",
"message": "PDF has 250 pages; this deployment caps PDF input at 100 pages per request."
},
{
"limit": "maxRequestPayloadMb",
"observed": 38.4,
"allowed": 32,
"unit": "MB",
"message": "Uploaded file is 38.4 MB; this deployment caps a single request payload at 32 MB."
}
]
}
}Each entry in failures has the same shape:
| Field | Notes |
|---|---|
limit | The deployment field path. E.g., maxPages, maxRequestPayloadMb, imageLimits.supportedImageFormats. Stable — use this to branch in code. |
observed | What your document actually presented. Number for sizes/dimensions, string for formats. |
allowed | What the deployment allows. Number for caps, array of strings for enum-style limits (supported formats). |
unit | Human display unit: pages, MB, px, format. |
message | Plain-English summary suitable to show end users or log. |
Limits enforced before the LLM call:
| Deployment limit | Applies to | Notes |
|---|---|---|
maxPages | PDFs | Page count from a fast probe of the uploaded PDF. |
maxRequestPayloadMb | All | Raw file size — the cap on the whole request body. |
maxCombinedDocumentsMb | All | Same as file size for this single-doc endpoint. |
imageLimits.supportedImageFormats | Images | Rejects any MIME the deployment didn't list. |
imageLimits.maxPerImageMb | Images | File size for an image upload. |
imageLimits.maxImageDimensionPx | Images | The longer side of width/height — read from the image header. |
Not pre-flight-checked (relies on the provider's own error to surface): contextWindowTokens, maxTokens, and the multi-doc variants of maxImagesPerRequest / maxDocumentsPerRequest (this endpoint only takes one file). If the document slips past pre-flight but blows the model's context window, you'll get the normal context_overflow error instead.
If the deployment has a limit set to null, that limit is skipped — pre-flight only enforces what the catalog explicitly declares.
Error types
type | HTTP | retryable | What it means | Typical fix |
|---|---|---|---|---|
extract_at_capacity | 503 | yes | This replica is already handling its maximum number of concurrent extractions and fast-failed rather than queue yours. Fires only during a burst, before scale-out catches up. | Back off and retry with jitter — see Rate limits and concurrency. It clears within seconds once a replica frees up or a new one starts. |
rate_limit | 429 | yes | Provider throttled this tenant/key. | Back off and retry; consider a smaller batch size or a different deployment. |
context_overflow | 400 | no | Document + prompt exceeded the model's context window. tokensUsed / tokensLimit tell you by how much. | Switch to a larger-context deployment, trim the document, or split the extraction. |
payload_too_large | 413 | no | Request body or attached file exceeded the transport cap. | Compress or split the PDF before resending. |
page_cap_exceeded | 400 | no | The model has a per-request PDF page cap (e.g., Anthropic 100 pages, Vertex 1000). | Split the PDF into chunks, or pick a deployment with a higher cap. |
images_per_request_exceeded | 400 | no | Azure OpenAI image-block cap (the model rasterises PDF pages to images). | Reduce page count or use a deployment that accepts the document differently. |
invalid_request | 400 | no | A provider 4xx we couldn't classify further. | Read message — usually a malformed schema, unsupported MIME type, or content-policy block. |
auth | 502 | no | Provider rejected our credentials — the key was revoked, rotated, lacks permission, or points at the wrong endpoint. | Update the Tenant LLM API Key. Reported as 502 because the caller's request was fine; the cloud config is broken. |
no_structured_output | 422 | no | The model answered but returned nothing matching your schema — typically a refusal, a content-policy block, or a truncated/empty response. (A field the model simply couldn't find is returned as null, not an error.) | Usually a content-policy refusal or a model poorly suited to the document. Read message; try a different Model Deployment. |
document_render_failed | 422 | no | The PDF reached the service but a page could not be rasterized for a vision model (corrupt or unreadable page). The model is never called. | The document is malformed for image rendering. Re-export / repair the PDF, or run it through POST /api/ocr to reflatten it, then resubmit. |
text_layer_required | 422 | no | The schema has Requires Text but the uploaded PDF has no extractable text layer (a raw scan), or the text-layer probe couldn't confirm one. The model is never called. See the note below: this shape omits provider. | OCR the document first via POST /api/ocr, then resubmit the OCRd PDF. A born-digital PDF that already has a text layer passes straight through. |
model_unavailable | 502 | no | Model isn't enabled in the upstream account, or has been deprecated. | Pick a different Model Deployment. |
insufficient_credits | 502 | no | Provider's billing/quota check failed. | Top up the upstream account. |
timeout | 504 | yes | Upstream took too long to respond. | Retry with backoff; consider a faster deployment for large documents. |
upstream_unavailable | 502 | yes | Provider returned 5xx or the transport failed. | Retry with backoff. |
precheck_failed | 400 | no | The document violated one or more limits declared on the chosen Model Deployment — caught before the LLM was called. | Read the failures array; each entry names the violated limit, the observed value, and what the deployment allows. Fix the document (split a long PDF, downscale an image, etc.) or pick a deployment with looser limits. |
unknown | 500 | no | The error didn't match any known pattern. | message has the raw text; please report it so we can add a matcher. |
A minimal client-side retry rule — branch on error.retryable, never on the prose message:
const res = await fetch(extractUrl, { method: "POST", headers, body: form })
if (res.ok) return res.json()
const { error } = await res.json() as { error: { type: string; retryable: boolean; message: string } }
if (error.retryable) {
// back off (exponential WITH FULL JITTER), then retry the same request
} else {
// surface error.type to your monitoring; do NOT auto-retry
}The retryable types are extract_at_capacity (503), rate_limit (429), timeout (504), and upstream_unavailable (502). Everything else is terminal — retrying sends the same document into the same wall.
Versioning, rollouts, and rollbacks
The Schema Version UUID is what pins your extraction to a specific (fields × base prompt × per-field prompts) tuple. When you want to ship a change:
- Build the new Schema Version under the same Schema Type. The version number auto-increments —
v2, thenv3. - Benchmark
v2side-by-side withv1using Run a Manual Test against the same Test Set. - When
v2clears your bar, change therequestSchemaVersionIdyour app sends. Oldv1calls (in-flight when you flip the config) still extract againstv1— there's no hard cutover. - Rollback is the same flip in reverse.
v1stays Published indefinitely.
Same applies to model swaps via modelDeploymentId. You don't need to redeploy your application to switch models — that's the whole point of keeping the IDs as configuration.
Rate limits and concurrency
The service runs on an autoscaling pool of replicas, each of which accepts a bounded number of concurrent extractions. Two distinct limits can throttle you:
- Service concurrency (
extract_at_capacity, 503). When a replica is already at its in-flight ceiling, it fast-fails immediately with a retryable503rather than queueing your request and risking a memory blowout. The pool scales out under load, but a new replica takes roughly 30–70 seconds to come online — so a simultaneous burst can see a few 503s during that window. They clear on their own; the fix is client-side retry. - Provider rate limits (
rate_limit, 429). Beyond the service, your own BYOK provider key has its own throttle, surfaced as a retryable429.
Both are retryable and both want the same handling: exponential backoff with full jitter.
Recommended policy (identical to the OCR client — see Apply OCR to a Document):
| Parameter | Value |
|---|---|
| Retry on | extract_at_capacity (503), rate_limit (429), timeout (504), upstream_unavailable (502), and transport errors |
| Max attempts | 5 (1 initial + 4 retries) |
| Backoff | base 4s, ×2 (4 → 8 → 16 → 32s), capped at 30s, full jitter |
| Retry deadline | ~120s for transient errors |
| Per-request timeout | ~150s (an extraction can run a couple of minutes) |
Related
- Apply OCR to a Document — the separate
POST /api/ocrstep for scans, before extraction. - Manage Extraction API Keys — minting the bearer token.
- Manage LLM Provider API Keys — wiring up the provider credential the deployment needs.
- Build a Request Schema — defining the shape of
data. - Browse Supported LLM Models — picking a deployment.
- Explore Costs — see what every call cost, broken out by operation type.
- Export a Schema as LangChain JSON — when you'd rather run extraction in your own LangChain pipeline and skip this endpoint entirely.
Manage Extraction API Keys
Create and revoke bearer tokens for the public /api/extract endpoint your applications call to get structured data out of documents.
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.