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.
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.
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/2 200
content-type: application/json
x-ratelimit-limit: 120
x-ratelimit-remaining: 87
x-ratelimit-reset: 60
x-ratelimit-scope: queriesWhen 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/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{
"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.
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-RemainingandX-RateLimit-Reseton every response, and slow down when few requests are left. - On a 429, wait as long as
Retry-Aftersays, 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
concurrencylimit 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
callstoo, so a loop keeps you over the limit for longer.
An example in 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_largebefore 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
unavailablewithRetry-After: 5. Nothing was run; retry after five seconds.
Every error type is listed on Errors.
Last reviewed 9 November 2026