---
title: "delete_tenant"
method: DELETE
path: "/v1/tenants/{external_ref}"
tags: ["tenants"]
---

# delete_tenant

`DELETE /v1/tenants/{external_ref}`

## Request

- Base URL: `https://ndi-api.nace.ai`
- URL: `https://ndi-api.nace.ai/v1/tenants/{external_ref}`
- Auth: API key in header `X-API-Key`
- TypeScript: <https://skmtc.dev/nace/apis/ndi-platform-api/docs/v1/tenants/:external_ref/delete.ts> (a function with typed inputs; its response is checked against the schema)

Disable the tenant and keep its data (default), or with ``purge=true`` delete all of it. Cost class: write.

A disabled tenant is re-enabled with ``PUT`` and ``disabled: false``. A purge cannot be undone: the
tenant is disabled at once, its jobs are cancelled, and every workspace, file, result, cache entry and
key is deleted. Only the content-free billing record (job ids, kinds, timestamps, units, credits) is
kept. A purge already queued or running is returned as is; one that failed or ended unverified is
started again. Once a purge has verified clean the ``external_ref`` no longer resolves, so a repeat of
this call answers ``tenant_not_found`` and a later ``PUT`` creates a new, empty tenant.

## Path parameters

- `external_ref` string, required: The partner's own reference for the tenant, such as its account id.

## Query parameters

- `purge` boolean: True permanently deletes the tenant's data instead of disabling it. Answers 202 with a `tenant_purge` job to poll on GET /v1/jobs/{job_id} without X-NDI-Tenant.

## Response `200`

Successful Response

- union
  - Tenant: One of the partner's child tenants. Send ``X-NDI-Tenant: ext:<external_ref>`` to act for it.
    - `created_at` string, date-time, required: When the tenant was created.
    - `data_retention` 'short' | 'zero_retention', required: Retention for document operations — a **client** setting, not a request field. A data-handling guarantee a caller can accidentally opt out of on one endpoint is not a guarantee, so this is configured per client at onboarding and appears in no request body.
    - `disabled` boolean, required: Whether the tenant is disabled. Its data is kept.
    - `display_name` string, nullable, required: Human-readable name of the tenant.
    - `external_ref` string, required: The partner's own reference for the tenant, unique per partner.
    - `id` string, uuid, required: NDI's id for the tenant; X-NDI-Tenant accepts it too.
    - `limits` TenantLimits, required: The limits in force for a tenant.
      - `max_concurrent_jobs` integer, required: Parse, split, classify, categorize, extract, ground and agent jobs this tenant may have running at once; more wait in the queue. NDI's own per-tenant cap applies when it is lower.
      - `rpm` integer, required: Requests per minute this tenant may make.
  - TenantPurgeJob
    - `cost` JobCost: An immutable USD price independent of the legacy credit schedule.
      - `currency` 'USD'
      - `lines` CostLine[], required: The independently priced Parse and operation components, including paid reuse.
        - `amount_usd` string, required: This line's USD amount: quantity multiplied by unit_price_usd, serialized as a decimal string.
        - `execution` 'fresh' | 'reused', required: The price tier: normal work or verified, billing-eligible tenant reuse; not provider token caching.
        - `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.
        - `reuse_reason` 'reused_parse' | 'repeat_of_job', nullable: Why the lower reuse rate applies; null on a normal-price line.
        - `unit` 'page' | 'item', required: The quantity's billing unit: page-equivalent or Ground target.
        - `unit_price_usd` string, required: USD per unit under the job's pinned price sheet, serialized as a decimal string.
      - `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.
    - `created_at` string, date-time, required: When the job row was created.
    - `credit_schedule_version` integer, nullable: Version of the credit schedule `credits` were priced under. Null until `usage_final` is true.
    - `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.
    - `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`.
    - `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`.
    - `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.
      - `detail` object, nullable: Structured payload, when the code carries one.
      - `message` string, required: Human- and model-readable; says what to do differently.
      - `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.
      - `retryable` boolean, required: Whether retrying the identical request could succeed.
    - `finished_at` string, date-time, nullable: When the job reached a terminal status.
    - `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.
    - `idempotency_key` string, nullable: Key that was replayed to return this job. Absent when the create call had no Idempotency-Key.
    - `job_id` string, uuid, required: Server-minted id. Poll GET /v1/jobs/{job_id} or pass wait_seconds on create.
    - `kind` 'tenant_purge'
    - `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}.
    - `payload_expires_at` string, date-time, nullable: Cleanup deadline after explicit deletion. Null for retained content; Zero retention uses its delivery window.
    - `progress` JobProgress: Absent until the job is ``running``.
      - `items_done` integer
      - `items_failed` integer: Quarantined after exhausting retries, for batch kinds. Excluded from items_done.
      - `items_total` integer, nullable: Files, for batch kinds.
      - `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.
        - `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.
        - `phase_count` integer, required: How many phases a build passes through before it is done. Currently 9.
        - `phase_index` integer, required: 0-based position of the current phase in the build's fixed run order; equals phase_count when done.
        - `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
        - `delegated_wall_ms` integer, nullable: Wall time spent preparing and running the native deep or filtered job.
        - `fact_wall_ms` integer, nullable: Wall time spent running fact search, when selected.
        - `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` 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.
            - `decision_point` number, required: The agent runs when ``p_agent`` is at or above this; fact search runs below it.
            - `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.
          - AutomaticRequestedRouter: No router ran: the request's ``mode`` named the route, or the workspace had no ingested files.
            - `kind` 'requested'
        - `router_model` string, required: The model used to choose the search pipeline; `none` when no router ran.
        - `router_wall_ms` integer, nullable: Wall time spent selecting the initial route.
        - `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.
      - `stage` string, nullable: Current pipeline stage, for display only.
      - `units_done` integer
      - `units_total` integer, nullable
    - `project_id` string, uuid, nullable: Caller-minted grouping id shared by jobs submitted together. Filterable on GET /v1/jobs.
    - `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.
        - `path` string, required
        - `type` 'file'
      - PathPrefixQueryScope: The question ran against one folder subtree.
        - `prefix` string, required
        - `type` 'path_prefix'
      - FilesQueryScope: The question ran against several files; only the count is public.
        - `count` integer, required
        - `type` 'files'
    - `replayed` boolean: True only on an idempotent replay response. No new charge is made; credits remains the original job's cost.
    - `result` TenantPurgeResult: The tenant's content is gone; no row is deleted: its billing record and metadata rows stay, de-identified. ``verified`` is true only when every store reports nothing remaining. An unfinished cancelled job counts against ``jobs`` and a workspace teardown that could not verify its own storage, index and graph counts against ``workspaces``. A purge that ends unverified is a ``succeeded`` job on purpose, like a workspace delete: the report says what is left, and a second DELETE re-runs it.
      - `billing_record_kept` boolean, required: True when the content-free billing record (job ids, kinds, timestamps, units, credits) was kept.
      - `external_ref` string, required: The partner's own reference the tenant had. It stops resolving once the purge succeeds.
      - `result_type` 'tenant_purge'
      - `stores` TenantPurgeStoreReport[], required: One line per store, with what the verification pass still found.
        - `remaining` integer, required: Rows that still hold the tenant's content or identifying data or still grant access, objects, and open executions still found there. Kept rows that are already emptied do not count. Zero when clean.
        - `store` 'jobs' | 'workspaces' | 'files' | 'upload_sessions' | 'profile_cache' | 'answer_cache' | 'catalog' | 'ledger_decisions' | 'parse_exports' | 'webhooks' | 'api_keys' | 'members' | 'users' | 'cli_sessions' | 'limit_overrides' | 'schemas_and_labels' | 'perception_cache' | 'storage' | 'redis', required: One place tenant data lives, and so one line of a purge's verification report. A purge never deletes a Postgres row: each store's rows are emptied or de-identified and marked deleted, and only stored objects and Redis keys are removed.
      - `tenant_id` string, uuid, required: The tenant whose data was purged, as NDI identifies it.
      - `verified` boolean, required: True when no content of the tenant remains anywhere and every kept metadata row is de-identified.
    - `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.
      - `detail` object, nullable: Structured payload, when the code carries one.
      - `message` string, required: Human- and model-readable; says what to do differently.
      - `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.
      - `retryable` boolean, required: Whether retrying the identical request could succeed.
    - `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.
    - `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.
        - `file_name` string, required: Filename to record for the fetched bytes.
        - `type` 'url'
        - `url` string, required: HTTPS URL the service fetches.
      - 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.
        - `file_id` string, uuid, required: File id from GET /v1/workspaces/{workspace_id}/files.
        - `type` 'workspace_file'
        - `workspace_id` string, uuid, required: Workspace that owns the file.
      - ParseResultSource: A prior Parse job whose parsed artifact can be reused by downstream methods.
        - `job_id` string, uuid, required: Succeeded parse job whose artifact should be reused.
        - `type` 'parse_result'
    - `started_at` string, date-time, nullable: When execution began. Null while still queued.
    - `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.
    - `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`.
    - `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.
    - `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.
        - `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.
        - `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.
        - `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).
        - `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.
    - `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.
    - `usage_finalized_at` string, date-time, nullable: When `credits` became final. Null until `usage_final` is true.
    - `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.

## Other responses

- `202`: The purge was accepted, or is already running.
- `504`: Gateway Timeout
- `default`: Typed error envelope used by every 4xx and 5xx. ``error.code`` distinguishes the failure; HTTP status is a consequence of the code.

## Changes

- **2026-11-07** `d002a3589a67`: 2 info
  - added the optional property `anyOf[subschema #2: TenantPurgeJob]/replayed` to the response with the `200` status
  - added the optional property `replayed` to the response with the `202` status

[Operation history](https://skmtc.dev/nace/apis/ndi-platform-api/changes/v1/tenants/:external_ref/delete.md)

---

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