# Document workflows Document workflows run a model over an already-parsed file. Every one of them needs the `extract.run` permission, viewer access to the file, and a parsed file — an unparsed file returns `409 file_must_be_parsed`. **Before you start.** You need an API key from the [quickstart](/quickstart) and a document that has already been [parsed](/parsing). Failures carry a stable `code`; the full list is on the [error codes](/errors) page. They do not all answer the same way. Some queue and return an operation id to poll; some run the model inside the request and return the result. Get this wrong and you will poll an endpoint that already gave you the answer, or read `output` from a 202 and find `null`. ## Which are queued, which are not | Endpoint | Shape | Response | |---|---|---| | [`POST /v1/split/rules/suggest`](/api/document-split#suggest-split-rules) | queued | `202` `{ operation_id, status }` | | [`POST /v1/split/runs`](/api/document-split#enqueue-a-document-split-run) | queued | `202` `{ operation_id, status }` | | [`POST /v1/extract/runs`](/api/document-extraction#enqueue-a-product-extraction-run) | queued | `202` `{ operation_id, status }` | | [`POST /v1/extract/schemas/generate`](/api/document-extraction#generate-and-save-reusable-extraction-schema) | queued | `202` `{ operation_id, status }` | | [`POST /v1/classify/classes/suggest`](/api/document-classification#suggest-page-classification-classes) | synchronous | `200` with the classes | | [`POST /v1/classify/runs`](/api/document-classification#classify-document-and-portions) | synchronous | `200` with the classifications | | [`POST /v1/edit/runs`](/api/document-edit#create-document-edit-proposal) | synchronous | `200` with the proposal | | [`POST /v1/split/download`](/api/document-split#download-split-pdf-page-range) | synchronous | `200` PDF bytes | The synchronous ones hold the connection open for the whole model call. That call is slow — minutes on a large document — so set a generous client timeout and expect any proxy in front of the API to be the binding constraint. The split walkthrough is on [split](/split) and the classify walkthrough on [classify](/classify); this page owns the operation model they share. The two `extract` rows are on this page for their operation shape only. What a schema looks like and what the run returns is on [extraction](/extraction). ## The operation model A queued workflow writes one operation row. [`GET /v1/operations/{operationId}`](/api/operations#get-queued-operation-status-progress-and-output) returns it in a fixed shape: ```json { "operation_id": "0f7c…", "operation_type": "split_run", "status": "in_progress", "progress": { "done": 3, "total": 8, "phase": "windows" }, "output": null, "error": null, "created_at": "2026-08-19T10:31:02.114Z", "completed_at": null } ``` `status` is one of `queued`, `in_progress`, `completed`, `failed`, `cancelled`. `operation_type` is one of `split_run`, `extract_run`, `classify_run`, `edit_run`, `split_rules_suggest`, `schema_generate` — the set the database CHECK constraint enumerates. `output` is **null until `status` is `completed`**. It is not an empty result and it is not partial. A client that reads `output` on its first poll gets `null`; keep polling. `error` carries the failure message on `failed` and `cancelled`, and is null otherwise. `progress` is advisory. `done` and `total` are both 0 before the run reports anything, and `phase` names the stage: a split run reports `windows` then `repair`, a rules suggestion reports `rules`. An unknown id, another organisation's id, and an operation whose file was permanently deleted are all `404 operation_not_found` — they are deliberately indistinguishable. ### Polling ```bash OP=$(curl -s -X POST "$DOCAI_API/v1/split/runs" \ -H "x-api-key: $DOCAI_API_KEY" -H "content-type: application/json" \ -d '{"file_id":"'"$FILE_ID"'","rules":[{"name":"Invoice","description":"One invoice per record"}]}' \ | jq -r .operation_id) curl -s "$DOCAI_API/v1/operations/$OP" -H "x-api-key: $DOCAI_API_KEY" ``` ### Cancelling ```bash curl -s -X POST "$DOCAI_API/v1/operations/$OP/cancel" -H "x-api-key: $DOCAI_API_KEY" ``` [`POST /v1/operations/{operationId}/cancel`](/api/operations#cancel-a-queued-or-in-progress-operation) is cooperative. The request records the intent and the run stops at its next checkpoint. A split checks between windows and again inside the repair pass, so a cancel does not leave repair model calls running. The response has the same shape as the GET, and `output` is still null unless the run had already completed. Cancelling an operation that is already terminal changes nothing and returns its current state. Reading an operation needs only viewer file access; cancelling one needs `extract.run` as well, because it moves credits. ## Edit `POST /v1/edit/runs` is **synchronous** and **proposes only**. It never mutates the source file or its artifacts. ```bash curl -s -X POST "$DOCAI_API/v1/edit/runs" \ -H "x-api-key: $DOCAI_API_KEY" -H "content-type: application/json" \ -d '{"file_id":"'"$FILE_ID"'","prompt":"Redact every bank account number","rules":["Keep headings"]}' ``` Returns `{ operation_id, proposal_id, proposal, modelId, credit }`. The `proposal` is the model's own JSON; its `diff` (or `patch`) is stored alongside it. The proposal is saved with status `proposed` and `proposal_id` identifies it. Applying it is your side of the contract. ## Model controls Every workflow body accepts the same three optional fields. | Field | Meaning | |---|---| | `model_id` | Model to run. Omit for the deployment default. | | `reasoning_level` | `none`, `low`, `medium` or `high`. Defaults to `medium`. | | `idempotency_key` | Request identity. Derived from the inputs when omitted. | An unavailable or unknown model is `400 unsupported_model`. Which models a deployment accepts is deployment configuration, not a fixed list. Lower `reasoning_level` is faster. At the default `medium`, one split window costs roughly 60 to 90 seconds, and a split runs several windows, so a large document takes minutes. ## Credits Each of these debits **exactly one credit** per charged attempt: split rules suggest, split run, classify classes suggest, classify run, edit run, extract run, schema generate. `POST /v1/split/download` debits nothing. The debit happens **before** any work starts. A `402 insufficient_credits` or `402 monthly_quota_exceeded` therefore leaves nothing queued and nothing running. Queued and synchronous workflows then diverge, and this is the part worth reading twice. **Queued workflows refund.** When a queued operation ends `failed` or `cancelled`, the credit for that attempt is refunded in the same transaction that writes the terminal status. Retrying is real work and is charged again; each attempt appears in the ledger under its own key, `…:attempt-1`, `…:attempt-2`, and so on. A failed split therefore costs nothing and does not block a retry. **Synchronous workflows do not refund.** Classify and edit debit once against the request's idempotency key. If the model call then fails, the operation is marked `failed` and the credit stays spent. Retrying with the same idempotency key is not charged a second time — the ledger entry already exists, and the response reports `credit.debited: false` — but it does re-run the model. Retrying with a fresh `idempotency_key` is a new charge. A completed synchronous run replays for free: the same idempotency key returns the stored output without touching the model. ## Where to next - [Split](/split) — suggest rules, run the split, download each record. - [Classify](/classify) — suggest classes and label a document and its pages. - [Extraction](/extraction) — the schema, the output shape and the citations behind `POST /v1/extract/runs`. - [API reference](/api) — every request body and response field for the endpoints on this page.