Skip to main content
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

pending ──▶ running ──▶ succeeded
                   └──▶ failed
StatusMeaning
pendingAccepted; waiting for a worker.
runningBeing processed.
succeededDone — result is populated.
failedTerminal 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)
Returns200 with the full job (including result) once finished202 with {"job_id": "..."} immediately
Best forSmall documents, interactive flowsLarge documents, batch pipelines
Wait budget?timeout_seconds= — default 120, min 1, max 300n/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:
The body is the full job object. Check status: it is succeeded (read result) or failed (read error).
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.
The job keeps running. The error detail includes the job_id (api_request_id); poll GET /jobs/{job_id} for the result.

Polling

Poll GET /jobs/{job_id} until status is terminal:
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):
{
  "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.
Always set an idempotency key in production pipelines. It converts “did my request go through?” from a dangerous unknown into a free replay.

Listing and cancelling

  • GET /jobs lists your jobs newest-first with cursor pagination and an optional status filter. Summaries only — no result payloads.
  • POST /jobs/{job_id}/cancel 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.