> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ndi.nace.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Document operations

> Parse, split, classify, extract, ground, and validate extraction schemas

Document operations are workspace-free: supply a document source and get back results.
Every job-creating endpoint returns **202** immediately and accepts `?wait_seconds=N`
to block up to N seconds (max 300) for the result inline.

`wait_seconds` is a **query parameter**, not a request body field.
`Idempotency-Key` is an **HTTP header**, not a request body field. Strict request
models refuse extra body fields.

***

## Source types

The five job-creating operations (`parse`, `split`, `classify`, `extract`, `ground`) accept the shared document source union. Schema validation is synchronous and validates a schema structure only — it does not accept a document source.

```json theme={"dark"}
// Staged upload (from POST /v1/uploads)
{"type": "upload", "upload_id": "<uuid>"}

// Public or S3 URL
{"type": "url", "url": "https://example.com/report.pdf", "file_name": "report.pdf"}

// File already in a workspace (workspace_id + file_id)
{"type": "workspace_file", "workspace_id": "<uuid>", "file_id": "<uuid>"}

// Reuse a prior Parse job where supported
{"type": "parse_result", "job_id": "<parse-job-id>"}
```

See [Sources and uploads](/concepts/sources-and-uploads) for details.

***

## Parse

```
POST /v1/parse
```

Convert any document into structured text, Markdown, or block JSON. OCR is applied
automatically when the document needs it.

**Query parameters:**

* **`wait_seconds`** (optional, default 0, max 300) — Block up to N seconds for the result inline. 0 returns 202 immediately.

**Headers:**

* **`Idempotency-Key`** (optional, max 200 chars) — Replaying with the same key returns the original job.

**Request body:**

```json theme={"dark"}
{
  "source": {"type": "upload", "upload_id": "550e8400-e29b-41d4-a716-446655440002"},
  "page_ranges": [{"start": 1, "end": 10}],
  "output": {
    "formats": ["markdown", "text", "blocks"],
    "table_format": "markdown",
    "include_images": false,
    "include_page_markers": true
  },
  "figures": {"mode": "include"},
  "chunking": {"strategy": "page"}
}
```

**Body fields:**

* **`source`** (required) — Document source discriminator.
* **`page_ranges`** (optional) — One or more 1-indexed inclusive page ranges. Omit to parse all pages.
* **`password`** (optional) — PDF user/owner password for encrypted uploads/URLs. Write-only: never stored in the job payload or returned in any response. Rejected with `parse_result` sources. Ignored when the PDF is not encrypted. Non-PDF sources with a password fail with `invalid_request`.
* **`output.formats`** (optional, default `["markdown", "blocks"]`) — One or more of `"markdown"`, `"text"`, `"blocks"`.
* **`output.table_format`** (optional, default `"html"`) — Render tables as `"html"` or GitHub-flavored `"markdown"`.
* **`output.include_images`** (optional, default `false`) — Include extracted figure crops in the parsed output. Works for PDF and image uploads (`png` / `jpg` / `jpeg` / `webp`).
* **`output.include_page_markers`** (optional, default `true`) — Insert `--- Page N ---` headers between PDF pages in markdown and text. Page chunking still uses these headers internally when this is false.
* **`figures.mode`** (optional, default `"include"`) — `"omit"`, `"include"`, or `"describe"`. Describe captions figure blocks (PDF and image). Independent of `diagrams.mode`.
* **`diagrams.mode`** (optional, default `"omit"`) — `"omit"` or `"mermaid"`. When omit, flowchart mermaid is stripped and node/edge labels are flattened into overlay text. When mermaid, fences stay and `figures.mode=describe` still appends a `Figure:` caption.
* **`chunking.strategy`** (optional, default `"none"`) — `"none"`, `"page"`, or `"section"`. Generated chunks are returned in `document.chunks`.

When `source.type` is `parse_result`, page selection and
`output.include_images` are not supported because NDI reuses the prior Parse
result instead of reading the original document again.

**Example (wait for result):**

```bash theme={"dark"}
curl -X POST "$NDI_BASE_URL/v1/parse?wait_seconds=60" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: parse-report-v1" \
  -d '{
    "source": {"type": "url", "url": "https://example.com/report.pdf", "file_name": "report.pdf"},
    "password": "optional-when-encrypted"
  }'
```

**Response (202 — queued, or 200 — terminal when wait\_seconds hit):**

```json theme={"dark"}
{
  "job_id": "550e8400-e29b-41d4-a716-446655440010",
  "kind": "parse",
  "status": "succeeded",
  "result": {
    "result_type": "parse",
    "file_name": "report.pdf",
    "lane": "document",
    "document": {
      "page_count": 96,
      "markdown": "# Annual Report 2025\n\n...",
      "text": null,
      "blocks": null,
      "chunks": [
        {
          "id": "page-1",
          "content": "# Annual Report 2025\n\n...",
          "location": {"kind": "page_region", "page": 1, "polygons": []}
        }
      ]
    },
    "ocr_applied": false,
    "units": 96
  },
  "created_at": "2026-08-09T12:00:00Z",
  "finished_at": "2026-08-09T12:01:00Z"
}
```

**Result fields:**

* **`file_name`** — Name of the parsed file.
* **`document.page_count`** — Total pages in the document.
* **`document.markdown`** — Present when `"markdown"` was in `output.formats`.
* **`document.text`** — Present when `"text"` was in `output.formats`.
* **`document.blocks`** — Present when `"blocks"` was in `output.formats`; list of structural blocks.
* **`document.chunks`** — RAG-oriented chunks produced by the selected `chunking.strategy`; empty for `"none"`.
* **`ocr_applied`** — Whether OCR ran.
* **`units`** — Processing units consumed (for billing).

***

## Split

```
POST /v1/split
```

Split a paged document into logical, classified page ranges. For spreadsheets,
Split deterministically creates one segment per worksheet, then classifies each
worksheet against the provided classes.

**Query parameters:**

* **`wait_seconds`** (optional, default 0, max 300)

**Headers:**

* **`Idempotency-Key`** (optional)

**Request body:**

```json theme={"dark"}
{
  "source": {"type": "upload", "upload_id": "550e8400-e29b-41d4-a716-446655440002"},
  "classes": [
    {
      "id": "invoice",
      "label": "Invoice",
      "description": "Supplier invoice or request for payment.",
      "subclasses": [
        {"id": "supplier_bill", "label": "Supplier bill", "description": "Vendor invoice."}
      ]
    },
    {"id": "statement", "label": "Statement"}
  ],
  "unknown_policy": "include",
  "overlap_policy": "exclusive",
  "output": {"include_content": false, "materialize_files": false}
}
```

**Body fields:**

* **`source`** (required) — One upload, URL, workspace-file, or supported Parse-result source.
* **`classes`** (required) — Target segment categories with stable `id` values and optional `subclasses`. Spreadsheet worksheets are classified against the same two-level leaf classes and can return more than one match.
* **`unknown_policy`** (optional, default `"include"`) — Whether unclassified pages or worksheet segments are included, forced, or rejected.
* **`overlap_policy`** (optional, default `"exclusive"`) — How adjacent candidate ranges are reconciled. `"shared_boundary_page"` is reserved for a future runtime.
* **`output.include_content`** (optional, default `false`) — Include segment content in the result.
* **`output.materialize_files`** (optional, default `false`) — Materialize document segments as child artifacts. Spreadsheet segments always materialize as single-sheet `.xlsx` artifacts regardless of this option.

`page_ranges` is not supported for spreadsheet sources. Spreadsheet sheet
position is represented as `start_page == end_page == sheet_index + 1`.

**Result:**

```json theme={"dark"}
{
  "result_type": "split",
  "file_name": "packet.pdf",
  "lane": "document",
  "page_count": 2,
  "segments": [
    {
      "id": "seg_1",
      "sequence": 0,
      "status": "matched",
      "class": {
        "id": "invoice/supplier_bill",
        "label": "Invoice / Supplier bill",
        "description": "Supplier invoice or request for payment. Vendor invoice.",
        "parent_id": "invoice",
        "subcategory_id": "supplier_bill"
      },
      "classes": null,
      "start_page": 1,
      "end_page": 2,
      "classification_confidence": {"band": "unavailable", "score": null},
      "boundary_confidence": {"band": "unavailable", "score": null},
      "content": null,
      "artifacts": [],
      "warnings": []
    }
  ],
  "units": 2
}
```

**Result fields:**

* **`segments`** — Ordered page-range segments for the document.
* **`class`** — The selected class for the segment, or `null` for unclassified segments.
* **`classes`** — Multi-class set for spreadsheet worksheet segments. Document/PDF segments keep the singular `class` field and return `null` here.
* **`start_page`** / **`end_page`** — Inclusive 1-indexed page range.
* **`sheet_name`** — Worksheet name for spreadsheet segments; `null` for document segments.
* **`classification_confidence`** — Confidence band and optional calibrated score for the selected class.
* **`boundary_confidence`** — Confidence band and optional calibrated score for the segment boundaries.
* **`artifacts`** — Materialized child files when requested for documents; always one single-sheet `.xlsx` for spreadsheet segments.
* **`warnings`** — Non-fatal notes about the segment.

***

## Classify

```
POST /v1/classify
```

Classify one document against caller-provided classes. Use `granularity: "document"`
for one document-level label unit, or `granularity: "page"` for one label unit per
page. `page_ranges` restricts which pages are parsed and classified.

**Query parameters:**

* **`wait_seconds`** (optional, default 0, max 300)

**Headers:**

* **`Idempotency-Key`** (optional)

**Request body:**

```json theme={"dark"}
{
  "source": {"type": "upload", "upload_id": "550e8400-e29b-41d4-a716-446655440002"},
  "classes": [
    {
      "id": "invoice",
      "label": "Invoice",
      "description": "Request for payment from a supplier.",
      "criteria": ["Has supplier name", "Has amount due"],
      "subclasses": [
        {"id": "utility", "label": "Utility invoice"}
      ]
    },
    {
      "id": "bank_statement",
      "label": "Bank statement"
    }
  ],
  "granularity": "document",
  "page_ranges": [{"start": 1, "end": 3}],
  "unknown_policy": "allow",
  "output": {"max_alternatives": 3, "include_reason": true}
}
```

**Body fields:**

* **`source`** (required) — One upload, URL, or workspace-file source. `parse_result` reuse is intentionally rejected for Classify.
* **`classes`** (required) — One or more classes with stable `id`, human `label`, optional `description`, optional `criteria`, and optional `subclasses`.
* **`granularity`** (optional, default `"document"`) — `"document"` or `"page"`.
* **`page_ranges`** (optional) — One-based inclusive page ranges. Valid for paged document sources.
* **`unknown_policy`** (optional, default `"allow"`) — `"allow"` returns an unknown unit with its detected `Other / …` label; `"force_best"` requires the classifier to select the best configured class.
* **`output.max_alternatives`** (optional, default `3`) — Maximum ranked labels per unit.
* **`output.include_reason`** (optional, default `true`) — Include model reasons in labels.

**Result:**

```json theme={"dark"}
{
  "result_type": "classify",
  "units": [
    {
      "granularity": "document",
      "page_range": {"start": 1, "end": 3},
      "unknown": false,
      "labels": [
        {
          "class_id": "invoice",
          "subclass_id": "utility",
          "label": "Invoice",
          "rank": 1,
          "confidence": 1.0,
          "reason": "The document includes a supplier, amount due, and invoice number."
        }
      ]
    }
  ]
}
```

**Result fields:**

* **`units`** — One document unit or one unit per page, depending on `granularity`.
* **`page_range`** — Page coverage for the unit, or `null` when not available.
* **`unknown`** — `true` when no provided class was selected. The detected freeform type remains in `labels` under class `other`.
* **`labels`** — Ranked class assignments with their reason and coverage-derived confidence. Confidence is the fraction of the unit's pages or sheets assigned that label, not a calibrated model probability.

***

## Extract

```
POST /v1/extract
```

Extract schema-shaped structured data from a document. Exactly one of `schema_id`
or `schema` must be present.

**Query parameters:**

* **`wait_seconds`** (optional, default 0, max 300)

**Headers:**

* **`Idempotency-Key`** (optional)

**Request body:**

```json theme={"dark"}
{
  "source": {"type": "upload", "upload_id": "550e8400-e29b-41d4-a716-446655440002"},
  "schema": {
    "type": "object",
    "properties": {
      "company_name": {"type": "string"},
      "revenue": {"type": "number"}
    },
    "required": ["company_name"]
  },
  "instructions": "Use the consolidated annual total.",
  "page_ranges": [{"start": 1, "end": 10}],
  "citations": {"enabled": true, "include_source_text": true}
}
```

**Body fields:**

* **`source`** (required) — Document source.
* **`schema_id`** (optional) — A registered schema identifier from
  [`/v1/extraction-schemas`](/api-reference/extraction-schemas). Mutually exclusive with `schema`.
* **`schema_version`** (optional, ≥ 1) — Pin a registry version when using `schema_id`.
  Omit to resolve the latest visible version.
* **`schema`** (optional) — Inline JSON Schema. Mutually exclusive with `schema_id`. Pre-validate with `POST /v1/extract/schema-validation`.
* **`instructions`** (optional) — Additional guidance for finding values declared by the schema. Instructions cannot add undeclared result fields.
* **`page_ranges`** (optional) — One or more 1-indexed inclusive page ranges. Not supported with a `parse_result` source.
* **`citations.enabled`** (optional, default `true`) — Attach per-field citations when values can be located.
* **`citations.include_source_text`** (optional, default `true`) — Include a short source quote in citations.

**Result:**

```json theme={"dark"}
{
  "result_type": "extract",
  "data": {
    "company_name": "Acme Corp",
    "revenue": null
  },
  "fields": [
    {
      "path": "/company_name",
      "value": "Acme Corp",
      "confidence": null,
      "citations": [
        {
          "location": {"kind": "page_region", "page": 1, "polygons": []},
          "source_text": "Acme Corp Annual Report"
        }
      ],
      "status": "found"
    },
    {
      "path": "/revenue",
      "value": null,
      "confidence": null,
      "citations": [],
      "status": "not_found"
    }
  ],
  "schema_id": null,
  "schema_version": null,
  "units": 10,
  "warnings": []
}
```

**Result fields:**

* **`data`** — The extracted object shaped by the submitted schema.
* **`fields`** — One entry per schema field. Status is `found`, `not_found`, or `ambiguous`. A field the model could not locate is explicitly `not_found`, never silently omitted.
* **`fields[*].citations`** — Per-field locations and source text tracing found values back to the source document.
* **`schema_id` / `schema_version`** — Set when Extract resolved a registry schema; null for inline `schema`.
* **`warnings`** — Non-fatal notes about the request, such as instructions that no declared schema field can satisfy.

***

## Extract schema validation

```
POST /v1/extract/schema-validation
```

Validate an extraction schema without creating a job. Synchronous — returns 200 immediately.

**Request body:**

```json theme={"dark"}
{
  "schema": {
    "type": "object",
    "properties": {
      "name": {"type": "string"},
      "age": {"type": "integer"}
    }
  }
}
```

**Body fields:**

* **`schema`** (optional) — Inline JSON Schema to validate. Mutually exclusive with `schema_id`.
* **`schema_id`** (optional) — Registered schema from
  [`/v1/extraction-schemas`](/api-reference/extraction-schemas). Mutually exclusive with `schema`.
* **`schema_version`** (optional, ≥ 1) — Pin a registry version when using `schema_id`.

**Response (valid):**

```json theme={"dark"}
{
  "valid": true,
  "errors": [],
  "normalized_schema": { ... }
}
```

**Response (invalid):**

```json theme={"dark"}
{
  "valid": false,
  "errors": [
    {"path": "", "message": "Unsupported keyword: $ref"}
  ],
  "normalized_schema": null
}
```

Use this before submitting an `extract` job to catch schema errors without consuming processing units.

***

## Ground

```
POST /v1/ground
```

Locate target text in a document. Each target is assessed independently as
`found`, `not_found`, or `failed`, with ranked matches that report how the
location was found.

Ground is target location, not claim verification. It locates target text and
reports location status only.

**Query parameters:**

* **`wait_seconds`** (optional, default 0, max 300)

**Headers:**

* **`Idempotency-Key`** (optional)

**Request body:**

```json theme={"dark"}
{
  "source": {"type": "upload", "upload_id": "550e8400-e29b-41d4-a716-446655440002"},
  "targets": [
    {
      "id": "t1",
      "text": "Net revenue exceeded $1 billion.",
      "hint": "financial highlights",
      "page_hints": [12]
    },
    {
      "id": "t2",
      "text": "more than 50 countries"
    }
  ],
  "options": {
    "max_matches": 10,
    "minimum_semantic_score": 0.7,
    "include_previews": true
  }
}
```

**Body fields:**

* **`source`** (required) — Document source.
* **`targets`** (required) — Array of 1–30 target texts to locate.
  * **`id`** — Caller-assigned identifier, echoed in the result.
  * **`text`** — Text or phrase to locate.
  * **`hint`** (optional) — Nearby text that helps disambiguate matches.
  * **`page_hints`** (optional) — 1-based page numbers to search first.
* **`options.max_matches`** (optional, default 10) — Maximum ranked matches per target.
* **`options.minimum_semantic_score`** (optional) — Minimum score from `0` to `1` for semantic matches.
* **`options.include_previews`** (optional, default `false`) — Generate source-image crops when a visual region is available.

Legacy `claims[]` requests remain accepted during rollout, but new integrations
should send `targets[]`.

**Result:**

```json theme={"dark"}
{
  "result_type": "ground",
  "targets": [
    {
      "id": "t1",
      "status": "found",
      "matches": [
        {
          "rank": 1,
          "matched_text": "Net revenue exceeded $1 billion.",
          "match_method": "normalized",
          "confidence": null,
          "location": {"kind": "text_range", "char_start": 120, "char_end": 180},
          "cropped_image_url": "https://ndi.example.com/v1/jobs/550e8400-e29b-41d4-a716-446655440011/ground-crops/czM6Ly8uLi4"
        }
      ]
    },
    {
      "id": "t2",
      "status": "not_found",
      "matches": []
    }
  ],
  "units": 2
}
```

**Result fields:**

* **`targets`** — One entry per input target, in the same order.
* **`status`** — `found`, `not_found`, or `failed`.
* **`matches`** — Ranked locations for found targets.
* **`match_method`** — `exact`, `normalized`, or `semantic`.
* **`confidence`** — `null` for exact and normalized matches; set only for probabilistic semantic matches.
* **`cropped_image_url`** — Authenticated crop URL when previews were requested and a visual region is available; otherwise `null`. Send the same `X-API-Key` header when fetching it. See [Get a Ground crop](/api-reference/jobs#get-a-ground-crop).

***

## Job lifecycle

All five job-creating endpoints (`parse`, `split`, `classify`, `extract`, `ground`) follow the same pattern:

1. **202 immediately** when `wait_seconds=0` (default). Poll `GET /v1/jobs/{job_id}`.
2. **200 with terminal result** when `wait_seconds=N` and the job finished within N seconds.
3. **202 with running job** when `wait_seconds=N` but the job did not finish in time. Still poll.

Read `status` regardless of whether the response is 200 or 202.

See [Jobs, idempotency, and async semantics](/concepts/jobs-idempotency) for the full job lifecycle.
