# Parsing Parsing turns a document into a bundle of artifacts: markdown you can read, JSON you can ground, page images you can draw on, and cropped figures you can show. Everything else in DocAI — extraction, search, agents — reads that bundle. **Before you start.** Parsing runs on a file that is already uploaded. Get an API key and upload a document with the [quickstart](/quickstart); the file has to sit in a [knowledge base](/knowledge-bases) first. A parse is queued, not synchronous. You get a job id, you poll it, and when it completes you fetch the artifacts by name. ```bash curl -s -X POST "$DOCAI_API/v1/files/$FILE_ID/parse" \ -H "x-api-key: $DOCAI_API_KEY" \ -H "content-type: application/json" \ -d '{"options": {}}' ``` ```json { "job": { "job_id": "9b2…", "status": "queued", "kind": "parse", "percent": 0 }, "usage": { "estimated_pages": 12, "required_credits": 12, "balance_credits": 480 }, "credit_reservation": { "id": "c41…", "status": "reserved", "estimated_credits": 12 }, "idempotency_key": "parse:5d7…:8f10c2", "idempotent": false } ``` The response is `202`. Credits are reserved up front at one credit per page and finalised against the pages actually processed. The reserve-then-commit flow and the monthly quota are covered in the [quickstart](/quickstart#credits). A short balance fails the dispatch with `402 insufficient_credits`, and a capped organization with `402 monthly_quota_exceeded`, before any work is queued — those and the rest are on the [error codes](/errors) page. Full request and response shape: [queue parse job for file](/api/file-processing#queue-parse-job-for-file-through-the-control-plane) in the API reference. ## Idempotency The default idempotency key is `parse:{file_version_id}:{options_hash}`. Sending the same request twice returns the first job with `idempotent: true` rather than queuing a second one and charging you twice. Pass your own `idempotency_key` to override it. A terminal job that failed is retryable: dispatching again creates a fresh job and reports the previous one as `retry_of_job_id`. ## What a parse produces One parse writes one artifact manifest. [`GET /v1/files/{fileId}/artefacts`](/api/knowledge-base-corpus#fetch-parse-extract-artefact-links) returns every manifest for the file, newest first, each with a `links` object: ```json { "artifacts": [ { "id": "a17…", "file_id": "5d7…", "file_version_id": "e02…", "job_id": "9b2…", "artifact_type": "parse", "manifest": { "page_count": 12, "changed_pages": [1, 2], "unchanged_pages": [3] }, "created_at": "2026-08-19T11:04:02.000Z", "links": { "result_md": "/v1/artifacts/a17…/result_md", "grounding_json": "/v1/artifacts/a17…/grounding_json", "result_json": null, "workbook_json": null, "tables_json": null, "pages": "/v1/artifacts/a17…/pages", "imgs": "/v1/artifacts/a17…/imgs" } } ] } ``` **Note the spelling.** The listing endpoint is `/artefacts` with an `e`. The download endpoint is `/artifacts` with an `i`. That is not a typo in this page; it is the actual API. Get it wrong and you get a 404 with no hint. A `null` link means this parse did not produce that artifact. Branch on the link, not on the file type. ### The artifact keys | Key | Type | Contains | Present when | |---|---|---|---| | `result_md` | Markdown | The readable document: headings, paragraphs, tables, image references | Always | | `grounding_json` | JSON | Every element with its page, bounding box, label and content | Always | | `pages` | Directory | Rendered page images, `page_0001.png` upward, 150 dpi by default | PDFs and images | | `imgs` | Directory | Cropped figures, charts, tables and formulas as JPEG | When the document has any | | `workbook_json` | JSON | Sheets, rows and cells of a spreadsheet | Spreadsheets only | | `tables_json` | JSON | Detected used-range tables in a spreadsheet | Spreadsheets only | | `result_json` | JSON | Structured extraction output | Extract manifests, not parse | `result_json` belongs to `artifact_type: "extract"`. It is listed here because it appears in the same `links` object and people reach for it expecting parse output. On a parse manifest it is normally `null`. ### Fetching one ```bash curl -s "$DOCAI_API/v1/artifacts/$MANIFEST_ID/result_md" -H "x-api-key: $DOCAI_API_KEY" ``` Links are built from manifest ids and artifact keys, never from filesystem paths. There is no path to guess and nothing to traverse. The endpoint is [download or list an artefact](/api/knowledge-base-corpus#download-or-list-an-artefact-by-generated-link). A manifest id the organization does not own is `404 artefact_manifest_not_found`, and a key the manifest does not carry is `404 artefact_not_found` — see [error codes](/errors). `pages` and `imgs` are directories. Fetching one without a `file` query returns a listing: ```json { "entries": [ { "name": "page_0001.png", "kind": "file", "url": "/v1/artifacts/a17…/pages?file=page_0001.png" }, { "name": "page_0002.png", "kind": "file", "url": "/v1/artifacts/a17…/pages?file=page_0002.png" } ] } ``` Then fetch a single entry with `?file=page_0001.png`. A `file` value containing `..` is rejected with `403 invalid_artefact_child_path`. ### Images in the markdown are relative `result_md` references crops as relative paths, for example `![Formula el_12](imgs/formula_page3_el_12.jpg)`. Those paths mean nothing to your renderer. Rewrite each `imgs/NAME` to `/v1/artifacts/{manifestId}/imgs?file=NAME` before displaying the markdown, or the images will silently fail to load. ## Backend routing `backend` defaults to `auto`. Auto is not a guess — it is a strict test, and it is deliberately biased towards OCR. A PDF goes to **Docling** only when **every one of these holds, on every page**: - the embedded text layer has at least 150 characters on that page — a per-page floor, not a document average - images and vector drawings cover less than 10% of the page area - the document has no AcroForm or XFA form fields - no page carries a stamp annotation Fail any one of them, on any single page, and the whole document goes to OCR. Plain images (`.png`, `.jpg`, `.jpeg`) and every non-PDF input go straight to OCR without inspection. **A figure-heavy born-digital PDF will take the OCR path.** A clean, text-native brochure with a large hero image on page one crosses the 10% graphic threshold and routes to OCR. That is policy, not a bug: Docling reads the text layer, and on documents that are substantially visual its text layer is not the document. One page over the line takes the whole file with it, because a bundle assembled from two different backends has two different notions of what an element is. Force a backend when you know better: ```json { "options": { "backend": "docling" } } ``` Accepted values are `auto`, `docling` and `glm-ocr`. Other options passed through to the parser include `dpi` (default 150), `layout_device`, `page_fallback_ocr` and `timeout_seconds`. Options are part of the idempotency key, so changing one queues a genuinely new parse. ### Re-parsing is page-diffed Before parsing, DocAI renders the pages and hashes them against the previous version. If nothing changed and the previous artifacts are readable, the job completes at `stage: "reused"` with `percent: 100` and debits zero credits. Only changed pages are charged on a partial re-parse — `manifest.changed_pages` and `manifest.unchanged_pages` tell you which were which. ## Groundings and coordinates `grounding_json` is the parse in structured form: ```json { "schema_version": "1.0", "document": { "filename": "contract.pdf", "file_type": "pdf", "page_count": 12, "processed_at": "2026-08-19T11:03:51.402Z", "mode": "docling" }, "pages": [ { "page_number": 1, "width": 1275, "height": 1650, "elements": [ { "id": "el_3", "type": "text", "label": "text", "bbox": { "x1": 0.1042, "y1": 0.2118, "x2": 0.8938, "y2": 0.2465 }, "pixel_bbox": [132, 349, 1139, 406], "content": "The Supplier shall deliver…", "markdown_anchor": "el-3" } ] } ] } ``` `bbox` is fractional, `0`–`1` of the page. `pixel_bbox` is absolute pixels at the rendered resolution. Both describe the same box. ### Segments Rather than parsing that yourself, use the [segments endpoint](/api/knowledge-base-corpus#read-segment-map-data-from-latest-parse-artifacts). It flattens the grounding into a list ready for citation: ```bash curl -s "$DOCAI_API/v1/files/$FILE_ID/segments" -H "x-api-key: $DOCAI_API_KEY" ``` The response is **a bare array, not an envelope**: ```json [ { "id": "el_3", "docId": "5d7…", "docName": "contract.pdf", "title": "text", "markdown": "The Supplier shall deliver…", "coordinates": { "pageNumber": 1, "xmin": 104, "ymin": 212, "xmax": 894, "ymax": 247, "sourceType": "pdf" }, "pdfUrl": "/v1/files/5d7…/content?org_id=…&v=e02…", "sourceType": "pdf" } ] ``` Segments with no markdown are dropped, so an empty array means the parse produced no readable content, not that the file is missing. ### The 0–1000 coordinate scale **`coordinates` is normalised to integers from 0 to 1000**, origin top-left. It is neither pixels nor fractions. A fractional grounding box is multiplied by 1000 and rounded; a pixel box is clamped into the same range. Values are always clamped to `[0, 1000]`. To draw a highlight over a rendered page image of width `W` and height `H`: ```js const left = (c.xmin / 1000) * W; const top = (c.ymin / 1000) * H; const width = ((c.xmax - c.xmin) / 1000) * W; const height = ((c.ymax - c.ymin) / 1000) * H; ``` A DocAI segment is one grounded piece of text with a page number and a bounding box. `pageNumber` is 1-based and matches `page_0001.png` for page 1. Pair a segment with its [page render](#page-images) and you have a citation a user can see: the page, the box, and the text that produced the answer. Segment ids are also the join key for extraction citations — see [extraction](/extraction). ### Table cell geometry Table segments may carry an additive `cells` array — one entry per **logical** cell, `{row, col, rowSpan, colSpan, bbox, text, isHeader}`. Merged cells appear once with a span greater than 1, so never assume a span-1 grid. Three fields tell you how far to trust that geometry: - **`cellsSource: "table-former"`** — cells come from the TableFormer structure model. Measured per-cell boxes, real spans, both axes trustworthy. This is the preferred source. - **`cellsSource: "projection"`** — both axes were confidently detected from the crop by grid detection. - **`cellsSource: "grid-estimate"`** — at least one axis fell back to a uniform split. - **`cellGrid: {rows, cols}`** — per-axis confidence, each `"detected"` or `"estimated"`, decided independently. - **`cellsEstimateReason`** — present only on the scanned path, and only when the TableFormer pass ran and **declined**, naming why: `"col count mismatch: html=15 model=2"`, `"tableformer model unavailable"`, `"crop too small: 40x12px"`. The rule for a client is `cellGrid`, not `cellsSource`: > Draw a precise per-cell box only when `cellGrid.cols === "detected"`. When it > is `"estimated"`, highlight the whole row band instead of a specific column. > `cellGrid.rows` follows the same rule, though rows are detected on the large > majority of real tables. Columns are conservatively marked `"estimated"` on borderless or mixed-alignment tables rather than reported as precise. That is deliberate: a cell box that looks exact but sits in the wrong place is worse than an honest estimate. On a dense 15-column form, TableFormer has been measured detecting only 2 columns, which is why the structure pass refuses to emit a grid that disagrees with the OCR text grid. `cellsEstimateReason` is diagnostic. Put it in a debug view or a support report, not in front of an end user. Its absence on a `grid-estimate` table means the structure pass never ran at all — the Docling path, or a non-table element. ## Spreadsheets `.xlsx` and `.csv` take a different path. There are no pages, so the unit is sheets: `manifest.unit` is `"sheets"`, `manifest.source_type` is `"spreadsheet"`, and credits are debited per sheet. The bundle carries `workbook_json` and `tables_json` instead of page renders — list them with [`GET /v1/files/{fileId}/artefacts`](/api/knowledge-base-corpus#fetch-parse-extract-artefact-links) and download them through the artifact links it returns, like any other artifact in the bundle. A spreadsheet only counts as fully parsed once it has both a `workbook_json` link and a non-zero `segment_count`. A manifest without them is stale. ## Page images Every parsed page is rendered to `pages/page_XXXX.png` inside the artifact bundle. List them with [`GET /v1/files/{fileId}/artefacts`](/api/knowledge-base-corpus#fetch-parse-extract-artefact-links) — the manifest's `pages` directory carries one entry per page — and download each through its artifact link: ```bash curl -s "$DOCAI_API/v1/artifacts/$MANIFEST_ID/pages?file=page_0001.png" \ -H "x-api-key: $DOCAI_API_KEY" -o page_0001.png ``` Filenames are zero-padded, so lexical order is page order, and `pageNumber` on a segment indexes straight into them. Spreadsheets have no pages to render — read the sheet through its `workbook_json` and `tables_json` artifacts instead. ## Bulk parse [Queue parse jobs for multiple files](/api/file-processing#queue-parse-jobs-for-multiple-files): ```bash curl -s -X POST "$DOCAI_API/v1/files/parse/bulk" \ -H "x-api-key: $DOCAI_API_KEY" \ -H "content-type: application/json" \ -d '{"file_ids": ["5d7…", "6e8…"], "options": {"backend": "docling"}}' ``` Returns `202` with one dispatch entry per file, in request order: ```json { "jobs": [ { "job": { "job_id": "9b2…", "status": "queued" }, "credit_reservation": { "estimated_credits": 12 } } ] } ``` **Bulk parse is not atomic, and this costs money.** Files are dispatched one at a time, in order. If the fifth file id does not exist, the request fails with [`404 file_not_found`](/errors) — and the first four jobs are already queued with credits already reserved against them. The error response does not tell you which ones went through. Two habits avoid the mess: - Validate every file id before sending the batch. - Pass an `idempotency_key`. Each file gets `{your_key}:{file_id}`, so a retry after a partial failure re-attaches to the jobs that already exist instead of queuing and charging for them a second time. `options` applies to every file in the batch. Mixed batches needing different backends need separate calls. ## Progress and failure A parse is a **job**, polled on the file. Queued document workflows — split, classify, schema generation — are **operations** with a different identifier and a different poll endpoint; see [workflows](/workflows) before you mix the two. [Poll the job](/api/file-processing#fetch-parse-extract-job-status-for-a-file): ```bash curl -s "$DOCAI_API/v1/files/$FILE_ID/jobs/$JOB_ID" -H "x-api-key: $DOCAI_API_KEY" ``` `status` is one of `queued`, `running`, `completed`, `failed`, `cancelled`. Internal names such as `started` and `in_progress` are normalised to `running`; `raw_status` carries the unnormalised value if you need it. `percent` is monotonic and banded by stage: | Percent | Stage | Meaning | |---|---|---| | 5 | `page-diff` | Hashing pages against the previous version | | 10 → 75 | `parse` | Text extraction — Docling chunks or GLM-OCR regions | | 75 → 95 | `parse` | Visual analysis of figures and charts | | 100 | `completed` | Done | | 100 | `reused` | Nothing changed; previous artifacts kept, no credits spent | Spreadsheets use `parse-spreadsheet` in place of `parse`. **Use `percent` for progress bars.** `completed_pages` and `total_pages` are written only at stage boundaries, so they sit still while work is happening. Progress is a display signal, not a contract: a parser that reports nothing leaves `percent` at its last stage boundary. ### When it fails `status: "failed"` puts the reason in `error`. The [event log](/api/file-processing#fetch-parse-extract-job-event-log-for-a-file) has the detail: ```bash curl -s "$DOCAI_API/v1/files/$FILE_ID/jobs/$JOB_ID/events" -H "x-api-key: $DOCAI_API_KEY" ``` ```json { "events": [ { "event_type": "parse.page_diff.started", "message": "Computing page hashes", "stage": "page-diff" }, { "event_type": "parse.started", "message": "Running DocAI parser", "stage": "parse" }, { "event_type": "job.failed", "message": "…", "payload": { "traceback": "…" } } ] } ``` Events are ordered oldest first. A `job.failed` event carries the last 4000 characters of the traceback in `payload.traceback`, which is usually enough to tell a bad input from an infrastructure problem. Two failures worth recognising by their message: - **Killed by the container runtime.** The message names insufficient Docker memory. On-prem only. Raise the memory limit and retry; the input is fine. - **"Parser completed but expected artefacts were not found".** The parser exited cleanly but wrote nothing. Usually a source file that is not what its extension claims. A failed parse spends nothing — the credit reservation is released rather than debited. Retrying is a normal dispatch: same endpoint, and the new job reports `retry_of_job_id`. Failed jobs stay in the history until you [clear them](/api/file-processing#clean-failed-parse-extract-jobs-for-a-file): ```bash curl -s -X DELETE "$DOCAI_API/v1/files/$FILE_ID/jobs/failed" -H "x-api-key: $DOCAI_API_KEY" ``` ```json { "cleaned": 2, "cleaned_job_ids": ["9b2…", "a03…"], "cleaned_kinds": ["parse"] } ``` That removes failed parse and extract jobs and their events for the file. It does not touch the file, its versions, or any successful parse. It needs `manager` access on the file, where polling only needs `viewer`. ## Where to next - [Extraction](/extraction) — turn the parse into JSON that matches a schema, with citations back to the segments above. - [Knowledge bases](/knowledge-bases) — search what you have parsed, and index a whole corpus rather than one file at a time. - [Split](/split), [Classify](/classify) and the other [workflows](/workflows) that run on a parsed document. - [Error codes](/errors) — every code a parse dispatch or poll can return.