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

# Rate limits

> Per-credential token buckets, one per family of operations, advertised on every response through the IETF RateLimit headers.

**Limits are per credential and per family, so a burst of writes can never drain your read allowance.**

Each family is a token bucket with two numbers: a **burst**, which is how many requests you can make instantaneously, and a **per-minute** quota, which is the rate the bucket refills at. Refill is continuous rather than a window reset, so a client that spreads its calls never sees a cliff.

## The families

<Note>
  These are the deployment's current defaults, read from the rate limiter's configuration. They are not encoded in the OpenAPI contract and are tuned per tier, so treat them as the numbers to design against rather than as a guarantee. Read the `RateLimit` headers if you need the live figure.
</Note>

| Family     | Burst | Per minute | What draws on it                                                             |
| ---------- | ----- | ---------- | ---------------------------------------------------------------------------- |
| `reads`    | 100   | 300        | Every `GET`, plus the export list and export detail polls                    |
| `writes`   | 30    | 120        | Graph mutations, proposals, settings, webhooks, connectors, jobs cancel      |
| `ingest`   | 30    | 60         | `POST /v1/ingest`, `/v1/ingest/batch`, `/v1/import`, `/v1/import/transcript` |
| `sessions` | 300   | 600        | The coding-session ingestion routes, sized for a backfill                    |
| `search`   | 30    | 120        | `POST /v1/search`, which embeds the query                                    |
| `exports`  | 10    | 30         | `POST /v1/exports` and the export download                                   |
| `default`  | 60    | 120        | Anything not classified above                                                |

Two deliberate choices in that table are worth knowing. `sessions` is generous because a transcript backfill is a burst of small writes, not abuse. `exports` is tight because one export dumps an entire knowledge graph, and the generic write bucket would let a client queue thirty of those in a breath. The export **poll** routes are routed back to `reads` on purpose, so a polling loop cannot throttle its own download.

## The headers

Every response carries these, success and 429 alike.

```
RateLimit: "default";r=87;t=4
RateLimit-Policy: "default";q=300;w=60;pk=:mQ2vK1pZ8Yy3Xa0T:
```

These are the field shapes from the IETF `RateLimit` header draft.

| Field              | Parameter | Meaning                                                                                                  |
| ------------------ | --------- | -------------------------------------------------------------------------------------------------------- |
| `RateLimit`        | `r`       | Tokens remaining in your bucket right now.                                                               |
| `RateLimit`        | `t`       | Seconds until the bucket is full again.                                                                  |
| `RateLimit-Policy` | `q`       | The quota for the window, which is the per-minute figure for the family that served this request.        |
| `RateLimit-Policy` | `w`       | The window in seconds. Always `60`.                                                                      |
| `RateLimit-Policy` | `pk`      | An opaque partition key: a stable, non-reversible digest of your credential. It is never the key itself. |

**The quoted policy name is the literal string `default` on every response**, including for a request that was metered against `search` or `exports`. The value that varies with the family is `q`. Do not parse the name expecting a family label.

On a 429 you additionally get:

```
Retry-After: 3
```

`Retry-After` is whole seconds, computed from your actual bucket state rather than a fixed backoff.

## Reading and respecting them

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -sS -D headers.txt -o body.json \
    https://api.get-exo.com/v1/graph/nodes \
    -H "X-Exo-API-Key: $EXO_KEY"

  grep -i '^ratelimit' headers.txt
  # RateLimit: "default";r=87;t=4
  # RateLimit-Policy: "default";q=300;w=60;pk=:mQ2vK1pZ8Yy3Xa0T:
  ```

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


  def call_with_backoff(client, request, attempts=4):
      for attempt in range(attempts):
          response = client.send(request)
          if response.status_code != 429:
              remaining = response.headers.get("RateLimit", "")
              # RateLimit: "default";r=87;t=4
              return response, remaining
          wait = int(response.headers.get("Retry-After", "1"))
          time.sleep(wait)
      response.raise_for_status()
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  async function callWithBackoff(url: string, init: RequestInit, attempts = 4) {
    for (let attempt = 0; attempt < attempts; attempt += 1) {
      const response = await fetch(url, init);
      if (response.status !== 429) {
        const limit = response.headers.get("RateLimit"); // "default";r=87;t=4
        return { response, limit };
      }
      const wait = Number(response.headers.get("Retry-After") ?? 1);
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
    }
    throw new Error("rate limited after retries");
  }
  ```
</CodeGroup>

The pattern that keeps you out of trouble: read `r` on successful responses and slow down before it reaches zero, rather than treating the 429 as your signal.

## When you are limited

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "type": "https://docs.get-exo.com/errors/rate_limit_exceeded",
  "title": "Rate limit exceeded",
  "status": 429,
  "detail": "Rate limit exceeded for writes operations: 120 requests per minute. Retry in 3 seconds.",
  "code": "rate_limit_exceeded",
  "requestId": "req_9f2c41ab7d0e5c18",
  "suggestedAction": "Wait for the Retry-After interval before retrying, and spread requests more evenly over time.",
  "documentationUrl": "https://docs.get-exo.com/platform/rate-limits"
}
```

`detail` names the family that refused you, which is the fastest way to find out which bucket your traffic actually lands in.

## Two caps that are not rate limits

Both are concurrency or capacity limits rather than request rates, and both come back as their own code.

| Cap                                           | Limit | Code                                                         |
| --------------------------------------------- | ----- | ------------------------------------------------------------ |
| Concurrent SSE streams per organization       | 5     | 429 [`too_many_streams`](/errors/too_many_streams)           |
| Registered webhook endpoints per organization | 20    | 409 [`webhook_limit_reached`](/errors/webhook_limit_reached) |

A webhook endpoint may subscribe to up to 20 event types, or to everything with `*`, so the endpoint cap is rarely the binding constraint.

## If limiting itself fails

The limiter fails **open**. If its store cannot be reached, the request proceeds without `RateLimit` headers rather than failing. A missing header is not an error and is not a signal to retry: it means the limiter could not answer, not that you have no quota.

## Next

<Columns cols={2}>
  <Card title="Errors" icon="triangle-alert" href="/platform/errors">
    The problem envelope and the full code catalogue.
  </Card>

  <Card title="Idempotency" icon="repeat" href="/platform/idempotency">
    How to retry a write safely after a 429 or a timeout.
  </Card>
</Columns>
