> ## 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.

# Ingesting content

> Choose the right route for raw text, a batch, a file or a transcript, then read what the job actually made of it.

Everything you put into Exo arrives through one of four routes, and all four are asynchronous. You get a job id back in milliseconds, a background worker chunks the content, embeds it and links it into the knowledge graph, and the finished job tells you how many nodes and edges it created.

## Before you start

<Columns cols={3}>
  <Card title="A key with write" icon="key" href="/scopes">
    Ingest routes need the `write` scope. Check yours with `GET /v1/me`.
  </Card>

  <Card title="The base URL" icon="globe" href="/authentication">
    `https://api.get-exo.com`, with your key in `X-Exo-API-Key`.
  </Card>

  <Card title="A subject, if B2B2C" icon="users" href="/guides/subjects">
    Writing on behalf of an end user means provisioning a subject first.
  </Card>
</Columns>

## The shortest call that works

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/ingest \
    -H "X-Exo-API-Key: $EXO_KEY" \
    -H "Content-Type: application/json" \
    -d '{"content": "We dropped the freemium tier. Support load was the deciding factor, not conversion."}'
  ```

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

  r = httpx.post(
      "https://api.get-exo.com/v1/ingest",
      headers={"X-Exo-API-Key": EXO_KEY},
      json={
          "content": "We dropped the freemium tier. Support load was the deciding factor, not conversion."
      },
  )
  r.raise_for_status()
  print(r.json())
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const r = await fetch("https://api.get-exo.com/v1/ingest", {
    method: "POST",
    headers: {
      "X-Exo-API-Key": process.env.EXO_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      content:
        "We dropped the freemium tier. Support load was the deciding factor, not conversion.",
    }),
  });
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  console.log(await r.json());
  ```
</CodeGroup>

```json 202 Accepted theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "jobId": "3f9c1d84-6b02-4a71-9f5e-2c8b7d40a115",
  "status": "pending"
}
```

That 202 means the content is durably queued, not that it is in the graph. Nothing is searchable until the job succeeds.

## Which route to use

| Route                        | Send                                                | Use it for                                                   |
| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------ |
| `POST /v1/ingest`            | JSON, one `content` string                          | A single note, decision, message or summary                  |
| `POST /v1/ingest/batch`      | JSON, up to 500 items                               | Backfilling many small items in one call                     |
| `POST /v1/import`            | `multipart/form-data`, one `file`                   | A ChatGPT or Claude export, a PDF, a DOCX                    |
| `POST /v1/import/transcript` | `multipart/form-data`, `file` plus `target_speaker` | A meeting or interview where only one speaker is the subject |

All four return the same accept envelope and all four are polled the same way.

## The full loop

<Steps>
  <Step title="Send the content" icon="upload">
    Post to the route that matches your input. Add an `Idempotency-Key` header on `POST /v1/ingest` and `POST /v1/ingest/batch` so a network retry cannot create a second copy.

    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl -X POST https://api.get-exo.com/v1/ingest \
        -H "X-Exo-API-Key: $EXO_KEY" \
        -H "Idempotency-Key: 9d0a4e2f-1c33-47b8-9a6d-5e71c0f2b884" \
        -H "Content-Type: application/json" \
        -d '{
              "content": "We dropped the freemium tier. Support load was the deciding factor.",
              "metadata": {"source": "product-council", "channel": "decisions"}
            }'
      ```

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

      r = httpx.post(
          "https://api.get-exo.com/v1/ingest",
          headers={
              "X-Exo-API-Key": EXO_KEY,
              "Idempotency-Key": str(uuid.uuid4()),
          },
          json={
              "content": "We dropped the freemium tier. Support load was the deciding factor.",
              "metadata": {"source": "product-council", "channel": "decisions"},
          },
      )
      job_id = r.json()["jobId"]
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      const r = await fetch("https://api.get-exo.com/v1/ingest", {
        method: "POST",
        headers: {
          "X-Exo-API-Key": process.env.EXO_KEY!,
          "Idempotency-Key": crypto.randomUUID(),
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          content: "We dropped the freemium tier. Support load was the deciding factor.",
          metadata: { source: "product-council", channel: "decisions" },
        }),
      });
      const { jobId } = await r.json();
      ```
    </CodeGroup>

    `metadata` is a free-form object. It is stored with the content and comes back on graph reads, so use it for your own foreign keys.
  </Step>

  <Step title="Poll the job" icon="loader">
    `GET /v1/ingest/{jobId}/status` reports the current phase and the phases already done.

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

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

      while True:
          s = httpx.get(
              f"https://api.get-exo.com/v1/ingest/{job_id}/status",
              headers={"X-Exo-API-Key": EXO_KEY},
          ).json()
          if s["status"] not in ("pending", "running"):
              break
          time.sleep(2)
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      let s;
      do {
        const r = await fetch(
          `https://api.get-exo.com/v1/ingest/${jobId}/status`,
          { headers: { "X-Exo-API-Key": process.env.EXO_KEY! } },
        );
        s = await r.json();
        if (s.status !== "pending" && s.status !== "running") break;
        await new Promise((res) => setTimeout(res, 2000));
      } while (true);
      ```
    </CodeGroup>

    <Warning>
      Break on "no longer in progress", not on one spelling of success. This route reports the worker's own status vocabulary, where a finished job is `done`. Only `GET /v1/jobs/{jobId}` renames it to `succeeded`. A loop that waits for `succeeded` here never terminates.
    </Warning>

    ```json 200 OK theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    {
      "jobId": "3f9c1d84-6b02-4a71-9f5e-2c8b7d40a115",
      "status": "running",
      "phase": "embedding",
      "percent": 45,
      "completedSteps": ["parsed", "chunked"],
      "errorMessage": null
    }
    ```

    `errorMessage` is non-null only when `status` is `failed`.
  </Step>

  <Step title="Read what it created" icon="git-branch">
    `GET /v1/jobs/{jobId}` returns the finished job with a `result` block. This is the part worth logging: it tells you what your content became, not just that it uploaded.

    ```json 200 OK theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    {
      "id": "3f9c1d84-6b02-4a71-9f5e-2c8b7d40a115",
      "jobType": "ingest",
      "status": "succeeded",
      "error": null,
      "result": {
        "nodesCreated": 1,
        "edgesCreated": 3,
        "domainsDetected": 1
      },
      "createdAt": "2026-07-30T14:02:11Z",
      "completedAt": "2026-07-30T14:02:29Z"
    }
    ```

    Any field the worker did not report stays null, and `result` itself is null until the job reaches a terminal status.
  </Step>
</Steps>

## Batching

`POST /v1/ingest/batch` takes up to 500 items. Each item is an object with a required `content` of up to 100,000 characters, plus optional `title`, `source_type` and `metadata`. Every valid item becomes its own job, so one slow or failing item cannot hold up the rest.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/ingest/batch \
    -H "X-Exo-API-Key: $EXO_KEY" \
    -H "Idempotency-Key: 5c2b9a17-8e44-4f30-b6d1-70a3e9c15f22" \
    -H "Content-Type: application/json" \
    -d '{
          "items": [
            {"title": "Pricing call", "content": "Seat plus usage won on predictability."},
            {"title": "Support review", "content": "Freemium drove 60 percent of tickets."}
          ]
        }'
  ```

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

  r = httpx.post(
      "https://api.get-exo.com/v1/ingest/batch",
      headers={
          "X-Exo-API-Key": EXO_KEY,
          "Idempotency-Key": str(uuid.uuid4()),
      },
      json={
          "items": [
              {"title": "Pricing call", "content": "Seat plus usage won on predictability."},
              {"title": "Support review", "content": "Freemium drove 60 percent of tickets."},
          ]
      },
  )
  print(r.json()["jobIds"])
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const r = await fetch("https://api.get-exo.com/v1/ingest/batch", {
    method: "POST",
    headers: {
      "X-Exo-API-Key": process.env.EXO_KEY!,
      "Idempotency-Key": crypto.randomUUID(),
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      items: [
        { title: "Pricing call", content: "Seat plus usage won on predictability." },
        { title: "Support review", content: "Freemium drove 60 percent of tickets." },
      ],
    }),
  });
  const { jobIds } = await r.json();
  ```
</CodeGroup>

```json 202 Accepted theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "total": 2,
  "succeeded": 2,
  "failed": 0,
  "results": [
    { "status": "accepted", "node_id": "", "chunks": 0, "jobId": "8a1f..." },
    { "status": "accepted", "node_id": "", "chunks": 0, "jobId": "b47c..." }
  ],
  "ingested": 2,
  "skipped": 0,
  "jobIds": ["8a1f...", "b47c..."]
}
```

An item that cannot be queued, because its content is empty or oversized or its `metadata` is not an object, comes back in `results` as `{"status": "skipped", "reason": "..."}` and is counted in `skipped`. It never fails the batch. Read `results` in request order to find out which of your items did not make it.

<Note>
  `failed` is `total` minus `succeeded`, so skipped items are counted in both `skipped` and `failed`. Use `skipped` when you want to know how many items were rejected before enqueue.
</Note>

## Files and transcripts

`POST /v1/import` takes one file as `multipart/form-data` under the field name `file`.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/import \
    -H "X-Exo-API-Key: $EXO_KEY" \
    -F "file=@conversations.json"
  ```

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

  with open("conversations.json", "rb") as fh:
      r = httpx.post(
          "https://api.get-exo.com/v1/import",
          headers={"X-Exo-API-Key": EXO_KEY},
          files={"file": ("conversations.json", fh, "application/json")},
          timeout=120,
      )
  print(r.json())
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const form = new FormData();
  form.append("file", new Blob([bytes]), "conversations.json");

  const r = await fetch("https://api.get-exo.com/v1/import", {
    method: "POST",
    headers: { "X-Exo-API-Key": process.env.EXO_KEY! },
    body: form,
  });
  console.log(await r.json());
  ```
</CodeGroup>

For a meeting or interview, `POST /v1/import/transcript` adds one required form field, `target_speaker`. Only that speaker's turns land in the subject's graph, which is what you want when three people are in the room and only one of them is the subject.

```bash theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
curl -X POST https://api.get-exo.com/v1/import/transcript \
  -H "X-Exo-API-Key: $EXO_KEY" \
  -F "file=@2026-07-14-product-council.vtt" \
  -F "target_speaker=Priya Raman"
```

`GET /v1/import` lists the 20 most recent imports for the current subject, newest first. `nodesCreated` and `domainsDetected` stay null until the worker finishes.

```json 200 OK theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
[
  {
    "importId": "c17b3e90-4d21-4a8f-bb02-9e6f31d7a405",
    "fileName": "conversations.json",
    "fileType": "json",
    "status": "succeeded",
    "nodesCreated": 412,
    "domainsDetected": 9,
    "createdAt": "2026-07-30T13:55:02Z",
    "completedAt": "2026-07-30T14:06:44Z"
  }
]
```

See [File and transcript imports](/integrations/imports) for supported formats.

## Retries that do not duplicate

Send an `Idempotency-Key` header on `POST /v1/ingest` and `POST /v1/ingest/batch`. A retry with the same key and the same body returns the original job id rather than queueing a second copy. That makes the accept safe to retry on a timeout, a 429 or a 503, which are exactly the moments you do not know whether the first attempt landed.

<Warning>
  `POST /v1/import` and `POST /v1/import/transcript` are not documented as replayable. Uploading the same file twice creates two import jobs. Track your own upload state if double-imports matter to you.
</Warning>

## Writing on behalf of a subject

By default every ingest is written to the API key owner's partition. To write to an end user's partition instead, provision the subject once and then send the `X-Exo-Subject` header on the ingest call.

```bash theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
curl -X POST https://api.get-exo.com/v1/ingest \
  -H "X-Exo-API-Key: $EXO_KEY" \
  -H "X-Exo-Subject: customer_6412" \
  -H "Content-Type: application/json" \
  -d '{"content": "Prefers async review over live calls."}'
```

The header is the selector. There is no body field that does this on the ingest routes. An unprovisioned selector is refused before the handler runs. See [One memory per end user](/guides/subjects).

## Canceling

`POST /v1/jobs/{jobId}/cancel` cancels a job that is still `pending`. A job the worker has already claimed cannot be canceled: cancellation never interrupts in-flight work.

## When it goes wrong

| What you see                        | Why                                                                  | What to do                                                                                                                             |
| ----------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `422 validation_error`              | `content` is empty, or the batch is over 500 items                   | Read `errors[]` in the response body: it names the offending field and JSON pointer. See [validation\_error](/errors/validation_error) |
| `403 permission_denied`             | The key lacks `write`, or names a subject that was never provisioned | Check `scopes` on `GET /v1/me`, and `PUT` the subject first. See [permission\_denied](/errors/permission_denied)                       |
| `409 job_not_cancelable`            | You tried to cancel a job that is already running or finished        | Let it finish and delete the result instead. See [job\_not\_cancelable](/errors/job_not_cancelable)                                    |
| `503 service_degraded`              | A dependency is briefly unavailable                                  | Retry with backoff. With an `Idempotency-Key` this is always safe. See [service\_degraded](/errors/service_degraded)                   |
| Job succeeds, `result` is all nulls | The worker finished but reported no counts                           | The content was accepted; the counters are best effort. Confirm with `GET /v1/graph/nodes`                                             |
| `202`, then nothing is searchable   | You read too early                                                   | The 202 is an accept, not a completion. Poll until the status is neither `pending` nor `running`                                       |

## Next

<Columns cols={2}>
  <Card title="Retrieving context" icon="search" href="/guides/retrieving">
    Read the graph back, and understand `path`, `degraded` and the null `cognition`.
  </Card>

  <Card title="Reacting to change" icon="radio" href="/guides/reacting-to-change">
    Stop polling. Subscribe to `job.completed` over SSE or a webhook.
  </Card>
</Columns>
