Rate limits
When a rate limit is exceeded, the API returns HTTP 429 with one of three error codes: RATE_LIMIT_ACCOUNT, RATE_LIMIT_TENANT, or PLATFORM_RATE_LIMIT. Every authenticated response, including 2xx, carries RateLimit-Policy and RateLimit headers so your agent can pace itself before hitting the ceiling.
# On every successful response (2xx):
RateLimit-Policy: "tenant";q=2500;w=60
RateLimit: "tenant";r=2310;t=37
# On a 429 (rate limit exceeded):
RateLimit-Policy: "tenant";q=2500;w=60
RateLimit: "tenant";r=0;t=37
Retry-After: 37Response headers
| Header | Present on | Description |
|---|---|---|
RateLimit-Policy | Every authenticated response | Quota policy: "tenant";q=<quota>;w=<window_seconds>. q is the total requests allowed in the window; w is the window length in seconds. |
RateLimit | Every authenticated response | Current usage: "tenant";r=<remaining>;t=<seconds_until_reset>. r counts remaining requests; t is seconds until the window resets. |
Retry-After | 429, whenever the reset time is known | Seconds to wait before retrying. The error body's retry_hint.delay_ms carries the same wait in milliseconds, so read either one. |
A 401 carries none of these, because the limiter runs after authentication. Absent headers mean "this request never authenticated", not "no limit applies".
When Retry-After is absent
Every limit Curviate enforces itself knows when its window resets, so RATE_LIMIT_ACCOUNT, RATE_LIMIT_TENANT, and an ingress-wide PLATFORM_RATE_LIMIT all set Retry-After. One case cannot: a PLATFORM_RATE_LIMIT raised because the upstream platform is saturated, where no reset time is available to report. Rather than invent a number you would then trust, that response omits the header.
The body always tells you which case you are in, so branch on it instead of on the error code:
retry_hint.kind | Meaning | Retry-After | What to do |
|---|---|---|---|
"delay" | The reset time is known; retry_hint.delay_ms is the wait in milliseconds. | Present | Wait at least that long. |
"backoff" | No reset time is available. | Absent | Use your own exponential backoff, as the examples below do. |
The examples below handle both by defaulting to a 60 s wait when the header is missing, then taking whichever is longer, the header or their own backoff. That way one retry path is correct for every 429.
Client algorithm
# On every successful response, read remaining headroom:
remaining = parse(RateLimit, "r") # remaining requests this window
quota = parse(RateLimit-Policy, "q") # total quota this window
window = parse(RateLimit, "t") # seconds until window resets
# Proactive throttle, to slow down before hitting the ceiling.
# Guard remaining > 0: it is legitimately 0 on the last permitted 200,
# and dividing by it gives Infinity (or a ZeroDivisionError in Python).
if remaining > 0 and remaining < 0.20 * quota:
sleep(window / remaining) # spread the remaining budget over the window
elif remaining == 0:
sleep(window) # no budget left, wait out the window
# On 429, back off exponentially and add jitter to avoid a thundering herd.
# Take whichever is longer: the server's Retry-After, or your own backoff.
wait = max(Retry-After, delay) + random(0, 2)
sleep(wait)
delay = min(60, delay * 2)
# Give up after a bounded number of attempts rather than looping forever.
# example delay sequence: 2, 4, 8, 16, 32, 60, 60, ...TypeScript example
// TypeScript: proactive throttle + bounded on-429 backoff
const API_KEY = process.env.CURVIATE_API_KEY ?? "cvt_live_<your_key>";
const BASE = "https://api.curviate.com";
const MAX_ATTEMPTS = 6;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function parseStructuredField(header: string, key: string): number {
const match = header.match(new RegExp(`${key}=(\\d+)`));
return match ? parseInt(match[1]!, 10) : 0;
}
async function apiRequest(path: string, body?: unknown): Promise<unknown> {
let delay = 2; // backoff seconds, doubled on each 429
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const res = await fetch(`${BASE}${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
// Proactive throttle on successful responses
if (res.ok) {
const policy = res.headers.get("RateLimit-Policy") ?? "";
const usage = res.headers.get("RateLimit") ?? "";
const quota = parseStructuredField(policy, "q");
const remaining = parseStructuredField(usage, "r");
const windowSec = parseStructuredField(usage, "t");
// remaining is legitimately 0 on the last permitted 200. Dividing by it
// yields Infinity, which silently disables throttling instead of applying it.
if (quota > 0 && windowSec > 0 && remaining < 0.20 * quota) {
const waitSec = remaining > 0 ? windowSec / remaining : windowSec;
await sleep(waitSec * 1000);
}
return res.json();
}
// Handle 429: RATE_LIMIT_ACCOUNT, RATE_LIMIT_TENANT, or PLATFORM_RATE_LIMIT
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get("Retry-After") ?? "60", 10);
const jitter = Math.random() * 2;
// Honour whichever is longer: the server's hint, or our own backoff.
await sleep((Math.max(retryAfter, delay) + jitter) * 1000);
delay = Math.min(60, delay * 2);
continue;
}
// Other errors
const err = await res.json() as { code: string; message: string };
throw new Error(`${err.code}: ${err.message}`);
}
throw new Error(`Still rate limited after ${MAX_ATTEMPTS} attempts.`);
}
// Usage
const accounts = await apiRequest("/v1/accounts");Python example
# Python: proactive throttle + bounded on-429 backoff using httpx
import os, re, time, random
import httpx
API_KEY = os.environ.get("CURVIATE_API_KEY", "cvt_live_<your_key>")
BASE = "https://api.curviate.com"
MAX_ATTEMPTS = 6
def parse_sf(header: str, key: str) -> int:
m = re.search(rf"{key}=(\d+)", header)
return int(m.group(1)) if m else 0
def api_request(path: str, body: dict | None = None):
delay = 2 # backoff seconds, doubled on each 429
for _ in range(MAX_ATTEMPTS):
method = "POST" if body else "GET"
res = httpx.request(
method,
f"{BASE}{path}",
headers={"Authorization": f"Bearer {API_KEY}"},
json=body,
)
if res.is_success:
policy = res.headers.get("ratelimit-policy", "")
usage = res.headers.get("ratelimit", "")
quota = parse_sf(policy, "q")
remaining = parse_sf(usage, "r")
window = parse_sf(usage, "t")
# remaining is legitimately 0 on the last permitted 200, and
# window / 0 raises ZeroDivisionError. Wait out the window instead.
if quota > 0 and window > 0 and remaining < 0.20 * quota:
time.sleep(window / remaining if remaining > 0 else window)
return res.json()
if res.status_code == 429:
retry_after = int(res.headers.get("retry-after", "60"))
jitter = random.uniform(0, 2)
# Honour whichever is longer: the server's hint, or our own backoff.
time.sleep(max(retry_after, delay) + jitter)
delay = min(60, delay * 2)
continue
err = res.json()
raise Exception(f"{err['code']}: {err['message']}")
raise Exception(f"Still rate limited after {MAX_ATTEMPTS} attempts.")
# Usage
accounts = api_request("/v1/accounts")Writes and retries
Backoff and retry is safe for read operations and for operations that are naturally idempotent. For write operations such as sending a message, creating an invite, or publishing a post, retry only when you are certain the previous attempt did not apply. A network timeout does not guarantee the server did not process the request. Clients own retry safety.
Limits by seat count
Limits scale with active seats on your subscription and are designed to never constrain a well-behaved agent. The proactive throttle above will keep most clients well below the ceiling.
Both windows are enforced, and the tighter one wins:
requests per 60 s = 2500 + 1000 x seats
requests per 10 s = 600 + 250 x seats| Seats | Requests per 60 s | Requests per 10 s (burst) |
|---|---|---|
| 0 | 2 500 | 600 |
| 1 | 3 500 | 850 |
| 5 | 7 500 | 1 850 |
| 10 | 12 500 | 3 100 |
Read your own current quota straight off the headers rather than from this table: RateLimit-Policy: "tenant";q=<quota>;w=60 on any authenticated response is the authoritative value.
Free-trial seats do not raise the quota. A trialing workspace gets the 0-seat allowance (2 500 per 60 s, 600 per 10 s). Only paid, non-provisional, non-cancelled seats count.
Next steps
- Errors: the full error-code reference, including
RATE_LIMIT_ACCOUNT,RATE_LIMIT_TENANT, andPLATFORM_RATE_LIMIT. - Getting started guides: the calls this pacing applies to.
- CLI quick start:
--page-delaykeeps a long--allstream under the same gate.