# 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](/reference/errors#rate-limits-and-quota). ## 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": "" }` | none | | Workflow test run | `{ "success": false, "error": "", "retryAfter": "" }` | none | | AI workflow builder | `{ "error": "Rate limit exceeded", "resetAt": "" }` | none | | Affiliate tag | `{ "ok": false, "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded", "reset_at": "" } }` | `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 { 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 `502` or `503` on a write is only safe if repeating the write is harmless. A `429` was rejected before any work was done, so it is always safe to retry. - If you hit `ingest:realtime` limits regularly, switch to the batch endpoints. If you hit them on the highest plan, contact us.