---
title: "upload_file"
method: POST
path: "/v1/workspaces/{workspace_id}/files"
tags: ["files"]
---

# upload_file

`POST /v1/workspaces/{workspace_id}/files`

## Request

- Base URL: `https://ndi-api.nace.ai` (the document declares no server; this is the origin it was published from)
- URL: `https://ndi-api.nace.ai/v1/workspaces/{workspace_id}/files`
- Auth: one of:
  - API key in header `X-API-Key`
  - API key in header `X-Upload-Token`
- TypeScript: <https://skmtc.dev/nace/apis/ndi-service/docs/v1/workspaces/:workspace_id/files/post.ts> (a function with typed inputs; its response is checked against the schema)

Upload a source file into the workspace. Cost class: **job**.

The one route with two credentials: the tenant's ``X-API-Key``, or a
short-lived ``X-Upload-Token`` grant minted by ``create_upload_grant`` —
which is how a customer's end client uploads directly without ever
holding the key. A grant redeems the multipart path only, within the
constraints frozen into it (workspace, optional pinned path, byte cap).

Two content paths, per the ``UploadFileRequest`` contract:

* ``multipart/form-data`` — file bytes in a ``file`` part, request
  parameters as a JSON string in a ``metadata`` form field.
* ``application/json`` — an ``UploadFileRequest`` body whose ``source``
  carries a ``UrlSource``; the bytes are fetched server-side and then
  take the exact same storage/ledger path as multipart.

**Upload does NOT ingest.**  The file lands with
``ingestion_status = discovered`` and is not searchable.  Making it
searchable requires a separate ``POST .../ingestions`` call.  This keeps
the cost model honest: a caller can upload a thousand files and choose to
ingest fifty.

Args:
    upload_auth: The resolved workspace context, plus the upload grant
        when the caller redeemed one instead of sending an API key.
    session: Database session.
    request: The raw request, for the JSON (URL-source) content path.
    file: The file bytes, as an ``UploadFile`` from the multipart body.
    metadata: JSON-encoded ``UploadFileRequest`` from the multipart body.
    wait_seconds: Accepted for uniformity; the work is already done.
    idempotency_key: Replaying with the same key returns the original job.
    response: Injected by FastAPI for status-code management.

Returns:
    A ``succeeded`` ``Job`` whose result is an ``UploadFileResult``.

Raises:
    PlatformError: ``invalid_request`` when the body is malformed, the
        metadata JSON is invalid, or a URL source is refused or answers
        with an error status.
    PlatformError: ``path_conflict`` when ``on_conflict="reject"`` and a
        different file already occupies the destination path.
    PlatformError: ``file_too_large`` when the upload exceeds
        ``NDI_MAX_UPLOAD_BYTES`` or the grant's tighter ``max_bytes``.
        Larger tabular files must use ``POST .../upload-sessions``.
    PlatformError: ``access_denied`` when an upload grant is redeemed
        outside its constraints (URL source, or a pinned-path mismatch).
    PlatformError: ``unauthorized`` when a single-use grant is replayed.

## Path parameters

- `workspace_id` string, uuid, required: Workspace identifier.

## Query parameters

- `wait_seconds` integer: Bounded inline wait. 0 (the default) returns 202 immediately. Above 0 the route returns 200 with a terminal job if the work finished in time, and 202 with the still-running job if it did not. Either way, read `status`.

## Headers

- `Idempotency-Key` string, nullable: Replaying a request with the same key returns the original job with 200, never a second job. The key is kept with the job for replay and never cleared, so it must not contain personal data.

## Request body `multipart/form-data`

- BodyUploadFileV1WorkspacesWorkspaceIdFilesPost
  - `file` string, binary, nullable
  - `metadata` string, nullable: JSON-encoded UploadFileRequest. Required for multipart uploads.

## Response `200`

Successful Response

- UploadFileJob
  - `job_id` string, uuid, required: Server-minted id. Poll GET /v1/jobs/{job_id} or pass wait_seconds on create.
  - `kind` 'file_upload'
  - `status` 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled', required: ``cancelled`` is a first-class terminal status, not a ``failed`` variant. Conflating them makes error-rate metrics lie and forces every client to string-match an error code.
  - `name` string, nullable: Display name (the console's 'project'). Public Parse, Split, Classify, Extract, and Ground runs supply a stable server default when blank; citation-generated Ground jobs are excluded. mutable via PATCH /v1/jobs/{job_id}.
  - `project_id` string, uuid, nullable: Caller-minted grouping id shared by jobs submitted together. Filterable on GET /v1/jobs.
  - `workspace_id` string, uuid, nullable: The workspace this job reads. Null for a document operation whose source is an upload, a URL, or a prior parse result.
  - `idempotency_key` string, nullable: Key that was replayed to return this job. Absent when the create call had no Idempotency-Key.
  - `force` boolean, nullable: The request's own `force` flag, echoed regardless of outcome. Present for kinds whose request carries one (e.g. kg_build, where a true value cleared the durable store before the job ran) so a caller can tell that apart from a build that failed leaving the prior store untouched — `result` alone cannot, since it is absent on failure either way.
  - `source` union: What this job read, for the document operations that take a single `source` (parse, split, classify, extract, ground); null for every other kind. Together with `workspace_id` this is what lets a caller re-open the document a past run was about — both `workspace_file` and `upload` sources remain until explicit deletion. Read from the stored request, so it rides the same retention path as `query` and `result`: null once `result_state` leaves `available`.
    - UploadSource: Bytes staged through ``POST /v1/uploads``. Retained until explicit deletion, except under Zero retention.
      - `type` 'upload'
      - `upload_id` string, uuid, required: Id returned by POST /v1/uploads. Ordinary bytes remain until their consuming jobs or tenant are deleted.
    - UrlSource: A public or presigned HTTPS location we fetch server-side.
      - `type` 'url'
      - `url` string, required: HTTPS URL the service fetches.
      - `file_name` string, required: Filename to record for the fetched bytes.
    - WorkspaceFileSource: A file already in a workspace. The single seam between the two contexts: a granular operation may read a workspace file and still write nothing back. The file need **not** be ingested — granular operations read source bytes, not representations.
      - `type` 'workspace_file'
      - `workspace_id` string, uuid, required: Workspace that owns the file.
      - `file_id` string, uuid, required: File id from GET /v1/workspaces/{workspace_id}/files.
    - ParseResultSource: A prior Parse job whose parsed artifact can be reused by downstream methods.
      - `type` 'parse_result'
      - `job_id` string, uuid, required: Succeeded parse job whose artifact should be reused.
  - `query` string, nullable: The question this job was asked, for the search kinds that carry one (intelligent_search, qa_file, qa_tables, filtered_search); null for every other kind. Read from the stored request, so it rides the same retention path as `result`: null once `result_state` leaves `available`. Narrow by design — the frozen access context never reaches the wire. File scope is summarised separately as `query_scope`.
  - `query_scope` union: Where this search ran, summarised for history UIs: the whole workspace, one file path, one folder prefix, or a count of files. Null for non-query jobs, filtered-search page continuations, and once `result_state` leaves `available`. Never lists multiple filenames, never the raw request, and never the frozen API-key id.
    - WorkspaceQueryScope: The question ran against the whole workspace.
      - `type` 'workspace'
    - FileQueryScope: The question ran against one workspace-relative file.
      - `type` 'file'
      - `path` string, required
    - PathPrefixQueryScope: The question ran against one folder subtree.
      - `type` 'path_prefix'
      - `prefix` string, required
    - FilesQueryScope: The question ran against several files; only the count is public.
      - `type` 'files'
      - `count` integer, required
  - `effort` string, nullable: How hard this run worked, for the search kinds that carry it. A deep-search job (kind deep_search_v2) created before the effort knob was removed echoes its requested level: 'low', 'medium', or 'high'; newer deep-search jobs, which all run one configuration, read null. An intelligent_search row carries an internal selector instead: 'fast' is fact-search, 'automatic' is automatic-search, and 'deep' (or the legacy 'balanced') is a deep-search or console QA chat job created before the 2026-09 consolidation — those two are not distinguishable by this field. Null for every other kind. Read from the stored request, so it rides the same retention path as `query` and `result`: null once `result_state` leaves `available`.
  - `surface` 'deep_search' | 'qa' | 'workbook', nullable: Which console history this deep-search job belongs to. `deep_search` is the public route and the Deep Search page; `qa` and `workbook` are the console chat skins over the same agent. It does not pick the implementation. Null for fact-search and every non-deep-search kind. Jobs stored before this field existed echo `deep_search`. Read from the stored payload, so it rides the same retention path as `query`.
  - `progress` JobProgress: Absent until the job is ``running``.
    - `units_total` integer, nullable
    - `units_done` integer
    - `items_total` integer, nullable: Files, for batch kinds.
    - `items_done` integer
    - `items_failed` integer: Quarantined after exhausting retries, for batch kinds. Excluded from items_done.
    - `stage` string, nullable: Current pipeline stage, for display only.
    - `knowledge_graph` KnowledgeGraphBuildProgress: Where a ``kg_build`` is, beyond what ``stage`` alone can say. A graph build runs for minutes to hours through a fixed sequence of phases, and ``stage`` names only the current one. Turning that into "how far along" otherwise means hard-coding the build engine's phase order in every client — an order that lives in another package and is free to gain a phase. So the position is reported rather than inferred. The counters are deliberately not a percentage. The phases are wildly unequal (extraction is ~60% of a build, and its per-document cost varies 25x with how dense the documents are), so weighting them is a presentation decision, made against measurements, that belongs to whoever is drawing the bar.
      - `phase_index` integer, required: 0-based position of the current phase in the build's fixed run order; equals phase_count when done.
      - `phase_count` integer, required: How many phases a build passes through before it is done. Currently 9.
      - `countable` boolean, required: Whether items_total/items_done describe this phase. False for the phases that are a single step, which have no unit to count — draw those as indeterminate rather than as a bar stuck at zero.
      - `documents` integer, nullable: Documents registered from the corpus, once the registering phase has run.
      - `mentions` integer, nullable: Mentions extracted across the corpus, once the validating phase has run.
      - `step` string, nullable: The span in flight inside a single-step phase, reported by the running step itself. Null when the phase counts its own units, or before the step has reported one.
      - `step_done` integer, nullable: Units of `step` finished, when it has countable units.
      - `step_total` integer, nullable: Units `step` has to get through, when that is known.
    - `routing` AutomaticSearchRouting
      - `selected` 'fact' | 'agent' | 'deep' | 'filtered', required: The route the router chose, the one the request's `mode` named, or `fact` when nothing was searched: the workspace had no ingested files, or the query asked for nothing that a document contains.
      - `final` 'fact' | 'agent' | 'deep' | 'filtered', required: The pipeline that produced the returned output. It differs from `selected` only in `auto` mode, when the router chose fact search, fact search could not settle the question, and the agent answered instead.
      - `reason` string, required: The router's explanation of its decision, or why no router ran.
      - `router_model` string, required: The model used to choose the search pipeline; `none` when no router ran.
      - `router` union: Which router decided, and what it measured: an LLM (its explanation is ``reason``), NaceDM (its probability and decision point), or `requested` when no router ran: the request's `mode` was `fact` or `agent`, or the workspace had no ingested files. Results routed before this field existed read as the LLM.
        - AutomaticLlmRouter: An LLM chose the route. Its explanation is the routing's ``reason``.
          - `kind` 'llm'
        - AutomaticNaceDmRouter: NaceDM, the decision model, chose the route by scoring how likely the query needs the agent.
          - `kind` 'nacedm'
          - `p_agent` number, nullable, required: NaceDM's probability that the query needs the investigating agent rather than one fact-search pass; 1 - p_agent is the probability that fact search settles it. Null when the request was too long for NaceDM to score, which routes to the agent.
          - `decision_point` number, required: The agent runs when ``p_agent`` is at or above this; fact search runs below it.
        - AutomaticRequestedRouter: No router ran: the request's ``mode`` named the route, or the workspace had no ingested files.
          - `kind` 'requested'
      - `router_wall_ms` integer, nullable: Wall time spent selecting the initial route.
      - `fact_wall_ms` integer, nullable: Wall time spent running fact search, when selected.
      - `delegated_wall_ms` integer, nullable: Wall time spent preparing and running the native deep or filtered job.
  - `result` UploadFileResult: Also the result of ``JobKind.UPLOAD_SESSION_COMPLETE``. A chunked upload's completion produces exactly this shape — the file it landed, and whether it deduplicated — so it deliberately reuses this result rather than minting a near-identical discriminated variant whose ``result_type`` would just be a second name for the same two fields.
    - `result_type` 'file_upload'
    - `file` File, required: A source file. The derived tree is platform-owned and has no CRUD surface.
      - `file_id` string, uuid, required
      - `workspace_id` string, uuid, required
      - `path` string, required: Full path relative to the workspace root. Send this exact string wherever a tool takes `path`: tools match it exactly.
      - `file_name` string, required: The last segment of `path`, for display only. A tool given it as `path` answers file_not_found.
      - `file_type` string, required
      - `size_bytes` integer, required
      - `observed_hash` string, required
      - `ingested_hash` string, nullable: Content hash at the last successful ingestion. Null with ingested_at means never ingested.
      - `ingestion_status` 'discovered' | 'queued' | 'ingesting' | 'ingested' | 'stale' | 'expired' | 'failed' | 'not_required', required: The only thing that says whether a file is searchable. ``expired`` cannot be derived from the hash pair: retention clears ``ingested_hash``, so an aged-out file and a never-ingested file look identical. They call for different customer actions, so the status carries the distinction the hashes cannot.
      - `ingested_at` string, date-time, nullable
      - `version` integer, required: Starts at 1; increments on every content replacement.
      - `category` string, nullable: Null until categorised.
      - `access_label` string, nullable: Exactly one label, or null when the workspace declares none.
      - `access_label_source` 'inherited' | 'classified' | 'mirrored': Which mechanism produced a document's label. The ``default_label`` fallback records as ``inherited``, so an audit can tell a real classification from a fallback.
      - `expires_at` string, date-time, nullable: When derived material for this file ages out.
      - `graph_inclusion` 'auto' | 'include' | 'exclude': This file's knowledge-graph inclusion policy, not its effective membership. 'auto' defers to the workspace's KG exclusion rules; 'include'/'exclude' override them for this one file. A file excluded by a workspace rule still reads 'auto' here, so this does not on its own tell an excluded file from one whose graph is not built yet — read representations.knowledge_graph for whether a build has covered it.
      - `labels` object: Caller-supplied metadata.
      - `created_at` string, date-time, required
      - `updated_at` string, date-time, required
    - `deduplicated` boolean, required: Identical bytes were already present.
  - `result_state` 'available' | 'expired' | 'not_retained' | 'cancelled' | 'storage_unavailable': Why ``Job.result`` is null. ``available`` with a null result means the job has not succeeded yet; ``expired`` means its content was removed; ``not_retained`` means the client is provisioned ``zero_retention`` and already had its one chance to read it; ``cancelled`` means the job, or the root job it ran for, was cancelled. A cancelled job serves no results (it is not charged, including delivered parts) — not its own progressive values, and not a delegated child's result. It is a read state only, derived on the wire; no row stores it. ``storage_unavailable`` withholds a retained result while its workspace binding refuses access; ``Job.result_error`` explains the current refusal. A late poll returns the job with a state, never a 404 — "this expired" and "this never existed" are a scheduling problem and a bug respectively, and a 404 makes them identical.
  - `result_error` Error: The error object, per spec 12 §1.8.
    - `code` 'storage_connection_not_found' | 'storage_connection_in_use' | 'storage_limit_exceeded' | 'storage_conflict' | 'storage_auth_required' | 'storage_unavailable' | 'scan_required' | 'scan_rejected' | 'scan_unavailable' | 'invalid_request' | 'unsupported_file_type' | 'file_too_large' | 'invalid_schema' | 'invalid_sql' | 'invalid_path_prefix' | 'command_not_permitted' | 'batch_too_large' | 'domain_validation_failed' | 'default_label_required' | 'wait_required_for_zero_retention' | 'ledger_decision_invalid' | 'tenant_required' | 'partner_required' | 'workspace_not_found' | 'file_not_found' | 'job_not_found' | 'upload_not_found' | 'node_not_found' | 'schema_not_found' | 'domain_not_found' | 'session_not_found' | 'access_label_not_found' | 'ledger_understanding_not_found' | 'ledger_table_not_found' | 'tenant_not_found' | 'path_conflict' | 'schema_version_conflict' | 'access_label_name_taken' | 'workspace_name_taken' | 'access_label_in_use' | 'upload_expired' | 'file_not_ingested' | 'workspace_deleting' | 'workspace_not_deletable' | 'ingestion_in_progress' | 'job_not_cancellable' | 'confirmation_required' | 'domain_not_published' | 'domain_in_use' | 'reserved_domain_slug' | 'kg_not_built' | 'kg_build_in_progress' | 'ledger_understanding_in_progress' | 'ledger_understanding_version_outdated' | 'session_busy' | 'tenant_limit_reached' | 'range_not_satisfiable' | 'result_expired' | 'access_denied' | 'tenant_disabled' | 'access_backstop_violation' | 'access_mode_not_implemented' | 'rate_limited' | 'concurrency_limit_reached' | 'quota_exceeded' | 'corrupt_file' | 'zero_byte_file' | 'encrypted_file' | 'csv_dialect_ambiguous' | 'csv_parse_failed' | 'parse_failed' | 'ocr_failed' | 'conversion_failed' | 'sheetless_workbook' | 'unplayable_media' | 'unclassified_pages' | 'job_cancelled' | 'job_timeout' | 'upstream_unavailable' | 'internal_error' | 'unauthorized' | 'not_implemented', required: Every error the ``/v1`` surface can return. Clients must treat this as an **open** enum: a new member is an additive change (spec 12 §9), and a client that crashes on an unknown code makes every future error a breaking change.
    - `message` string, required: Human- and model-readable; says what to do differently.
    - `detail` object, nullable: Structured payload, when the code carries one.
    - `retryable` boolean, required: Whether retrying the identical request could succeed.
    - `request_id` string, nullable: Per-request correlation id; matches the ``X-Request-Id`` header. Null only when the error was minted outside any HTTP request, e.g. by a worker.
  - `error` Error: The error object, per spec 12 §1.8.
    - `code` 'storage_connection_not_found' | 'storage_connection_in_use' | 'storage_limit_exceeded' | 'storage_conflict' | 'storage_auth_required' | 'storage_unavailable' | 'scan_required' | 'scan_rejected' | 'scan_unavailable' | 'invalid_request' | 'unsupported_file_type' | 'file_too_large' | 'invalid_schema' | 'invalid_sql' | 'invalid_path_prefix' | 'command_not_permitted' | 'batch_too_large' | 'domain_validation_failed' | 'default_label_required' | 'wait_required_for_zero_retention' | 'ledger_decision_invalid' | 'tenant_required' | 'partner_required' | 'workspace_not_found' | 'file_not_found' | 'job_not_found' | 'upload_not_found' | 'node_not_found' | 'schema_not_found' | 'domain_not_found' | 'session_not_found' | 'access_label_not_found' | 'ledger_understanding_not_found' | 'ledger_table_not_found' | 'tenant_not_found' | 'path_conflict' | 'schema_version_conflict' | 'access_label_name_taken' | 'workspace_name_taken' | 'access_label_in_use' | 'upload_expired' | 'file_not_ingested' | 'workspace_deleting' | 'workspace_not_deletable' | 'ingestion_in_progress' | 'job_not_cancellable' | 'confirmation_required' | 'domain_not_published' | 'domain_in_use' | 'reserved_domain_slug' | 'kg_not_built' | 'kg_build_in_progress' | 'ledger_understanding_in_progress' | 'ledger_understanding_version_outdated' | 'session_busy' | 'tenant_limit_reached' | 'range_not_satisfiable' | 'result_expired' | 'access_denied' | 'tenant_disabled' | 'access_backstop_violation' | 'access_mode_not_implemented' | 'rate_limited' | 'concurrency_limit_reached' | 'quota_exceeded' | 'corrupt_file' | 'zero_byte_file' | 'encrypted_file' | 'csv_dialect_ambiguous' | 'csv_parse_failed' | 'parse_failed' | 'ocr_failed' | 'conversion_failed' | 'sheetless_workbook' | 'unplayable_media' | 'unclassified_pages' | 'job_cancelled' | 'job_timeout' | 'upstream_unavailable' | 'internal_error' | 'unauthorized' | 'not_implemented', required: Every error the ``/v1`` surface can return. Clients must treat this as an **open** enum: a new member is an additive change (spec 12 §9), and a client that crashes on an unknown code makes every future error a breaking change.
    - `message` string, required: Human- and model-readable; says what to do differently.
    - `detail` object, nullable: Structured payload, when the code carries one.
    - `retryable` boolean, required: Whether retrying the identical request could succeed.
    - `request_id` string, nullable: Per-request correlation id; matches the ``X-Request-Id`` header. Null only when the error was minted outside any HTTP request, e.g. by a worker.
  - `units` integer: Measured work in legacy credit units, retained even when a failed or cancelled job is not billed. Kept for compatibility: `credits` is the authoritative billed amount.
  - `credits` integer, nullable: Final credits billed for this job, priced once under `credit_schedule_version`. Null until `usage_final` is true. Never changes afterwards, including after payload expiry or deletion. Failed and cancelled-before-completion jobs finalize with 0 credits, including delivered parts; ones finalized before that policy keep their credits. Always null on a stage job that Automatic Search delegated: its work is billed on the parent job.
  - `usage_final` boolean: True once `credits` is final and will not change. False while the job is queued or running, and until any cleanup of its delegated stages has been accounted for. A failed tool job (read_file, run_sql, hybrid_search, grep, file_metadata, folder_metadata) created with an Idempotency-Key stays false for one hour after it fails, because a retry with the same key re-runs it; after that hour it is final and the retry returns the stored error.
  - `credit_schedule_version` integer, nullable: Version of the credit schedule `credits` were priced under. Null until `usage_final` is true.
  - `usage_finalized_at` string, date-time, nullable: When `credits` became final. Null until `usage_final` is true.
  - `cost` JobCost: An immutable USD price independent of the legacy credit schedule.
    - `currency` 'USD'
    - `pricing_version` string, required: The dollar price-sheet version pinned when this job was accepted.
    - `total_usd` string, required: Sum of the component amounts in USD, serialized as a decimal string.
    - `lines` CostLine[], required: The independently priced Parse and operation components, including paid reuse.
      - `operation` 'parse' | 'split' | 'classify' | 'extract' | 'ground', required: The work this dollar amount pays for.
      - `quantity` integer, required: The number of page-equivalents or Ground targets priced by this line.
      - `unit` 'page' | 'item', required: The quantity's billing unit: page-equivalent or Ground target.
      - `execution` 'fresh' | 'reused', required: The price tier: normal work or verified, billing-eligible tenant reuse; not provider token caching.
      - `unit_price_usd` string, required: USD per unit under the job's pinned price sheet, serialized as a decimal string.
      - `amount_usd` string, required: This line's USD amount: quantity multiplied by unit_price_usd, serialized as a decimal string.
      - `reuse_reason` 'reused_parse' | 'repeat_of_job', nullable: Why the lower reuse rate applies; null on a normal-price line.
  - `usage` JobUsage: How a job's final credits were computed.
    - `lines` UsageLine[], required: One line per part. Their credits sum to the job's credits; waived parts remain listed at 0 credits.
      - `part` 'parse' | 'operation' | 'image_ocr', required: parse: reading the document into text. operation: the tool's own work on what was parsed. image_ocr: reading the images inside an email, HTML or spreadsheet file. A ground job's image_ocr is the email images it read to find targets its text search missed.
      - `quantity` integer, required: How many units of work this part measured, including waived or included work.
      - `unit` 'page' | 'item' | 'unit' | 'image', required: What quantity counts: page is a page or page-equivalent (a spreadsheet's non-empty cells, a minute of audio), item is a ground item, image is an image Parse read inside an email, HTML or spreadsheet file, unit is a job priced by its units directly (agent steps).
      - `credits_per_unit` integer, required: The credit schedule's rate for one unit of this part; zero for OCR included in the ordinary price.
      - `multiplier` integer, required: 2 on the parse and image_ocr parts of a parse_mode "high" job, otherwise 1.
      - `credits` integer, required: quantity x credits_per_unit x multiplier: what this part added to the job's credits. 0 when waived or included in the ordinary price.
      - `waiver` UsageWaiver: Why part of a job, or the whole job, was not charged.
        - `part` 'parse' | 'job', required: parse: the parse part was not charged. job: nothing in the job was charged.
        - `reason` 'reused_parse' | 'repeat_of_job', required: reused_parse: the job read a parse your earlier job already paid for. repeat_of_job: the job repeated your earlier job exactly and was answered with its result.
        - `source_job_id` string, uuid, nullable: Always null: billing history must not disclose another job as its visibility changes.
  - `created_at` string, date-time, required: When the job row was created.
  - `started_at` string, date-time, nullable: When execution began. Null while still queued.
  - `finished_at` string, date-time, nullable: When the job reached a terminal status.
  - `payload_expires_at` string, date-time, nullable: Cleanup deadline after explicit deletion. Null for retained content; Zero retention uses its delivery window.
  - `deleted_at` string, date-time, nullable: When the caller hid this run from listings, via `delete_job`. A soft delete: the job still reads by id and still counts toward `GET /v1/usage` — what it stops doing is appearing in `GET /v1/jobs`.

## Other responses

- `400`: Bad Request
- `401`: Unauthorized
- `402`: Payment Required
- `403`: Forbidden
- `404`: Not Found
- `409`: Conflict
- `410`: Gone
- `413`: Request Entity Too Large
- `422`: Unprocessable Entity
- `424`: Failed Dependency
- `429`: Too Many Requests
- `500`: Internal Server Error
- `501`: Not Implemented
- `503`: Service Unavailable
- `504`: Gateway Timeout

---

[API](https://skmtc.dev/nace/apis/ndi-service.md) - [All operations](https://skmtc.dev/nace/apis/ndi-service/llms.txt) - [OpenAPI document](https://skmtc.dev/nace/apis/ndi-service/revisions/d9d1f8ec2207?raw)
