Rate limits

When one of Curviate's own request-rate limits is exceeded, the API returns HTTP 429 with RATE_LIMIT_ACCOUNT, RATE_LIMIT_TENANT, or PLATFORM_RATE_LIMIT; the pacing algorithm and examples here cover those three. Every authenticated response, including 2xx, carries RateLimit-Policy and RateLimit headers so your agent can pace itself before hitting the ceiling.

A fourth code shares the 429 and needs the opposite handling. BUDGET_EXHAUSTED is not a rate limit: it is an account-safety ceiling you configured, or the account being outside the hours it works in. Nothing reached LinkedIn and nothing was spent. It carries no Retry-After and no retry_hint, deliberately, because its reset can be weeks out and a client that slept the header would hang. Do not run the backoff on this page against it. Read reset_at and hint.parameter instead, and see Safety settings.

# 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: 37

Response headers

HeaderPresent onDescription
RateLimit-PolicyEvery authenticated responseQuota policy: "tenant";q=<quota>;w=<window_seconds>. q is the total requests allowed in the window; w is the window length in seconds.
RateLimitEvery authenticated responseCurrent usage: "tenant";r=<remaining>;t=<seconds_until_reset>. r counts remaining requests; t is seconds until the window resets.
Retry-After429, whenever the reset time is knownSeconds 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. That response omits the header rather than invent a number.

Within these three codes, branch on the body's retry_hint.kind, not on the code. Check the code first, though: BUDGET_EXHAUSTED carries retry_hint: null and matches neither row below.

LINKEDIN_RATE_LIMITED is a fifth 429 and a different layer again: LinkedIn is throttling the connected account itself, not Curviate throttling your requests. No Curviate setting changes it. Back off substantially and slow the whole job rather than the one call, because retrying into a LinkedIn rate limit is what turns it into an account restriction.

A Contacts or Motions write can also answer RATE_LIMIT_TENANT without Retry-After: other writes of your team (an import, a bulk update, a wide burst) kept it waiting for five seconds. Writes of one team take their turn, a few at a time; a write that waits longer than that is refused. It carries retry_hint: backoff; nothing was written, so retry shortly.

IMPORT_QUEUE_FULL is a sixth 429 and not a request-rate limit either: your team already has five Contact imports queued or running. It carries retry_hint: backoff. Wait for one import to finish (GET /v1/contacts/imports), then submit again; inline imports of up to 500 rows are not affected.

retry_hint.kindMeaningRetry-AfterWhat to do
"delay"The reset time is known; retry_hint.delay_ms is the wait in milliseconds.PresentWait at least that long.
"backoff"No reset time is available.AbsentUse 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 is one retry path for all three rate-limit codes. It is the wrong path for BUDGET_EXHAUSTED, so each example reads the code and returns instead of sleeping.

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, read the CODE first. BUDGET_EXHAUSTED is an account-safety ceiling,
# not a rate limit: no wait clears it, so surface it instead of sleeping.
if err.code == "BUDGET_EXHAUSTED":
  raise SafetyCeiling(err.row, err.reset_at, err.hint.parameter)
 
# Otherwise 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.
    // BUDGET_EXHAUSTED also arrives as a 429 and must NOT be retried: it is an
    // account-safety ceiling, no wait clears it, and its reset can be weeks out.
    if (res.status === 429) {
      const err = await res.clone().json() as { code: string; row?: string; reset_at?: string | null };
      if (err.code === "BUDGET_EXHAUSTED") {
        throw new Error(`Safety ceiling on ${err.row}; frees at ${err.reset_at ?? "no fixed time"}.`);
      }
      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:
            # BUDGET_EXHAUSTED shares the 429 and must NOT be retried: it is an
            # account-safety ceiling, no wait clears it, and its reset can be
            # weeks out.
            # NOT `body`: that name is this function's request payload, and
            # rebinding it here would make the next attempt POST the error.
            err = res.json()
            if err.get("code") == "BUDGET_EXHAUSTED":
                raise Exception(
                    f"Safety ceiling on {err.get('row')}; "
                    f"frees at {err.get('reset_at') or 'no fixed time'}."
                )
            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 reads. For writes 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 and are designed never to constrain a well-behaved agent; the throttle above keeps 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
SeatsRequests per 60 sRequests per 10 s (burst)
02 500600
13 500850
57 5001 850
1012 5003 100

Read your quota off the headers, not this table: RateLimit-Policy: "tenant";q=<quota>;w=60 on any authenticated response is authoritative.

Free-trial seats do not raise the quota. A trialing tenant gets the 0-seat allowance (2 500 per 60 s, 600 per 10 s). Only paid, non-provisional, non-cancelled seats count.

Next steps