Perception guides

Jobs and results

Read a job until it finishes, download the files it produced, and list or delete jobs.

Every document operation creates a job. Its job_id is what the routes below take. The SDKs and the CLI wrap each of them; see In the SDKs and the CLI.

Read a job

Poll the job until status is succeeded, failed or cancelled:

curl https://console.nace.ai/v1/documents/jobs/$JOB_ID \
  -H "Authorization: Bearer $NACE_API_KEY"

The body is the document service's job with a Drex customer charge in cost, and its result once it has succeeded. Classify may include a partial result while it is still running, so wait for a terminal status before you treat a result as final. The result's shape depends on the operation: see Parse, Split, Classify, Extract and Ground.

To skip polling for short jobs, add ?wait_seconds=60 when you create the job. See Wait in the same call. The SDKs' jobs.wait polls for you, starting at 1 second and backing off to 10, for up to 600 seconds by default. A job that ends failed or cancelled raises NaceJobFailedError (Python) or JobFailedError (TypeScript), unless you turn that off. When the wait runs out, it raises NaceJobTimeoutError or JobTimeoutError, and the job keeps running.

A job id that isn't yours answers 404, exactly like one that doesn't exist. Workspace jobs read here too: any your account started, even after its workspace is deleted, and any in a workspace you can use, including the Ground jobs a search starts. They don't appear in List jobs, and Delete a job answers 404 for them.

Zero retention

An account on zero retention (an owner or admin turns it on under Settings, Data retention) gets each result once, in the response to the request that creates the job. The result isn't stored afterwards, so there is nothing to poll for.

Create with a wait. wait_seconds is required, and how long it can be depends on how you call:

Calling throughwait_secondsWithout it
HTTP1 to 100. A longer wait is held to 100.422 with code wait_required_for_zero_retention, before any job is created or billed. 0 is refused the same way.
The SDKs and the CLI1 to 60They return at once by default, which gets that error.
The MCP server1 to 60It waits 30 seconds by default, which a long document can outlast.

What you get back.

The jobAnswerWhat happens
Finishes inside the wait200The response has its result. Keep it.
Is still running when the wait ends202It keeps running and is billed when it finishes, but its result isn't kept and can't be fetched. Create it again with a longer wait, or split the document. The new job is billed too.
Is read laterNot changedresult is null and result_state is "not_retained", and there is no source, request or download links. Its status, credits and usage_final stay. Its usage is billed like any other job's.

Files. The files a result links to (page images, figure and ground crops, converted PDFs, a sheet's rows and content, a large document's content, split segment files) stay downloadable for about 21 minutes after the job finishes. Then the links close and answer 410 with code result_expired, so download what you need right away. Closing a link does not delete anything in your own S3 or Azure storage, and Zero retention never deletes your source files: you manage their lifecycle in your storage provider.

What doesn't work.

  • A parse_result source can't name a job from a zero-retention account.
  • Retrying a create with the same Idempotency-Key after a lost response returns the job, not its result: the result went to the response you lost. The job is billed. Create it again with a new key to get a result, and that job is billed too.
  • jobs.wait and polling still give the status and usage metadata, but they return result as None or null. Use the create response.

What else is kept, and for how long, is on Data retention.

The job object

FieldWhat it holds
job_idThe id every route on this page takes.
kindThe operation: parse, split, classify, extract or ground, or a workspace job such as a workspace delete, an ingestion or a search.
statusqueued, running, succeeded, failed or cancelled. The last three are final.
nameThe name you gave the job, or a default the document service picks.
project_idThe project_id you gave the job, or null.
sourceThe source the job read. null once result_state isn't available.
workspace_idThe workspace of a workspace_file source, or the workspace a workspace job ran on. null for a url or parse_result source.
progressWhile running, how far it got (units_done, units_total, stage). null otherwise.
resultThe result, usually once succeeded, until explicit deletion. Classify may include a partial result while still running, with reason_status (pending, complete, failed or not_requested); a result without reason_status is complete.
result_stateavailable, or why a finished job's result is null: expired or not_retained.
errorWhy a failed job failed: code, message, retryable and, for some codes, detail.
credits, usage_finalMetered usage, final once usage_final is true. See Usage fields.
created_atISO 8601 time when NDI created the job; non-null on a normal job response.
started_atISO 8601 execution start, or null while queued or if the job ended before execution began. Can be omitted while Drex is waiting for settlement, or when Drex closes a job as cancelled for funding even though NDI ran it.
finished_atISO 8601 time when NDI reached succeeded, failed or cancelled; null while NDI is queued or running. Can be omitted while Drex is waiting for settlement.
timingServer measurements for every finished newly measured document kind. Historical/unmeasured jobs and fallback list items may return null or omit it. See Job timing.
payload_expires_atCleanup deadline after explicit deletion; normally null.
deleted_atWhen you deleted the job, or null. A deleted job still reads by id.
idempotency_keyDrex's id for the charge, the same as the create's x-drex-document-job header. It isn't the Idempotency-Key you sent.

Job timing

The lifecycle timestamps above belong to the NDI job and appear on normal create, read and list responses. started_at can remain null after a failure or cancellation before execution; a terminal status alone does not guarantee that subtraction is possible. During settlement Drex can return a reduced running response without started_at or finished_at. A job Drex closes as cancelled for funding (for example budget_exhausted) keeps finished_at but omits started_at, even if NDI ran it, so a missing start does not always mean the job never ran. If NDI cannot be read while listing jobs, a fallback list item has the stored Drex admission time in created_at and omits started_at, finished_at and timing. Older responses or SDK versions may also lack fields: handle both an absent field and null.

When both endpoints are available, finished_at - created_at measures NDI queue plus processing, and finished_at - started_at measures NDI processing alone. Neither includes Drex admission or final result preparation. Do not describe the second subtraction as including queue time.

Newly admitted Parse, Extract, Split, Classify and Ground jobs carry a timing object on create, individual reads and live list items, including every finished job. It uses the original request received by Drex, preserved on replay, rather than the latest poll's start. It excludes upload, client network/rendering and idle time awaiting a later client read. Historical or unmeasured jobs and fallback list items may omit the object or return null; Python exposes either as None. Individual unavailable measurements remain null, never an invented zero.

FieldMeaning and availability
timing.received_atOriginal Drex request receipt, ISO 8601; present whenever timing is present.
timing.observed_atISO 8601 server time of this snapshot; present whenever timing is present.
timing.completed_atFirst terminal response preparation time; null until Drex prepares a terminal response. Later reads preserve it.
timing.elapsed_msLive elapsed estimate while pending; frozen total_ms after completion. null if the duration cannot be computed.
timing.total_msFrozen total server duration; null until completion or if required timing endpoints are unavailable or inconsistent.
timing.first_response_msOriginal request to the first usable server-published Parse preview; null until measured. Parse uses the total when the server confirms no preview was published; unavailable telemetry can leave it null after success. Extract, Split, Classify and Ground use final total; this does not separately measure Classify's partial labels. Waiting for a complete SDK result does not select a delivery mode.
timing.breakdown.preparation_msOriginal receipt to NDI creation; null if either endpoint is unavailable or inconsistent.
timing.breakdown.queue_msNDI creation to execution start; null before start or if either timestamp is unavailable or inconsistent.
timing.breakdown.ndi_processing_msNDI start to finish, including document finalization; null before finish or without valid start/finish timestamps.
timing.breakdown.result_preparation_msActive final-result request work after NDI finishes, including transfer and settlement; null until its endpoints are measured.
timing.breakdown.delivery_wait_msIdle interval after NDI finishes and before the final read begins; null until completion, then zero if there was no measured idle interval. Excluded from total_ms.
timing.breakdown.document_finalization_msAll requested Parse previews published to NDI finish; null without both milestones. Always null for other document kinds. Already included in NDI processing.

Durations are milliseconds. With complete measurements, total_ms = preparation_ms + queue_ms + ndi_processing_ms + result_preparation_ms (allowing millisecond rounding). Do not add delivery_wait_ms or document_finalization_ms again. A null component is unknown, even on a successful job; it does not mean that stage took no time. The Python and TypeScript type references describe access from each SDK. See measured Parse latency for dated examples.

Usage fields

cost is the recorded Drex wallet charge, separate from NDI’s service tariff and operating costs. It is null while settlement or component measurements are pending. A known zero is a decimal string, not an unknown charge. Historical credit-priced jobs can return an empty line list because their component breakdown was not recorded.

cost.currency is "USD"; pricing_version identifies the pinned customer pricebook; total_usd is the exact decimal total. Each entry in lines has operation, quantity, unit ("page" or "target"), execution ("fresh" or "reused"), unit_price_usd, amount_usd and nullable reuse_reason. Amounts are decimal strings. The Python and TypeScript SDKs type it as DocumentCost from nace-sdk 0.4.0 (Python, TypeScript).

The public API retains units and credits for metering compatibility. units describes standard-rate usage; credits includes high-effort processing and reused-parse adjustments and is final when usage_final is true. On some Extract and Ground jobs credits also counts OCR of embedded images, which is included in the price and not charged; cost.lines lists only billed pages or targets. These are not USD amounts. The settled charge uses the job’s pinned price version; the console shows that recorded USD charge, not a conversion at today’s prices. See Pricing for per-page and per-target billing.

Progress events

GET /v1/documents/jobs/{id}/events streams Server-Sent Events. Each frame is { "sequence", "at", "status", "progress", "message" }, with sequence as its SSE id. The first frame is the job's current state, and a new one comes whenever status or progress changes. The stream ends when the job finishes, or after 5 minutes. To reconnect, send the last sequence as Last-Event-ID to keep the numbering going.

Events are for showing progress, not for deciding the outcome: read the job to know how it ended.

Stream a job's events:

curl -N https://console.nace.ai/v1/documents/jobs/$JOB_ID/events \
  -H "Authorization: Bearer $NACE_API_KEY" \
  -H "Accept: text/event-stream"

Files a job produced

Links in a result to the job's own files point at https://console.nace.ai/v1/documents/jobs/{id}/...: figure crops, transcripts, ground crops, converted PDFs, cell maps, full document content and spreadsheet rows. Fetch them with the same API key. Use the link from the result as it is; you don't build these paths yourself.

A result links a file only when the job made it:

FileIn the result when
Full document or sheet content (content_url)The content is over 1 MiB. See Very large results.
Parquet rows (rows_url)The source is a Parquet file.
Figure crops (image_url)Parse ran with output.include_images.
Ground cropsGround ran with options.include_previews.
Split segment filesSplit ran with output.materialize_files.
Converted PDFThe source is an Office file.
TranscriptThe source is audio or video.
Cell mapThe source is a workbook.
FileHow it answers
Stored files: split segment files, figure crops, transcripts, ground crops, converted PDFs, cell maps302 to a signed storage link that works for 5 minutes without a key. With ?redirect=false, 200 with { "url", "expires_at" } instead. When the document service can't sign links, 200 with the file's bytes, streamed, even with ?redirect=false.
Full document or sheet content (document.content_url, a sheet's content_url)200 with the Markdown, streamed. 202 with { "status": "pending", "retry_after_seconds" } and retry-after while it's being prepared. 409 when it couldn't be prepared. See Very large results.
Parquet rows (a Parquet sheet's rows_url)200 with one page of rows. See Very large results.
request200 with the request the job ran under, tagged with request_type (the job's kind). A parse password is never returned. It is removed with the result.
eventsThe event stream above.
  • Following a redirect: let your HTTP client follow the 302, and don't forward your API key to it: storage refuses a request that carries one, and most clients drop Authorization on a cross-origin redirect anyway.
  • Handing a file to a browser: the /v1 routes can't be called from a browser, so ask for the link on your server with ?redirect=false and give the browser the url. Images, PDFs and JSON open in place; anything else downloads. An answer that isn't JSON is the file itself: serve it from your server.
  • Rate limits: file downloads don't count against your per-minute limit. The request echo and the event stream use the independent job-read window, which cannot consume create capacity. See Rate limits.
  • Deletion: ordinary results and their files have no automatic expiry. After explicit deletion or Zero retention cleanup, a link answers 404 or 410 (code result_expired).

Examples

Each takes a link from the job's result, or the part after the job id, as PATH. Save a file:

curl -L https://console.nace.ai/v1/documents/jobs/$JOB_ID/parse-images/<ref> \
  -H "Authorization: Bearer $NACE_API_KEY" \
  -o figure-1.png

Get a stored file's signed link, to hand to a browser:

curl "https://console.nace.ai/v1/documents/jobs/$JOB_ID/parse-images/<ref>?redirect=false" \
  -H "Authorization: Bearer $NACE_API_KEY"
# {"url":"https://...","expires_at":1760000000}

Read the request the job ran under:

curl https://console.nace.ai/v1/documents/jobs/$JOB_ID/request \
  -H "Authorization: Bearer $NACE_API_KEY"

Read a page of a parsed Parquet file's rows, from its sheet's rows_url:

curl "$ROWS_URL?start_row=0&limit=100" \
  -H "Authorization: Bearer $NACE_API_KEY"

List jobs

GET /v1/documents/jobs lists your jobs, newest first by when Drex received each request, without their results. created_at is when NDI created the job (the stored Drex admission time on a fallback item), so two jobs sent within a moment of each other can list a little out of created_at order.

curl "https://console.nace.ai/v1/documents/jobs?operation=parse&status=succeeded&limit=20" \
  -H "Authorization: Bearer $NACE_API_KEY"
QueryWhat it does
limit1 to 50, default 20.
operationOnly jobs of one operation, such as parse.
statusOnly jobs in one status, as Drex last recorded it. A job whose status has changed since is left off the page, so a filtered page can hold fewer than limit jobs, even none, while next_cursor is set.
cursorThe previous page's next_cursor. Keep going until it's null.

A job joins the list once its create request has answered, so one created with wait_seconds appears only after that wait, at its creation time. A listing that has already paged past that time doesn't include it.

Delete a job

If a submission was refused before NDI created a job, delete the local ID from its x-drex-document-job response header through the same route. This erases its retained request and error.

DELETE /v1/documents/jobs/{id} answers 204. A job that's still running is cancelled first; a finished job is taken off the list and its content is queued for erasure. The job record stays readable by id for status and billing. A job cancelled this way is not charged, including delivered parts; a finished job keeps the charge it already had. It also cancels a recorded workspace processing job that your account started directly through the API or dashboard, even after the workspace is deleted or your access ends. Reading another account's job in a shared workspace never lets you cancel it, a workspace or file deletion can't be cancelled, and an automatically chained child job isn't recorded as yours (all 404).

curl -X DELETE https://console.nace.ai/v1/documents/jobs/$JOB_ID \
  -H "Authorization: Bearer $NACE_API_KEY"

In the SDKs and the CLI

RoutePython and TypeScriptCLI
Read a jobjobs.get, and jobs.wait to poll until it finishesnace job ID, with --wait to wait
List jobsjobs.list for one page, jobs.iter for every jobnace jobs, with --cursor or --all
Delete a jobjobs.deletenace cancel ID
Progress eventsjobs.eventsnace events ID
A stored file's signed linkjobs.file_link / jobs.fileLink (stored files only)nace file ID PATH --link
Any filejobs.download (Python saves to a path), jobs.fetchFile and jobs.download (TypeScript)nace file ID PATH
Spreadsheet rowsjobs.rowsnace rows ID PATH
The request echojobs.get_request / jobs.getRequestnace job-request ID

Each takes the link from the result as PATH, or the part after the job id. Downloads wait for content that's still being prepared, for up to 600 seconds by default. Every parameter is on the Python, TypeScript and CLI pages.

Parse previews

The separate parse-progress snapshot, parse-progress/events stream and parse-progress/parts/{generation}/{revision} content routes show ready pages or sheets before final delivery. events above streams status only. See streaming Parse for the full contract, SDK examples, server timing and finalization behavior.

On this page