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

# Rate limiting

> How the Signal API limits requests per API key, the rate-limit headers on every response, the 429 answer, and how to handle limits in your code.

Last reviewed 9 November 2026

The Signal API limits how many requests you can make in a short time, so that one busy integration cannot slow down everyone else. Every response tells you how much of the limit you have left, so your code can slow down before it is refused.

## How the limits work

- Each API key has its own limits. Two keys in the same organization do not share them.
- The limits are counted over a rolling minute: a request counts for 60 seconds after it was made.
- The numbers can differ between organizations. Read them from the response headers rather than writing them into your code.

## What is counted

| Scope | What it counts |
| --- | --- |
| calls | Every request with a valid key, on every endpoint, whatever the answer: a 400, 404, 422 or 429 counts as well. |
| metadata | Requests that list or describe things: organizations, Signals, DataSources, DataSets, metrics, dimension values, periods and the maps guide. These are all the GET endpoints. |
| queries | Data requests: the query and map endpoints (POST …/query and POST …/map). |
| concurrency | Queries running at the same time with the key. A place frees up when a query finishes. |
| ip | Requests with an invalid key from one network address. |

What makes a key invalid (unknown, expired or revoked) is described on [Authentication](https://docs.odysseysignal.com/api/authentication).

A request can count against more than one limit. A query counts against `calls`, `queries` and `concurrency`; a GET request against `calls` and `metadata`.

## Rate-limit headers

Every response to a request with a valid key carries these headers: successful answers and errors alike. A 401 does not, because the key is not known.

| Header | What it holds |
| --- | --- |
| X-RateLimit-Limit | How many requests the limit allows per minute (for concurrency: how many queries may run at once). |
| X-RateLimit-Remaining | How many you have left now. Never below 0. |
| X-RateLimit-Reset | The number of seconds until the limit is fully restored. A count of seconds from now, not a time of day. |
| X-RateLimit-Scope | Which limit the other three describe: calls, metadata, queries, concurrency or ip. |

When several limits apply to a request, the headers describe the one with the least left, or the one that refused the request. For example:

```http
HTTP/2 200
content-type: application/json
x-ratelimit-limit: 120
x-ratelimit-remaining: 87
x-ratelimit-reset: 60
x-ratelimit-scope: queries
```

## When you reach a limit

The request is refused with 429 and the type `rate_limited`. It was not run. The `Retry-After` header says how many whole seconds to wait, at least 1:

```http
HTTP/2 429
content-type: application/problem+json
retry-after: 12
x-ratelimit-limit: 120
x-ratelimit-remaining: 0
x-ratelimit-reset: 12
x-ratelimit-scope: queries
```

```json
{
  "type": "https://api.odysseysignal.app/problems/rate_limited",
  "title": "Too many requests",
  "status": 429,
  "detail": "…",
  "instance": "/v1/signals/…/query",
  "reason": "rate_limit",
  "scope": "queries",
  "limit": 120,
  "opens_at": "2026-11-09T14:03:12Z"
}
```

| Field | What it holds |
| --- | --- |
| reason | Always rate_limit for a 429. Other refusals use the same field. |
| scope | Which limit was reached, the same as X-RateLimit-Scope. |
| limit | The limit that was reached. |
| opens_at | When the request can succeed again, as an ISO 8601 time in UTC. |
| detail | What happened and when to try again, in words you can show to a person. Do not parse it. |

The other values of `reason` are listed on [Errors](https://docs.odysseysignal.com/api/errors).

For `concurrency`, `Retry-After` is 1: we cannot know when one of your running queries will finish, so wait a moment and try again, or wait until one of your own queries has answered.

## Handle limits in your code

- Read `X-RateLimit-Remaining` and `X-RateLimit-Reset` on every response, and slow down when few requests are left.
- On a 429, wait as long as `Retry-After` says, then try the same request again. If it is refused again, wait longer each time and add a little randomness, so that many clients do not retry at the same moment.
- Run queries one after another, or a few at a time, rather than all at once. More parallel queries than the `concurrency` limit allows are refused.
- Fetch descriptions such as Signals, DataSets and metrics once and keep them, rather than before every query. They change rarely.
- Never retry in a tight loop. Refused requests count against `calls` too, so a loop keeps you over the limit for longer.

An example in Python:

```python
import os
import random
import time

import requests

API = "https://api.odysseysignal.app"
session = requests.Session()
session.headers["X-Api-Key"] = os.environ["ODYSSEY_API_KEY"]


def request(method, path, **kwargs):
    for attempt in range(6):
        response = session.request(method, API + path, timeout=300, **kwargs)
        if response.status_code not in (429, 503):
            response.raise_for_status()
            return response.json()
        wait = int(response.headers.get("Retry-After", "1"))
        time.sleep(wait + attempt * attempt + random.random())
    response.raise_for_status()
```

## Other limits

- **Query size.** A query that would read too much data is refused with 400 `query_too_large` before it runs. Ask a narrower question: a shorter period, or fewer markets, metrics or breakdowns.
- **Limits not available.** If the limits cannot be checked because of a fault on our side, the request gets 503 `unavailable` with `Retry-After: 5`. Nothing was run; retry after five seconds.

Every error type is listed on [Errors](https://docs.odysseysignal.com/api/errors).
