Rate limits
The Developer API's limits per key, person and address, requests running at once, the daily row budget, values by plan, Retry-After, reading your limits from whoami, and a backoff snippet.
Limits come from your organization's plan, and Halo can adjust them per organization, so read yours from whoami rather than hard-coding them.
Limits by plan
| Limit | Standard | Professional | Enterprise |
|---|---|---|---|
| Requests a minute, per key | 240 | 600 | 1,200 |
| Requests a minute, per person across their keys | 480 | 1,200 | 2,400 |
| Rows a day, per person in each organization | 100,000 | 500,000 | 2,000,000 |
| Longest a query may run | 60 seconds | 120 seconds | 300 seconds |
These are the same on every plan:
| Limit | Amount | Notes |
|---|---|---|
| Requests a minute, per address | 600 | Checked before the key is looked up, so people behind one address share it |
| Requests running at once, per key | 4 | Per API process, so treat 4 as the most to plan for |
| Rows a request | 1,000 | Page for more |
| Live keys, per person in each organization | 25 | Playground keys don't count |
What you see at a limit
| Limit reached | Answer | Retry-After |
|---|---|---|
| Requests a minute (key, person or address) | 429 rate_limited | 60 |
| Requests running at once | 429 rate_limited | 60 |
| The daily row budget | 429 row_budget_exceeded | 3600 |
| The organization's queries running at once | 429 query_busy | None |
| How long a query may run | 504 query_timeout | None |
Retry-After is in seconds, and is a fixed number rather than a countdown. Your request may succeed sooner: request limits refill as a token bucket over the minute. Halo also answers rate_limited if it can't check a limit, rather than letting the request through.
The daily row budget
Each person has a budget of data rows in each organization, across all their keys.
What counts: rows returned by
POST /v1/developer/datasets/{id}/queryandGET /v1/developer/charts/{id}/data. A query grouped into 10 rows costs 10, however many source rows it read.What doesn't:
whoami, lists, dataset descriptions, and chart and report documents. They keep working when the budget is spent.The window is the last 24 hours, rolling, rather than a calendar day. Rows free up a day after they were read.
The check runs before each query, so the request that crosses the line still answers in full. Requests are refused once nothing is left.
To spend less: aggregate more, filter to a shorter range, and page only as far as you need. Asking for countRows first tells you what a full read would cost.
Queries running at once
Beyond your own limits, an organization runs up to eight queries at once, across Halo Data, reports and the API, and up to six on any one connection. A query waits for a turn as long as your plan lets a query run, then gets 429 query_busy. It isn't about your key, so back off and try again.
Read your limits
GET /v1/developer/whoami needs no scope and shows what applies to this key now.
curl -sS https://api.usehalo.net/v1/developer/whoami \
-H "Authorization: Bearer $HALO_API_KEY" | jq .limits{
"requestsPerMinute": 240,
"rowBudget": { "perDay": 100000, "used": 42180, "left": 57820 }
}| Field | Means |
|---|---|
requestsPerMinute | The most this key may make |
rowBudget.perDay | The most this person's keys may read in a day in this organization |
rowBudget.used | Rows read in the last 24 hours |
rowBudget.left | Rows left. Queries are refused at zero |
whoami counts as a request, so check it once per job rather than once per query.
Back off
Wait what Retry-After says when it's there, and otherwise back off with some jitter. Stop on row_budget_exceeded rather than waiting an hour inside a request.
import random
import time
import requests
RETRY = {"rate_limited", "query_busy", "source_error", "request_timeout", "internal"}
def call(session: requests.Session, method: str, url: str, attempts: int = 5, **kwargs) -> dict:
for attempt in range(1, attempts + 1):
response = session.request(method, url, timeout=330, **kwargs)
if response.ok:
return response.json()
problem = response.json()
if problem.get("code") not in RETRY or attempt == attempts:
raise RuntimeError(f"{problem.get('code')}: {problem.get('detail')} ({problem.get('requestId')})")
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else min(2**attempt, 30) + random.random())
raise RuntimeError("attempts must be at least 1")The same helper is in Snippets for cURL, JavaScript and Go. To stay under the limit in the first place, run no more than four requests at once per key.
Other surfaces
| Surface | Limits |
|---|---|
| Public report API | 6,000 a minute per token, 600 a minute per address |
| MCP server | Its own limits and row budget |
| Playground keys | 5,000 rows a day, within the person's own budget |