Appearance
Rate limits
This page is the single reference for OneHazel's request rate limits: which limits apply, what a rate-limited response looks like on each API, and how to back off correctly.
Rate limits are short-window burst protection. They are separate from your plan's monthly usage quota (Actions and AI Actions), which returns HTTP 402 with code QUOTA_EXCEEDED when used up. See the error reference.
How the limit is counted
- Per operator, not per key. Requests are counted per operator account. Every API key on the account shares the same ceiling, so creating more keys does not raise it.
- One-minute windows, aligned to the clock. Each bucket counts requests in the current wall-clock minute (UTC). The count resets at the start of the next minute, not 60 seconds after your first request.
- Exactly N. With a ceiling of N, the Nth request in a minute succeeds and request N+1 is the first one rejected.
- By plan. Ceilings for most buckets depend on your plan. If your plan cannot be looked up at that moment, the platform applies the default ceilings in the last column below rather than rejecting you.
- Fails open. If the rate limiter's own store is unavailable, requests are allowed rather than rejected.
Ceilings by plan
Requests per minute, per operator.
| Bucket | What it covers | Free | Grow | Scale | Enterprise | Default |
|---|---|---|---|---|---|---|
ingest:realtime | Single-record ingest writes (POST /entities, POST /entities/:id/events, PUT /entities/:id/state/:key) | 250 | 500 | 2,500 | 10,000 | 500 |
ingest:batch | Batch ingest (POST /batch/entities, /batch/events, /batch/states) | 30 | 60 | 300 | 1,200 | 60 |
api:read | Read routes on the main API | 600 | 6,000 | 30,000 | 150,000 | 300 |
api:write | Write routes on the main API | 300 | 3,000 | 15,000 | 75,000 | 60 |
gateway:execute | Outbound supplier calls through the gateway, and connection tests | 300 | 3,000 | 15,000 | 75,000 | 100 |
workflow:ai | AI workflow builder requests | 30 | 120 | 600 | 3,000 | 10 |
concierge | Agentic integration assistant | 20 | 40 | 100 | 300 | 20 |
These limits are the same on every plan:
| Bucket | What it covers | Limit |
|---|---|---|
analytics:read | Every GET on the analytics API, including exports | 200 |
analytics:refresh | POST /analytics/refresh | 1 |
workflow:test | Manual workflow test runs | 30 |
| Affiliate tag | Tag events, counted per operator and visitor IP | 500 |
Batch ingest accepts up to 1,000 entities, 5,000 events or 1,000 states per request, so ingest:batch carries far more records per minute than ingest:realtime.
MCP tools
MCP tools/call has its own limit per tool (per workflow exposed as a tool): by default 30 calls per minute and 3 concurrent calls per tool. Your account may have different per-tool values configured.
What a rate-limited response looks like
The response shape differs by API. All of them use the code RATE_LIMITED except MCP.
Main API and gateway
HTTP 429, with headers:
| Header | Value |
|---|---|
Retry-After | Always 60 (seconds). This is an upper bound: the window may reset sooner. |
X-RateLimit-Remaining | 0 |
X-RateLimit-Reset | ISO 8601 timestamp of the start of the next window. Prefer this. |
json
{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded",
"retryAfter": "2026-10-11T09:31:00.000Z"
}
}error.retryAfter is the same timestamp as X-RateLimit-Reset, not a number of seconds.
Ingest (Operator Data API) and analytics
HTTP 429 with no rate-limit headers. The body names the bucket:
json
{ "success": false, "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded for ingest:batch" } }Because windows are clock-aligned, wait until the start of the next minute.
Other surfaces
| Surface | Body | Headers |
|---|---|---|
| Connection test | { "success": false, "code": "RATE_LIMITED", "error": "...", "retryAfter": "<ISO timestamp>" } | none |
| Workflow test run | { "success": false, "error": "<message>", "retryAfter": "<ISO timestamp>" } | none |
| AI workflow builder | { "error": "Rate limit exceeded", "resetAt": "<ISO timestamp>" } | none |
| Affiliate tag | { "ok": false, "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded", "reset_at": "<ISO timestamp>" } } | Retry-After: 60 |
MCP tools/call | JSON-RPC error, code: -32099, data.code: "MCP_RATE_LIMITED", data.retryAfterSeconds (rate) or data.reason: "MAX_CONCURRENT" | none |
Related: supplier circuit breaker
When a supplier has failed repeatedly, the gateway pauses calls to it and returns HTTP 503, code CIRCUIT_OPEN, with Retry-After: 30. This is not a rate limit on your account, but you handle it the same way.
Backoff
Every rate-limit window ends on a minute boundary, so the exact wait is always knowable: use X-RateLimit-Reset when it is present, otherwise wait for the next minute. Do not retry a rate-limited request immediately, and do not add your own exponential backoff on top of a known reset time.
ts
// Every OneHazel rate-limit window is one wall-clock minute (UTC-aligned),
// so a 429 always clears at the start of the next minute.
export function retryDelayMs(res: Response, attempt: number): number {
const jitter = Math.random() * 500;
if (res.status === 429) {
const reset = Date.parse(res.headers.get("X-RateLimit-Reset") ?? "");
if (Number.isFinite(reset) && reset > Date.now()) return reset - Date.now() + jitter;
return 60_000 - (Date.now() % 60_000) + jitter;
}
const retryAfter = Number(res.headers.get("Retry-After"));
if (retryAfter > 0) return retryAfter * 1000 + jitter;
// Other transient 5xx: exponential backoff with full jitter, capped at 30 s.
return Math.random() * Math.min(30_000, 1_000 * 2 ** attempt);
}
export async function fetchWithBackoff(
url: string,
init: RequestInit = {},
maxAttempts = 5,
): Promise<Response> {
for (let attempt = 1; ; attempt++) {
const res = await fetch(url, init);
const transient = res.status === 429 || res.status === 502 || res.status === 503;
if (!transient || attempt === maxAttempts) return res;
await new Promise((r) => setTimeout(r, retryDelayMs(res, attempt)));
}
}Notes:
- The jitter spreads retries from many workers so they do not all land in the first millisecond of the new window.
- Retrying a
502or503on a write is only safe if repeating the write is harmless. A429was rejected before any work was done, so it is always safe to retry. - If you hit
ingest:realtimelimits regularly, switch to the batch endpoints. If you hit them on the highest plan, contact us.