Rate limits
Limits apply per key and per store. Every key has an allowance, and every store has a larger one, shared by all of its keys. The allowance refills continuously, so a short burst is fine and a steady stream is what counts. A request is charged to the key’s allowance first; a request the key’s allowance refuses never touches the store’s.
The numbers
| Plan | Burst per key | Sustained per key | Store-wide |
|---|---|---|---|
| Plus | 72 units | 12 units per second | 3 x the key’s numbers |
| Enterprise | 360 units | 60 units per second | 6 x the key’s numbers |
GET /v1/store returns the numbers in force for your store under limits; a store may carry its own override. The dashboard shows the same numbers, and what is in use right now across all keys, under Developer > Limits.
Cost of a request
Every operation belongs to a class, and the class sets what it costs: read 1 (every GET except analytics), write 2 (every POST, PATCH and DELETE except a search), analytics 3 (every analytics view) and search 6 (a people search). The reference states the class of each operation (x-rate-class).
An analytics call is charged 1 when the key is accepted and 2 more once an answer body is served, so a 304, a 404 or a 422 on an analytics route costs 1 and a 200 costs 3.
With a Plus key you can burst 72 reads, 36 writes, 24 analytics bodies or 12 searches, then sustain 12 reads, 6 writes, 4 analytics bodies or 2 searches per second.
Headers
Every limited response carries two headers:
RateLimit: limit=72, remaining=42, reset=3RateLimit-Policy: 72;w=6limitis the size of the allowance the headers describe.remainingis what it can still take now.resetis the number of seconds until it is completely refilled.RateLimit-Policynames the same size andw, the seconds a full allowance takes to refill.
On an admitted request the headers describe the key’s allowance. On a 429 they describe the allowance that refused the request; when that is the store’s, limit is the store’s larger number. The headers are absent on a 401, on a 403 answered before the limit is checked (plan_required, insufficient_scope), and on a read admitted while the limiter could not be reached.
When you go over
Past the limit the answer is 429 rate_limited with a Retry-After header (seconds). The same number is in the body as retry_after:
{ "type": "https://api.octaneai.com/errors/rate_limited", "title": "rate_limited", "detail": "Too many requests", "status": 429, "request_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "retry_after": 2}Wait for Retry-After seconds, then retry. A store-level refusal has already spent the key’s own allowance for that request; there is no refund. Read remaining on every answer and slow down before you get there:
import osimport timeimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}
def get(url, **params): while True: r = httpx.get(url, params=params, headers=HEADERS, timeout=30) if r.status_code != 429: r.raise_for_status() return r time.sleep(int(r.headers.get("Retry-After", "1")))
r = get("https://api.octaneai.com/v1/quizzes", status="published")print(r.headers["RateLimit"], len(r.json()["data"])) # limit=72, remaining=71, reset=1 2const HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}` };
async function get(url: string): Promise<Response> { while (true) { const res = await fetch(url, { headers: HEADERS }); if (res.status !== 429) { if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res; } const wait = Number(res.headers.get("Retry-After") ?? "1"); await new Promise((r) => setTimeout(r, wait * 1000)); }}
const res = await get("https://api.octaneai.com/v1/quizzes?status=published");console.log(res.headers.get("RateLimit"), (await res.json()).data.length); // limit=72, remaining=71, reset=1 2package main
import ( "encoding/json" "fmt" "log" "net/http" "os" "strconv" "time")
func get(url string) *http.Response { for { req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } if res.StatusCode != http.StatusTooManyRequests { if res.StatusCode >= 400 { log.Fatalf("status %d", res.StatusCode) } return res } res.Body.Close() wait, _ := strconv.Atoi(res.Header.Get("Retry-After")) if wait < 1 { wait = 1 } time.Sleep(time.Duration(wait) * time.Second) }}
func main() { res := get("https://api.octaneai.com/v1/quizzes?status=published") defer res.Body.Close() var page struct { Data []json.RawMessage `json:"data"` } json.NewDecoder(res.Body).Decode(&page) fmt.Println(res.Header.Get("RateLimit"), len(page.Data)) // limit=72, remaining=71, reset=1 2}Searches
A people search costs 6 units and has two more limits. Each statement behind it has 10 seconds; a filter that matches a large share of your people runs past that and answers 422 validation_error whose export field carries the export request that builds the same people as a file (see People). A store runs at most 15 live searches at a time; a search that finds no free slot within a second answers 429 rate_limited with Retry-After: 1. Exports count against neither.
Webhooks
Outgoing webhooks have their own numbers, also returned by GET /v1/store: webhook_endpoints (5 on Plus, 25 on Enterprise) and webhook_sends_per_second per endpoint (50 on Plus, 200 on Enterprise). Creating an endpoint past the quota answers 409 endpoint_limit.
