Extraction
Extraction reads a document that DocAI has already parsed and returns JSON shaped to a schema you define, plus a citation for each value pointing at the segment it was read from.
Before you start. Extraction needs a file that has already been parsed. Run a parse first — parsing covers the dispatch, the poll and the artifacts it writes.
There are two entry points, and they are not interchangeable. Read which one to use before you write any code.
Code
Parse first
Extraction never touches the original PDF. It runs over the parse
artifacts, so a file with no parse artifact
cannot be extracted, and the failure is a 409 before any model work happens.
The two entry points check different things and return different codes:
| Endpoint | Checks | Code |
|---|---|---|
POST /v1/files/{fileId}/extract | a parse manifest exists for the current file version | file_must_be_parsed_before_extraction |
POST /v1/extract/runs | any parse manifest exists for the file, its artifact is readable, and the segment selection is non-empty | file_must_be_parsed |
Code
The version scoping on the first row matters in practice: replacing a file's
content with PUT /v1/files/{fileId}/content
creates a new version, and that version has no parse artifact until you parse it
again. The old version's artifact does not satisfy the check. Both codes are on
the error codes page.
POST /v1/extract/runs also returns file_must_be_parsed when you pass
segment_ids that match nothing in the parse output — an empty evidence
selection is treated the same as no parse at all, rather than extracting from
nothing.
Which one to use
POST /v1/extract/runs | POST /v1/files/{fileId}/extract | |
|---|---|---|
| Runs as | a queued operation | a queued job |
| Poll at | GET /v1/operations/{operationId} | GET /v1/files/{fileId}/jobs/{jobId} |
| Schema format | DocAI field list (fields) or a saved schema_id | JSON Schema |
| Result | output inline in the poll response | an artifact you fetch separately |
| Result stored | on the operation row | processing.extraction_results plus an extract artifact manifest |
| Validation | none | JSON Schema validation, reported in warnings |
| Credits | 1 credit per run | none in v1 |
| Scoping | segment_ids selects part of the document | options.scope |
Use /v1/extract/runs when you are building an application flow: it takes
the same schema objects the schema endpoints produce, the result arrives inline
when you poll, and there is nothing else to fetch.
Use /v1/files/{fileId}/extract when you want the result kept as a durable
artifact against the file version, or when you want your JSON Schema enforced —
this is the only path that validates the model's output and reports the errors.
It is also the path bulk extraction uses.
The job and operation split is not specific to extraction — workflows explains it once for split, classify and the rest.
Both require the extract.run permission and viewer access to the file. An API
key bypasses both checks, as it does everywhere else.
Schemas
A DocAI extraction schema is a flat-or-nested list of fields:
Code
type is one of string, number, boolean, date, object, array,
table. Anything else is coerced to string.
Field names are normalised: lowercased, with every run of characters outside
[a-z0-9_] replaced by _. Invoice Number is stored and returned as
invoice_number, and that normalised name is the key you will find in
jsonResult. Each field also gets a stable id derived from its path, so
field-invoice-number.
Generate one from a document
Generate and save a schema from a parsed file:
Code
This is a queued operation, not a synchronous call — see
queued operations. It debits 1
credit, and when it completes it has already saved the
schema, so the output
carries a schema_id you can use straight away:
Code
Optional body fields: segment_ids to generate from part of the document only,
model_id and reasoning_level to override the deployment defaults
(reasoning_level defaults to medium, and an unknown value falls back to
medium rather than erroring), and idempotency_key.
Save and reuse
POST /v1/extract/schemas
stores a schema you wrote yourself. It is CRUD — no model call, no credit:
Code
Responds 201 { "schema": { ... } }. Every schema endpoint returns the same
object, and it carries two representations of the same thing:
Code
schemais the DocAI field list. Send it — or itsidasschema_id— toPOST /v1/extract/runs.jsonSchemais a JSON Schema derived from the same fields. Send it asschematoPOST /v1/files/{fileId}/extract.
You may supply your own jsonSchema when saving; if you omit it, DocAI derives
one. The derivation is deliberate but lossy in one place: a table or array
field becomes {"type": "array", "items": {"type": "object"}} with no item
properties. The job path builds per-column result nodes from
items.properties, so if you want rows broken down on that path, write the
items.properties yourself rather than relying on the derived version.
The rest of the set:
GET /v1/extract/schemas
lists them,
GET /v1/extract/schemas/{schemaId}
reads one,
PATCH /v1/extract/schemas/{schemaId}
updates title, description, schema, jsonSchema or metadata (metadata is
merged, not replaced), and
DELETE /v1/extract/schemas/{schemaId}
soft-deletes, returning { "deleted": true, "schema_id": "…" }.
Access follows whatever the schema is attached to: viewer on its file_id if
it has one, else on its knowledge_base_id, else the plain files.read /
files.write permission. A missing or deleted schema is
404 schema_not_found.
Queued operations, and the 200 vs 202 rule
POST /v1/extract/runs and POST /v1/extract/schemas/generate do not run
inline. The normal response is 202:
Code
status is the row's real status, never a fixed string, so a request that
re-attaches to a run already in flight comes back 202 with
"status": "in_progress".
The one case that is not 202 is an idempotent replay of a run that has
already finished. Then the response is 200, and the body is the completed
output itself — not the { operation_id, status } envelope:
| Prior attempt for this idempotency key | Status | Body | Charged |
|---|---|---|---|
| none | 202 | { operation_id, status: "queued" } | yes |
queued or in_progress | 202 | { operation_id, status } | no |
completed | 200 | the full result | no |
failed or cancelled | 202 | { operation_id, status: "queued" } | yes, a fresh attempt |
Branch on the status code, not on the body. A 202 body has no result
fields at all.
If you send no idempotency_key, one is derived from file_id, the resolved
schema and model_id. It hashes the schema contents, not the schema_id, so
editing a saved schema produces a new key and a genuine re-run, while two
identical schemas under different ids collide onto one.
Polling
Code
Code
output is null on every status except completed, including failed and
cancelled — read error on those. An extraction is one model call, so
progress only ever reads 0/1 then 1/1; it tells you the run has started,
not how far through the document it is.
Start polling at about 2s and back off to a 10s ceiling. Stop on completed,
failed or cancelled. The operation endpoints are shared by every queued
workflow and are documented once under workflows; in the reference
they sit on the Operations tag.
POST /v1/operations/{operationId}/cancel stops a run at its next checkpoint.
An extraction has exactly one checkpoint, immediately before the model call: a
cancel that lands before it stops the run before any model work, and a cancel
that lands after it has no effect. Cancelling needs extract.run as well as
viewer access, because it moves credits. Reading only needs viewer access.
The output
When an extract_run operation completes, output looks like this:
Code
jsonResult is the plain data object, keyed by normalised field name. If
you only want the values, stop here.
resultTree is the same data walked against the schema, one node per
field, in schema order. Each node carries path, label, type, value, its
own citations, and children for nested fields. type is the field's schema
type, except that a null value reports "null". confidence is taken from the
node's first citation, or 0.8 when it has none — treat a bare 0.8 as
"unattributed", not as a measurement. Use resultTree when you are rendering
fields for review; use jsonResult when you are feeding a downstream system.
citations is the flat list, and the join key is path. Each entry has:
path— a slash path intojsonResult:/total_amount, or/line_items/0/amountfor an array member.segmentId— the id of the parse segment the value was read from. This is the same idGET /v1/files/{fileId}/segmentsreturns, so you can look up the segment's markdown and itscoordinates(page number and bounding box) and highlight the exact region that produced the value. Parsing explains the 0–1000 coordinate scale those boxes use.pageNumber— the page the segment sits on.quote— the snippet of segment text supporting the value.confidence.
That is what makes the output auditable: every field can be traced to a page, a
region, and a quote, without a second call to the model. On this endpoint the
citations come straight from the model and are passed through as they arrive,
so treat a missing or unmatched citation as "no provenance for this field"
rather than an error — check that the path you expect is actually present
before showing a value as sourced.
warnings on this path is whatever the model returned, and is usually
empty. The file-job path below produces real, computed warnings instead.
metrics describes the evidence that went in: how many segments and pages
were used, and how many characters of context were sent. modelCalls is 1.
durationMs is always 0 and timedOut always false on this path — they are
part of the shape, not a measurement, so do not build timing dashboards on them.
There is no credit field. Credit spend is visible in the ledger
(GET /v1/billing/ledger),
not echoed on the operation.
Extracting as a file job
POST /v1/files/{fileId}/extract
takes a JSON Schema and returns 202 with a job:
Code
Code
The default idempotency key is
extract:{file_version_id}:{schema_hash}:{options_hash}, so the same schema
against the same version replays: the response carries the existing job and
"idempotent": true instead of queuing a second one. Pass your own
idempotency_key to force a new run. options is passed through to the
processing plane; options.scope narrows which parts of the parse are used.
Poll with GET /v1/files/{fileId}/jobs/{jobId}.
The job walks stages
load-parse, build-evidence, extract, validate, write-artifacts. On
completion result carries extraction_result_id, artifact_manifest_id and
validation_errors, and the extraction itself is written as an artifact. List
the manifests with GET /v1/files/{fileId}/artefacts:
Code
The extract manifest exposes a result_json link. Fetch
it:
Code
The document behind that link carries schema, data, jsonResult (the same
object as data), resultTree, citations, evidence, warnings,
metrics, validationErrors, schema_name and modelId.
Three things differ from the operation path, all of them in your favour if you care about correctness:
- Validation is real. The output is validated against your JSON Schema with
a Draft 2020-12 validator, and every error is reported both in
validationErrors(pathplusmessage) and as a warning with codeschema_validation_error. A required field that came back empty adds arequired_value_missingwarning. - Citations are computed, not just trusted. Model-supplied citations are
merged with citations resolved against the parse grounding, and each entry
additionally carries
elementIdandbbox, so you can draw a box without looking the segment up. - Result nodes flag absence. A node with no value gains
"missingReason": "not_in_evidence", andconfidenceis the minimum across its citations rather than the first one's.
metrics on this path is measured rather than nominal: durationMs is the
real elapsed time, and it adds extractedFieldCount and citationCount.
Bulk extraction
POST /v1/files/extract/bulk
queues one job per file with a single schema:
Code
Responds 202 { "jobs": [ … ] }, one entry per requested file in request order,
each the same shape as the single-file dispatch. When you pass an
idempotency_key, each file's job gets "{key}:{fileId}" so the files do not
collide.
It is not atomic. Files are dispatched in order, and the first failure aborts the request — a file that is unparsed, missing, or not accessible throws mid-loop. Jobs already queued for earlier files in the list stay queued and keep running; you get an error response and a partially started batch. Check that every file is parsed before calling, or call the single-file endpoint per file so a failure is scoped to one document.
What it costs
Extraction jobs on the processing plane do not debit credits in v1. The cost of a bulk extraction is the parsing that had to happen first, at 1 credit per parsed page — so a hundred unparsed 20-page documents is 2,000 credits of parse before a single field is extracted, not a free operation.
If you instead loop POST /v1/extract/runs over the same files, that is a fixed
1 credit per file. Check GET /v1/billing/credits
before starting a large batch; there is no dry-run mode and no per-request cap.
Credits
POST /v1/extract/runs and POST /v1/extract/schemas/generate each debit
exactly 1 credit, regardless of document size or how many internal model
calls the run makes. The order is fixed and matters:
- The request is validated — file access, permission, parse artifacts, schema, model.
- The credit is debited.
- Only then is the operation row created or reset, and the work queued.
A declined debit (402 insufficient_credits, or 402 monthly_quota_exceeded
where a quota is set) throws before anything is enqueued, so a refused payment
can never leave a live job behind.
Each attempt is charged under its own ledger key, <operationKey>:attempt-N. A
failed or cancelled attempt is refunded under
<operationKey>:attempt-N:refund as a separate reversal row, so the charge
and the refund stay individually auditable. That is why retrying a failed run
charges again — the first attempt's credit was already returned.
Schema CRUD (POST/GET/PATCH/DELETE /v1/extract/schemas) never debits.
Worked example
Parse a document, generate a schema from it, extract against that schema, and read the result. Uploading and parsing is covered in the quickstart; this picks up from a completed parse.
1. Confirm the file is parsed. A parse manifest has to exist, or everything
below returns 409.
Code
2. Generate a schema.
Code
Code
3. Poll until it completes, then take the schema_id.
Code
Code
Review the generated fields before you rely on them —
GET /v1/extract/schemas/2f7d6a19-84c3-4b0e-9d51-7ac83e15b920 returns the full
definition, and PATCH edits it.
4. Run the extraction.
Code
Code
One credit has been debited at this point.
5. Poll until completed.
Code
Code
6. Show where a value came from. Take the citation's segmentId and look
it up in the file's segments:
Code
Code
Those coordinates are the box to highlight on page 3. That is the whole provenance chain: field → citation → segment → page region.
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | file_id_is_required | No file_id on a workflow request |
| 400 | schema_is_required | No schema on a file extract or bulk request |
| 400 | unsupported_model | model_id is not an available model |
| 402 | insufficient_credits | Balance minus active reservations is below the charge |
| 402 | monthly_quota_exceeded | The organization's quota would be exceeded |
| 403 | permission_required | Caller lacks extract.run |
| 404 | schema_not_found | Unknown, deleted, or another organization's schema_id |
| 404 | operation_not_found | Unknown operation id, or its file was permanently deleted |
| 409 | file_must_be_parsed | /v1/extract/runs on a file with no readable parse artifact, or an empty segment_ids selection |
| 409 | file_must_be_parsed_before_extraction | /v1/files/{fileId}/extract on a file version with no parse artifact |
| 422 | invalid_schema | Neither schema_id nor an inline schema was supplied |
| 503 | model_provider_unavailable | No default model is configured or reachable |
Branch on code, never on error — the sentence may be reworded, the code is
stable. The full list is on the error codes page.
Where to next
- Knowledge bases — read a whole corpus in one call and search it without spending credits, to find the documents worth extracting from.
- Split and Classify — separate a combined PDF or label it before you extract, so each schema runs on one document.
- MCP server —
docai_generate_schemaanddocai_extract_bulkdrive this same flow from Claude Code, Cursor or Codex.

