Skip to content

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#

ScopeWhat it counts
callsEvery request with a valid key, on every endpoint, whatever the answer: a 400, 404, 422 or 429 counts as well.
metadataRequests that list or describe things: organizations, Signals, DataSources, DataSets, metrics, dimension values, periods and the maps guide. These are all the GET endpoints.
queriesData requests: the query and map endpoints (POST …/query and POST …/map).
concurrencyQueries running at the same time with the key. A place frees up when a query finishes.
ipRequests 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.

HeaderWhat it holds
X-RateLimit-LimitHow many requests the limit allows per minute (for concurrency: how many queries may run at once).
X-RateLimit-RemainingHow many you have left now. Never below 0.
X-RateLimit-ResetThe number of seconds until the limit is fully restored. A count of seconds from now, not a time of day.
X-RateLimit-ScopeWhich 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"
}
FieldWhat it holds
reasonAlways rate_limit for a 429. Other refusals use the same field.
scopeWhich limit was reached, the same as X-RateLimit-Scope.
limitThe limit that was reached.
opens_atWhen the request can succeed again, as an ISO 8601 time in UTC.
detailWhat 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-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.

Last reviewed 9 November 2026