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

# Authentication

> Every request carries an Exo API key. Keys are org credentials, minted self-serve, shown once, scoped on purpose and revocable.

**Send your key as the `X-Exo-API-Key` header on every request.** There is no session, no login step and no token exchange in front of it.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl 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

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

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

The same key is also accepted as a bearer token, which is the easier path through HTTP clients and proxies that assume `Authorization`:

```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
curl https://api.get-exo.com/v1/me \
  -H "Authorization: Bearer $EXO_KEY"
```

Send one or the other. Both authenticate identically.

## What a key is

A key looks like `exo_<env>_<token>`, where the middle segment names the deployment it belongs to. A production key is minted `exo_prod_...`; a test-mode key is minted `exo_sandbox_...`. The token is 43 URL-safe characters, so treat the whole string as opaque and never split it on anything but the first two underscores.

It is an **organization** credential bound to the user who minted it, and it resolves on every request to a principal: an org, a user, and a set of scopes. `GET /v1/me` shows you exactly what yours resolves to.

```json 200 application/json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "org": {
    "id": "0d5b7c92-3a41-4e88-9b02-6f7c1d4a5e30",
    "displayName": "Acme",
    "slug": "acme"
  },
  "user": { "id": "4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93" },
  "scopes": ["read", "write"],
  "subjectDefault": "4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93",
  "plan": "default"
}
```

Org and user ids are UUIDs. `subjectDefault` is a user id, not a subject key you provisioned.

`subjectDefault` is the identity your calls act as when you send no subject selector. It is always the key owner, and it is deliberately not the resolved value of an `X-Exo-Subject` header, because internal subject ids are an isolation detail that never leaves the API.

## Selecting a subject

If you provision a subject per end user of your product, the selector is a **header**, not a body field:

```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
curl -X POST https://api.get-exo.com/v1/retrieve \
  -H "X-Exo-API-Key: $EXO_KEY" \
  -H "X-Exo-Subject: customer_6412" \
  -H "Content-Type: application/json" \
  -d '{"query": "What did this customer decide about pricing?"}'
```

Omit the header and the call acts as the key owner, so single-user integrations stay one line. A selector naming a subject that was never provisioned is refused before the handler runs. See [One memory per end user](/guides/subjects).

<Note>
  A handful of routes read the key owner's own content by construction and refuse an `X-Exo-Subject` selector outright: `POST /v1/brain/query` and `POST /v1/brain/answer` are the ones you are most likely to meet. Each endpoint page says so explicitly.
</Note>

## Minting a key

Your first key comes from [the Exo dashboard](https://get-exo.com), because a signed-in dashboard session is allowed to mint. After that, `POST /v1/keys` mints from the API itself, and **an API key must carry the `admin` scope to use it**.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/keys \
    -H "X-Exo-API-Key: $EXO_ADMIN_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: 9c1e-ingest-worker-key" \
    -d '{"label": "ingest worker", "scopes": ["read", "write"]}'
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  mint = requests.post(
      "https://api.get-exo.com/v1/keys",
      headers={
          "X-Exo-API-Key": os.environ["EXO_ADMIN_KEY"],
          "Content-Type": "application/json",
          "Idempotency-Key": "9c1e-ingest-worker-key",
      },
      json={"label": "ingest worker", "scopes": ["read", "write"]},
  )
  mint.raise_for_status()
  print(mint.json()["apiKey"])  # store it now
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const mint = await fetch("https://api.get-exo.com/v1/keys", {
    method: "POST",
    headers: {
      "X-Exo-API-Key": process.env.EXO_ADMIN_KEY!,
      "Content-Type": "application/json",
      "Idempotency-Key": "9c1e-ingest-worker-key",
    },
    body: JSON.stringify({ label: "ingest worker", scopes: ["read", "write"] }),
  });
  const { apiKey, id } = await mint.json(); // store apiKey now
  ```
</CodeGroup>

```json 201 application/json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "apiKey": "exo_prod_pTyGJMuHbEL31IeL2HPcHyGcFRl1SPnXNYvMIHa_2o7",
  "id": "aa91b60134f58668ad63539bf23b277636d6f0f163485148135c07e140a7c8fd",
  "label": "ingest worker"
}
```

Three things about that response:

* **`apiKey` is shown once.** Only the SHA-256 hash is stored, so nothing can recover the plaintext afterwards. If you lose it, revoke the key and mint another.
* **`id` is that hash**, 64 hex characters. It cannot authenticate. It is the opaque id `GET /v1/keys` lists and `DELETE /v1/keys/{id}` takes, so it is safe to log and safe to show in your own dashboard.
* **Omitting `scopes` inherits the caller's.** Naming a scope the caller does not hold is a `403`, so minting can never escalate.

<Warning>
  List every umbrella scope the key needs. `read`, `write` and `admin` are separate grants, not a ladder: a key minted with `["write"]` alone is refused by every `read` route, including the job-status poll that follows an ingest. That is why the example above asks for `["read", "write"]`. See [Scopes](/scopes).
</Warning>

<Warning>
  Sending an `Idempotency-Key` makes the mint replay-safe, but a replayed response carries a marker in place of `apiKey`. The plaintext is never persisted anywhere, not even in the replay store, so a retry can return the same key id but never the same secret. Capture `apiKey` from the first response.
</Warning>

## Listing and revoking

`GET /v1/keys` returns the caller's keys, newest first, active and revoked. Plaintext is never in this response.

```json 200 application/json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "keys": [
    {
      "id": "aa91b60134f58668ad63539bf23b277636d6f0f163485148135c07e140a7c8fd",
      "label": "ingest worker",
      "status": "active",
      "createdAt": "2026-07-14T09:12:03Z",
      "lastUsedAt": "2026-07-29T16:40:11Z"
    }
  ]
}
```

`lastUsedAt` is the field to watch when a client "looks connected" but Exo has heard nothing from it. A key that stays `null` was never sent.

`DELETE /v1/keys/{key_id}` soft-revokes and returns `204` with no body. It is idempotent, so revoking an already-revoked key you own is another `204`. A key that is not yours, in any sense, is a `404` rather than a `403`, which also keeps key hashes unenumerable.

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

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  requests.delete(
      f"https://api.get-exo.com/v1/keys/{key_id}",
      headers={"X-Exo-API-Key": os.environ["EXO_ADMIN_KEY"]},
  )  # 204
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  await fetch(`https://api.get-exo.com/v1/keys/${keyId}`, {
    method: "DELETE",
    headers: { "X-Exo-API-Key": process.env.EXO_ADMIN_KEY! },
  }); // 204
  ```
</CodeGroup>

Listing is scoped to the calling user inside their organization. An admin key sees its own keys, not every member's.

## What a leaked key can and cannot do

This is the reason scopes exist, so it is worth stating in one place. A key that escapes into a log, a client bundle or a screenshot can do exactly what its scopes allow inside one organization, and nothing else.

| A leaked key can                                               | A leaked key cannot                                                                       |
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Read and write within the org that minted it, up to its scopes | Reach any other organization. Isolation is a physical per-org schema, not a filter        |
| Act as any subject that org has provisioned                    | Recover the plaintext of any other key, including its own, from the API                   |
| Be used until you notice                                       | Escalate its own scopes. Minting is bounded by the caller's scopes                        |
| Erase data, only if it carries `write` or `memory:forget`      | Erase data with `graph:write` alone. That scope edits the graph and cannot forget from it |
| Tear down the account, only if it carries `admin`              | Tear down the account without `admin`. `DELETE /v1/me` refuses sandbox keys outright      |

The practical consequence: **mint narrow keys per workload.** A key that only ingests should hold `write` and nothing more. A key that only serves reads should hold `read`. Read [Scopes](/scopes) for the full vocabulary and what each one unlocks.

Revocation is the containment step. `DELETE /v1/keys/{id}` takes effect on the next request, and `lastUsedAt` on the remaining keys tells you what is still live.

## Sandbox keys

A sandbox key is bound to an isolated partition of your org, so you can run the quickstart, a CI suite or a demo without putting anything into real memory. Mint one with `POST /v1/sandbox/keys`, which needs `admin`.

```json 201 application/json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "apiKey": "exo_sandbox_6umfXfKm_r5kJP1VrT-1FJors_6ILi8IHn5kxsC7tVO",
  "id": "800ed320dbfbc80014ff4a94556c6b45047424ee79d5c433e1ee612f47173cb1",
  "label": "Sandbox key",
  "scopes": ["read", "write"],
  "subject": "sandbox",
  "expiresAt": "2026-08-29T00:00:00Z"
}
```

Four things differ from a live key:

* The prefix is `exo_sandbox_`, so it is recognisable on sight.
* Scopes are fixed at `read` and `write` and are not caller-selectable. A test key must never be able to mint keys, provision subjects or erase real data.
* It expires. `expiresAt` says when. Mint another to continue.
* It is deliberately **not** idempotency-replayable. A retried `Idempotency-Key` mints a fresh key rather than replaying the first response, because persisting a plaintext credential to save one key is the wrong trade.

`POST /v1/sandbox/reset` wipes the partition and keeps the keys, so your test credential keeps working against an empty graph. Full behaviour on [Sandbox](/platform/sandbox).

## OAuth, and where it applies

Exo also speaks OAuth 2.1 with PKCE, but **not on this API**. It exists for MCP clients that cannot hold a static credential: the consumer Claude app on web, desktop and mobile, and the ChatGPT connector. Those clients point at Exo's MCP endpoint, discover that it is OAuth-protected, send the user to a sign-in page, and receive a minted `exo_` key as the access token. One trust root, so revoking the key in the dashboard kills the connection.

Nothing about that flow changes how you call `/v1/*`. If you are writing server-side code, use a static key.

| Client                                                    | Credential                                   |
| --------------------------------------------------------- | -------------------------------------------- |
| Your own backend, scripts, CI                             | Static `exo_` key, this page                 |
| [Claude Code](/integrations/claude-code)                  | Static `exo_` key over hosted MCP            |
| [Claude app and Claude Desktop](/integrations/claude-app) | OAuth 2.1 with PKCE                          |
| [ChatGPT connector](/integrations/chatgpt)                | OAuth 2.1 with PKCE. Static keys do not work |
| [Codex CLI and IDE](/integrations/codex)                  | Static `exo_` key over MCP                   |

## When it goes wrong

| Status          | Meaning                                          | What to check                                                                                                           |
| --------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| 401             | Missing or invalid credentials                   | Header spelling, the `Bearer ` prefix if you chose that form, and whether the key was revoked                           |
| 403             | [`permission_denied`](/errors/permission_denied) | The key authenticated but lacks the scope, or names an unprovisioned subject. `GET /v1/me` shows what it actually holds |
| 404 on a key id | Not yours                                        | Another member's key, another org's, or nonexistent. Exo does not distinguish, on purpose                               |
| 429             | Rate limited                                     | Back off for the interval in `Retry-After`. See [Rate limits](/platform/rate-limits)                                    |
| 503             | [`service_degraded`](/errors/service_degraded)   | A dependency is unavailable. Safe to retry with backoff. Never a 500                                                    |

Every failure body is RFC 9457 `problem+json` with a stable `code` and a `requestId`. See [Errors](/platform/errors).

## Next

<Columns cols={2}>
  <Card title="Scopes" icon="shield-check" href="/scopes">
    The full vocabulary, what each scope unlocks, and the two implications that surprise people.
  </Card>

  <Card title="Quickstart" icon="rocket" href="/quickstart">
    Put the key to work in five calls.
  </Card>
</Columns>
