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

LimitStandardProfessionalEnterprise
Requests a minute, per key2406001,200
Requests a minute, per person across their keys4801,2002,400
Rows a day, per person in each organization100,000500,0002,000,000
Longest a query may run60 seconds120 seconds300 seconds

These are the same on every plan:

LimitAmountNotes
Requests a minute, per address600Checked before the key is looked up, so people behind one address share it
Requests running at once, per key4Per API process, so treat 4 as the most to plan for
Rows a request1,000Page for more
Live keys, per person in each organization25Playground keys don't count

What you see at a limit

Limit reachedAnswerRetry-After
Requests a minute (key, person or address)429 rate_limited60
Requests running at once429 rate_limited60
The daily row budget429 row_budget_exceeded3600
The organization's queries running at once429 query_busyNone
How long a query may run504 query_timeoutNone

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}/query and GET /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 }
}
FieldMeans
requestsPerMinuteThe most this key may make
rowBudget.perDayThe most this person's keys may read in a day in this organization
rowBudget.usedRows read in the last 24 hours
rowBudget.leftRows 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

SurfaceLimits
Public report API6,000 a minute per token, 600 a minute per address
MCP serverIts own limits and row budget
Playground keys5,000 rows a day, within the person's own budget