> ## 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.

# Jobs: sync, async & idempotency

> How work runs in NDI — the job lifecycle, polling, and safe retries

Every method call — parse, ground, categorize — creates a **job**. The job is the unit you poll, list, and cancel; results are attached to it.

## Job lifecycle

```text theme={"dark"}
pending ──▶ running ──▶ succeeded
                   └──▶ failed
```

| Status      | Meaning                                                                                                                       |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `pending`   | Accepted; waiting for a worker.                                                                                               |
| `running`   | Being processed.                                                                                                              |
| `succeeded` | Done — `result` is populated.                                                                                                 |
| `failed`    | Terminal failure — `error.code` / `error.message` explain why. Cancelled jobs also land here with `error.code = "cancelled"`. |

`succeeded` and `failed` are terminal; a job never leaves them.

## Sync vs. async

Every method comes as a pair:

|             | Sync (`POST /parse`)                                       | Async (`POST /parse_async`)                |
| ----------- | ---------------------------------------------------------- | ------------------------------------------ |
| Returns     | `200` with the full job (including `result`) once finished | `202` with `{"job_id": "..."}` immediately |
| Best for    | Small documents, interactive flows                         | Large documents, batch pipelines           |
| Wait budget | `?timeout_seconds=` — default 120, min 1, max 300          | n/a                                        |

**The job always starts, in both variants.** Sync only changes whether the connection waits for it.

A sync call can return three shapes, and a robust client handles all of them:

<AccordionGroup>
  <Accordion title="200 — finished within the timeout">
    The body is the full job object. Check `status`: it is `succeeded` (read `result`) or `failed` (read `error`).
  </Accordion>

  <Accordion title="202 — started, but no sync capacity right now">
    Under load the server starts your job but declines to hold the connection.
    The body is `{"job_id": "..."}` — poll `GET /jobs/{job_id}` exactly as if
    you had called the async variant.
  </Accordion>

  <Accordion title="408 — didn't finish within timeout_seconds">
    The job **keeps running**. The error detail includes the `job_id`
    (`api_request_id`); poll `GET /jobs/{job_id}` for the result.
  </Accordion>
</AccordionGroup>

## Polling

Poll [`GET /jobs/{job_id}`](/api-reference/get-job) until `status` is terminal:

```python theme={"dark"}
import time, httpx

def wait_for_job(client: httpx.Client, job_id: str, interval: float = 2.0) -> dict:
    while True:
        job = client.get(f"/jobs/{job_id}").json()
        if job["status"] in ("succeeded", "failed"):
            return job
        time.sleep(interval)
```

A 2–5 second interval is plenty; polling endpoints are never rate-limited. Webhooks are on the roadmap — polling is the delivery mechanism today.

## Idempotency

Network failures make retries inevitable. To retry a POST safely, set an `idempotency_key` (any string up to 128 characters — a UUID you mint per logical operation works well):

```json theme={"dark"}
{
  "file": { "source_url": "ndi://file/7f4d2a10-..." },
  "idempotency_key": "invoice-batch-2026-07-12-item-0042"
}
```

Repeating a POST with the same key and the same method **never creates a second job**. Instead you get a `200` with the *existing* job's current state — which may be `pending`, `running`, or already `succeeded` with the result inline. (On the sync endpoints, a replay of a still-running job waits for it, preserving the sync contract.)

Keys are scoped to your tenant and to the method: the same key on `/parse` and `/ground` refers to two different jobs.

<Tip>
  Always set an idempotency key in production pipelines. It converts "did my request go through?" from a dangerous unknown into a free replay.
</Tip>

## Listing and cancelling

* [`GET /jobs`](/api-reference/list-jobs) lists your jobs newest-first with cursor pagination and an optional `status` filter. Summaries only — no result payloads.
* [`POST /jobs/{job_id}/cancel`](/api-reference/cancel-job) cancels a pending/running job. There is no separate `cancelled` status: the job lands in `failed` with `error.code = "cancelled"`. Cancelling an already-terminal job is a no-op that returns its current state.
