> ## Documentation Index
> Fetch the complete documentation index at: https://docs.get-exo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Forgetting and portability

> Export everything a subject has, then erase it: one memory, one end user, a whole organization's graph, or the account. Each level has a different verb and a different undo.

Exo is a record of how someone thinks, so the delete path matters as much as the write path. This page covers the four levels of erasure, what each one actually destroys, and how to take the data with you first.

## Before you start

* A key with the right scope. Erasure is deliberately narrow: `memory:forget` for a single node, `subjects:provision` for a subject, `admin` for a purge. See [Scopes](/scopes).
* Certainty about the level. These verbs are not interchangeable, and three of the four cannot be undone.
* An export, if the data has any chance of being wanted again. Take it before, not after.

## Pick the right verb

| You want to                   | Use                                   | Undo                                                   |
| ----------------------------- | ------------------------------------- | ------------------------------------------------------ |
| Record that a belief changed  | `POST /v1/graph/nodes/{id}/supersede` | Nothing is destroyed. The chain is kept.               |
| Erase one memory              | `DELETE /v1/graph/nodes/{id}`         | Structure only, for 30 days. Content never comes back. |
| Erase one end user completely | `DELETE /v1/subjects/{id}`            | None.                                                  |
| Empty an organization's graph | `POST /v1/purge` with `scope: "org"`  | None.                                                  |
| Delete the account itself     | `DELETE /v1/me`                       | None.                                                  |

<Warning>
  **Superseding is not forgetting, and it is usually what you want.** If someone changed their mind, supersede the old belief: authority transfers to the successor, the old node stays queryable, and "what did they used to think?" still has an answer. Forgetting destroys that history. Reach for erasure when the requirement is privacy, not accuracy. See [Belief and supersession](/concepts/belief-and-supersession).
</Warning>

## Export first

The export is the portability guarantee and, in practice, the only backup you get. Erasure has no undo at the subject level and above.

<Steps>
  <Step title="Request the export">
    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl -X POST https://api.get-exo.com/v1/exports \
        -H "X-Exo-API-Key: $EXO_KEY" \
        -H "X-Exo-Subject: customer_6412" \
        -H "Idempotency-Key: 0b5d1f47-3c62-4a90-8e71-2d4f6c8b93a5" \
        -H "Content-Type: application/json" \
        -d '{"scope": "user", "format": "json"}'
      ```

      ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      import os, uuid, requests

      res = requests.post(
          "https://api.get-exo.com/v1/exports",
          headers={
              "X-Exo-API-Key": os.environ["EXO_KEY"],
              "X-Exo-Subject": "customer_6412",
              "Idempotency-Key": str(uuid.uuid4()),
          },
          json={"scope": "user", "format": "json"},
      )
      export_id = res.json()["exportId"]
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      const res = await fetch("https://api.get-exo.com/v1/exports", {
        method: "POST",
        headers: {
          "X-Exo-API-Key": process.env.EXO_KEY!,
          "X-Exo-Subject": "customer_6412",
          "Idempotency-Key": crypto.randomUUID(),
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ scope: "user", format: "json" }),
      });
      const { exportId } = await res.json();
      ```
    </CodeGroup>

    ```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    { "exportId": "d94f61a3-5c27-4e80-b16d-8f3a2e70c945" }
    ```

    `scope: "user"` exports the effective subject. `scope: "org"` exports every member's data and requires the `admin` scope, the same posture as org-wide usage reads.

    `format` is `json`. It is the only value the contract accepts.
  </Step>

  <Step title="Poll until it is ready">
    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl https://api.get-exo.com/v1/exports/d94f61a3-5c27-4e80-b16d-8f3a2e70c945 \
        -H "X-Exo-API-Key: $EXO_KEY"
      ```

      ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      import os, time, requests

      while True:
          res = requests.get(
              f"https://api.get-exo.com/v1/exports/{export_id}",
              headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
          )
          export = res.json()
          if export["status"] in ("succeeded", "failed", "canceled"):
              break
          time.sleep(3)
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      let exportRow;
      for (;;) {
        const res = await fetch(
          `https://api.get-exo.com/v1/exports/${exportId}`,
          { headers: { "X-Exo-API-Key": process.env.EXO_KEY! } },
        );
        exportRow = await res.json();
        if (["succeeded", "failed", "canceled"].includes(exportRow.status)) break;
        await new Promise((r) => setTimeout(r, 3000));
      }
      ```
    </CodeGroup>

    ```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    {
      "id": "d94f61a3-5c27-4e80-b16d-8f3a2e70c945",
      "scope": "user",
      "format": "json",
      "status": "succeeded",
      "bytes": 4192753,
      "counts": {
        "nodes": 1284,
        "edges": 5310,
        "chunks": 3907,
        "identitySignals": 216
      },
      "downloadUrl": "https://api.get-exo.com/v1/exports/d94f61a3-5c27-4e80-b16d-8f3a2e70c945/download",
      "error": null,
      "createdAt": "2026-07-14T12:00:04Z",
      "completedAt": "2026-07-14T12:01:37Z"
    }
    ```

    `bytes`, `counts` and `downloadUrl` are null until the export succeeds. Read `counts` before you erase anything, because it is the last honest statement of how much existed.
  </Step>

  <Step title="Download it">
    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl https://api.get-exo.com/v1/exports/d94f61a3-5c27-4e80-b16d-8f3a2e70c945/download \
        -H "X-Exo-API-Key: $EXO_KEY" \
        -o customer_6412.json
      ```

      ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      import os, requests

      res = requests.get(
          f"https://api.get-exo.com/v1/exports/{export_id}/download",
          headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
      )
      with open("customer_6412.json", "wb") as fh:
          fh.write(res.content)
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      import { writeFile } from "node:fs/promises";

      const res = await fetch(
        `https://api.get-exo.com/v1/exports/${exportId}/download`,
        { headers: { "X-Exo-API-Key": process.env.EXO_KEY! } },
      );
      await writeFile("customer_6412.json", Buffer.from(await res.arrayBuffer()));
      ```
    </CodeGroup>

    <Note>
      `downloadUrl` is an authenticated route, not a presigned blob link. It needs the same credentials as every other read, which means the document is never reachable by URL alone and a leaked link is not a leaked export. It also means you cannot hand the URL to a browser tab without the header.
    </Note>

    Downloading an export that has not finished successfully returns 409 [`export_not_ready`](/errors/export_not_ready). Poll first.
  </Step>
</Steps>

## Forget one memory

`DELETE /v1/graph/nodes/{id}` is the erasure verb for a single node.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X DELETE https://api.get-exo.com/v1/graph/nodes/ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2 \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os, requests

  res = requests.delete(
      "https://api.get-exo.com/v1/graph/nodes/ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2",
      headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
  )
  print(res.json())
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const res = await fetch("https://api.get-exo.com/v1/graph/nodes/ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2", {
    method: "DELETE",
    headers: { "X-Exo-API-Key": process.env.EXO_KEY! },
  });
  console.log(await res.json());
  ```
</CodeGroup>

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{ "id": "ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2", "deleted": true, "tombstoned": true, "undeleteWindowDays": 30 }
```

Two things happen, and the difference between them is the whole design:

* **The content is erased.** The node's chunks, which is where the retrievable and searchable text lives, are deleted outright. Search, retrieve, recall, brain training and the dashboard all stop seeing it. That is what makes this a real erasure verb rather than a hidden flag.
* **The node row survives as a tombstone**, for structure and audit, for 30 days. After the window it is physically purged.

Re-deleting an already-tombstoned node is a no-op success, so a retried delete is safe. A node you do not own is 404 [`node_not_found`](/errors/node_not_found), never a 403, because a 403 would confirm the node exists.

<Warning>
  `graph:write` is **not** enough to forget. Erasure needs `memory:forget`. A `write` key holds both, but a key minted with only `graph:write` can edit and supersede and cannot destroy content. Give integrations that key. See [Scopes](/scopes).
</Warning>

### Undelete restores structure, not content

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/graph/nodes/ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2/undelete \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os, requests

  res = requests.post(
      "https://api.get-exo.com/v1/graph/nodes/ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2/undelete",
      headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
  )
  print(res.json())
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const res = await fetch(
    "https://api.get-exo.com/v1/graph/nodes/ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2/undelete",
    { method: "POST", headers: { "X-Exo-API-Key": process.env.EXO_KEY! } },
  );
  console.log(await res.json());
  ```
</CodeGroup>

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "id": "ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2",
  "restored": true,
  "contentRestored": false,
  "deletedAt": "2026-07-14T12:31:08Z",
  "undeleteWindowDays": 30
}
```

`contentRestored` is **always** false. Forget erased the text and no undelete can bring it back. What returns is the node's graph position, title, metadata and authority. Edges removed around the node stay removed. The restored node is visible and linkable but contributes no retrievable text until you write new content to it with `PATCH /v1/graph/nodes/{id}`.

Past 30 days you get the same 404 as a node that never existed, whether or not the collector has physically removed the row yet, because the tombstone is gone by contract at that point. Restoring a node that is not tombstoned is a no-op success with `restored: false`.

## Erase one end user

`DELETE /v1/subjects/{id}` is the documented right-to-be-forgotten path for a single end user of your product.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X DELETE https://api.get-exo.com/v1/subjects/customer_6412 \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os, requests

  res = requests.delete(
      "https://api.get-exo.com/v1/subjects/customer_6412",
      headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
  )
  assert res.status_code == 204
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const res = await fetch(
    "https://api.get-exo.com/v1/subjects/customer_6412",
    { method: "DELETE", headers: { "X-Exo-API-Key": process.env.EXO_KEY! } },
  );
  console.log(res.status); // 204
  ```
</CodeGroup>

204 with no body, and idempotent: a second call on an already-erased subject also returns 204. Requires `subjects:provision`, which an `admin` key also carries.

What it removes:

* Every row that subject owns in the tenant schema.
* The subject mapping and its internal user.
* The subject's uploaded import files and finished export documents, deleted from storage after the rows commit. Erasure reaches blob storage, not only the database.

What it does **not** remove: the organization's trained ExoBrain. The brain is trained on every subject, so deleting it for one erasure would destroy everyone else's model. It is marked stale instead, and retrieval reports `degradationReason: "brain_stale"` until the organization retrains. See [Training and serving an ExoBrain](/guides/exobrain).

<Note>
  If the mapping's internal user is not a service user, the call aborts with 409 [`subject_mapping_invalid`](/errors/subject_mapping_invalid) **before any row is erased**. It fails closed. A corrupt mapping never results in a half-deleted subject.
</Note>

## The two-phase purge

`POST /v1/purge` is the admin ceremony for erasing at scale. It runs in two phases, and the dry run is the default, so a single mistyped call cannot destroy anything.

<Steps>
  <Step title="Dry run: count the impact">
    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl -X POST https://api.get-exo.com/v1/purge \
        -H "X-Exo-API-Key: $EXO_KEY" \
        -H "Content-Type: application/json" \
        -d '{"scope": "subject", "subjectId": "customer_6412", "dryRun": true}'
      ```

      ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      import os, requests

      res = requests.post(
          "https://api.get-exo.com/v1/purge",
          headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
          json={"scope": "subject", "subjectId": "customer_6412", "dryRun": True},
      )
      plan = res.json()
      print(plan["totalRows"], plan["counts"])
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      const res = await fetch("https://api.get-exo.com/v1/purge", {
        method: "POST",
        headers: {
          "X-Exo-API-Key": process.env.EXO_KEY!,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          scope: "subject",
          subjectId: "customer_6412",
          dryRun: true,
        }),
      });
      const plan = await res.json();
      ```
    </CodeGroup>

    ```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    {
      "dryRun": true,
      "scope": "subject",
      "subjectId": "customer_6412",
      "counts": { "vault_nodes": 1284, "memory_chunks": 3907, "graph_edges": 5310 },
      "totalRows": 10501,
      "confirmToken": "pct_9f1c...c204",
      "expiresAt": "2026-07-14T12:39:22Z",
      "purgedAt": null,
      "requestId": "req_5b3e81af"
    }
    ```

    Nothing was deleted. Zero-row tables are omitted from `counts` so the payload stays readable, and `totalRows` is the sum of everything reported. Show these numbers to whoever asked for the deletion before you continue.
  </Step>

  <Step title="Confirm within five minutes">
    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl -X POST https://api.get-exo.com/v1/purge \
        -H "X-Exo-API-Key: $EXO_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "scope": "subject",
          "subjectId": "customer_6412",
          "dryRun": false,
          "confirmToken": "pct_9f1c...c204"
        }'
      ```

      ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      import os, requests

      res = requests.post(
          "https://api.get-exo.com/v1/purge",
          headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
          json={
              "scope": "subject",
              "subjectId": "customer_6412",
              "dryRun": False,
              "confirmToken": plan["confirmToken"],
          },
      )
      done = res.json()
      print(done["purgedAt"], done["totalRows"])
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      const res = await fetch("https://api.get-exo.com/v1/purge", {
        method: "POST",
        headers: {
          "X-Exo-API-Key": process.env.EXO_KEY!,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          scope: "subject",
          subjectId: "customer_6412",
          dryRun: false,
          confirmToken: plan.confirmToken,
        }),
      });
      const done = await res.json();
      ```
    </CodeGroup>

    Phase two reports actual per-table deleted counts under the same `counts` keys, plus `purgedAt`.
  </Step>
</Steps>

The confirm token is bound to this organization, this scope, this subject, this admin **and the impact the dry run reported**. If new data has landed since the dry run, the confirm is refused with 409 [`purge_impact_changed`](/errors/purge_impact_changed) and nothing is deleted. Re-run the dry run, review the new numbers, confirm with the fresh token.

That is why purge is deliberately **not** replayable by `Idempotency-Key`. A replayed destructive call is a footgun, so every confirm must present a valid, unexpired, impact-bound token. See [Idempotency](/platform/idempotency).

<AccordionGroup>
  <Accordion title="What org scope does and does not touch" icon="circle-question">
    `scope: "org"` empties the organization's knowledge graph and keeps the organization itself, its users and its API keys. It also deletes uploaded imports, finished export documents and the trained ExoBrain, since the brain is derived from the graph being emptied. Retrieval falls back to the live rows until you retrain.

    `subjectId` is required for `scope: "subject"` and rejected for `scope: "org"`.

    To delete the account itself rather than empty it, use `DELETE /v1/me`.
  </Accordion>

  <Accordion title="Read `skipped` before you call it done" icon="circle-question">
    `skipped` lists tables the purge could not clear: absent from an older schema, or blocked by a foreign key. It is present **only when non-empty**, precisely so a purge that left rows behind can never look identical to one that had none.

    If `skipped` is present, the erasure is incomplete. Do not report it as finished.
  </Accordion>
</AccordionGroup>

## Delete the account

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X DELETE https://api.get-exo.com/v1/me \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os, requests

  res = requests.delete(
      "https://api.get-exo.com/v1/me",
      headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
  )
  assert res.status_code == 204
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const res = await fetch("https://api.get-exo.com/v1/me", {
    method: "DELETE",
    headers: { "X-Exo-API-Key": process.env.EXO_KEY! },
  });
  console.log(res.status); // 204
  ```
</CodeGroup>

This tears down the whole account. Sending an `X-Exo-Subject` selector with it is a 400: deleting an account is not something you do on someone else's behalf, so the selector is refused rather than ignored. Sandbox keys are refused too.

## When it goes wrong

<AccordionGroup>
  <Accordion title="403 permission_denied on a delete" icon="circle-question">
    You are one scope short. Forgetting a node needs `memory:forget`, erasing a subject needs `subjects:provision`, purging needs `admin`. A `write` key covers `memory:forget` but not `subjects:provision`. See [`permission_denied`](/errors/permission_denied) and [Scopes](/scopes).
  </Accordion>

  <Accordion title="409 purge_impact_changed" icon="circle-question">
    Data landed between your dry run and your confirm, so the token no longer matches reality and nothing was deleted. This is the guard working. Re-run the dry run, check the new counts, confirm with the new token. If it keeps happening, pause ingestion for that subject first.
  </Accordion>

  <Accordion title="409 export_not_ready" icon="circle-question">
    You called the download route before the export finished. Poll `GET /v1/exports/{id}` until `status` is `succeeded`, then follow `downloadUrl`. See [`export_not_ready`](/errors/export_not_ready).
  </Accordion>

  <Accordion title="Retrieval degraded right after an erasure" icon="circle-question">
    Expected. Erasing a subject marks the organization's brain stale, so `degradationReason` becomes `brain_stale` until you retrain. Results are still correct, they are served from a model that no longer matches the graph. See [Brain path and hybrid path](/concepts/retrieval-paths).
  </Accordion>

  <Accordion title="I forgot a node and want the text back" icon="circle-question">
    There is no route that does this. Forget erases content by design, and undelete explicitly reports `contentRestored: false`. If you have an export from before the deletion, re-ingest the content from it. If you do not, the text is gone.
  </Accordion>
</AccordionGroup>

## Next

<Columns cols={2}>
  <Card title="Subjects and isolation" icon="shield" href="/concepts/isolation">
    Why per-subject erasure is clean: the partition it is erasing is physical.
  </Card>

  <Card title="Belief and supersession" icon="git-branch" href="/concepts/belief-and-supersession">
    The verb to use when the belief changed rather than the person leaving.
  </Card>
</Columns>
