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 through | wait_seconds | Without it |
|---|---|---|
| HTTP | 1 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 CLI | 1 to 60 | They return at once by default, which gets that error. |
| The MCP server | 1 to 60 | It waits 30 seconds by default, which a long document can outlast. |
What you get back.
| The job | Answer | What happens |
|---|---|---|
| Finishes inside the wait | 200 | The response has its result. Keep it. |
| Is still running when the wait ends | 202 | It 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 later | Not changed | result 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_resultsource can't name a job from a zero-retention account. - Retrying a create with the same
Idempotency-Keyafter 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.waitand polling still give the status and usage metadata, but they returnresultasNoneornull. Use the create response.
What else is kept, and for how long, is on Data retention.
The job object
| Field | What it holds |
|---|---|
job_id | The id every route on this page takes. |
kind | The operation: parse, split, classify, extract or ground, or a workspace job such as a workspace delete, an ingestion or a search. |
status | queued, running, succeeded, failed or cancelled. The last three are final. |
name | The name you gave the job, or a default the document service picks. |
project_id | The project_id you gave the job, or null. |
source | The source the job read. null once result_state isn't available. |
workspace_id | The workspace of a workspace_file source, or the workspace a workspace job ran on. null for a url or parse_result source. |
progress | While running, how far it got (units_done, units_total, stage). null otherwise. |
result | The 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_state | available, or why a finished job's result is null: expired or not_retained. |
error | Why a failed job failed: code, message, retryable and, for some codes, detail. |
credits, usage_final | Metered usage, final once usage_final is true. See Usage fields. |
created_at | ISO 8601 time when NDI created the job; non-null on a normal job response. |
started_at | ISO 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_at | ISO 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. |
timing | Server 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_at | Cleanup deadline after explicit deletion; normally null. |
deleted_at | When you deleted the job, or null. A deleted job still reads by id. |
idempotency_key | Drex'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.
| Field | Meaning and availability |
|---|---|
timing.received_at | Original Drex request receipt, ISO 8601; present whenever timing is present. |
timing.observed_at | ISO 8601 server time of this snapshot; present whenever timing is present. |
timing.completed_at | First terminal response preparation time; null until Drex prepares a terminal response. Later reads preserve it. |
timing.elapsed_ms | Live elapsed estimate while pending; frozen total_ms after completion. null if the duration cannot be computed. |
timing.total_ms | Frozen total server duration; null until completion or if required timing endpoints are unavailable or inconsistent. |
timing.first_response_ms | Original 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_ms | Original receipt to NDI creation; null if either endpoint is unavailable or inconsistent. |
timing.breakdown.queue_ms | NDI creation to execution start; null before start or if either timestamp is unavailable or inconsistent. |
timing.breakdown.ndi_processing_ms | NDI start to finish, including document finalization; null before finish or without valid start/finish timestamps. |
timing.breakdown.result_preparation_ms | Active final-result request work after NDI finishes, including transfer and settlement; null until its endpoints are measured. |
timing.breakdown.delivery_wait_ms | Idle 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_ms | All 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:
| File | In 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 crops | Ground ran with options.include_previews. |
| Split segment files | Split ran with output.materialize_files. |
| Converted PDF | The source is an Office file. |
| Transcript | The source is audio or video. |
| Cell map | The source is a workbook. |
| File | How it answers |
|---|---|
| Stored files: split segment files, figure crops, transcripts, ground crops, converted PDFs, cell maps | 302 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. |
request | 200 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. |
events | The 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 dropAuthorizationon a cross-origin redirect anyway. - Handing a file to a browser: the
/v1routes can't be called from a browser, so ask for the link on your server with?redirect=falseand give the browser theurl. 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
404or410(coderesult_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.pngGet 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"| Query | What it does |
|---|---|
limit | 1 to 50, default 20. |
operation | Only jobs of one operation, such as parse. |
status | Only 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. |
cursor | The 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
| Route | Python and TypeScript | CLI |
|---|---|---|
| Read a job | jobs.get, and jobs.wait to poll until it finishes | nace job ID, with --wait to wait |
| List jobs | jobs.list for one page, jobs.iter for every job | nace jobs, with --cursor or --all |
| Delete a job | jobs.delete | nace cancel ID |
| Progress events | jobs.events | nace events ID |
| A stored file's signed link | jobs.file_link / jobs.fileLink (stored files only) | nace file ID PATH --link |
| Any file | jobs.download (Python saves to a path), jobs.fetchFile and jobs.download (TypeScript) | nace file ID PATH |
| Spreadsheet rows | jobs.rows | nace rows ID PATH |
| The request echo | jobs.get_request / jobs.getRequest | nace 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.