Skip to content

Rate limits ​

Two independent budgets apply. Both are token buckets refilled to capacity every minute rather than reset in daily lumps, so an integration that saves its allowance can spend it in a burst — a nightly sync is not punished for being a nightly sync.

Both are configured in API.RATE_LIMIT and API.AUTH_FAILURE_RATE_LIMIT in src/lib/const/api.const.ts.

The request budget ​

PropertyValue
Capacity120 tokens
Refill120 tokens per 60s
Keyed onThe API key

Keyed on the key rather than the organization, deliberately: one runaway integration cannot starve the other credentials its organization issued, and when something does go wrong we can tell you which key was at fault.

Cost ​

Most requests cost one token. Expensive endpoints cost more, and each endpoint page states its cost:

EndpointCost
GET /api/public/v11
POST /api/public/v1/contacts/import10
POST /api/public/v1/suppressions10
POST /api/public/v1/qa/reviews10
GET /api/public/v1/usage/summary5

Import costs ten because one request is up to 500 rows and roughly 4 000 bind parameters — it is a count of work, not a count of HTTP requests. At 120 tokens per minute that is twelve import chunks a minute, or 6 000 contacts, which is comfortably more than a nightly sync needs.

Organization editing budgets ​

Some writes also spend from a budget per organization, shared by all its keys — the editing ones with its dashboard, so a script cannot outpace the people editing beside it, and QA reviews so one backfill cannot take every review for itself:

WritesBudgetSource
QA rubric creates, changes, archives60 per 60s, per orgQA.WRITE_RATE_LIMIT
Webhook endpoint creates, changes, rotations, tests60 per 60s, per orgWEBHOOKS.WRITE_RATE_LIMIT
QA review requests60 per 60s, per orgQA.API_REVIEW.RATE_LIMIT

Each is charged after the request budget, one token per write. A spent one is a 429 TOO_MANY_REQUESTS whose message names it; the x-ratelimit-* headers still describe the request budget.

The authentication-failure budget ​

PropertyValue
Capacity30 tokens
Refill30 tokens per 60s
Keyed onThe caller's IP (an IPv6 caller's /64)
ChargedOnly on 401

A request that fails to authenticate has no key to bill, so it is billed to the IP instead. Every address in one IPv6 /64 shares one budget, since a single host is usually assigned a whole /64. This bucket is only ever touched by failures — a correctly configured client never spends from it and pays nothing for its existence.

Thirty failures a minute is generous for a misconfigured client and useless for anyone enumerating keys. Once the budget is spent, the 401 you were getting is replaced by a 429 — so if a bad credential suddenly starts returning 429, fix the credential; you are hitting this bucket, not the request budget.

A 403 never touches it. A scope shortfall or the wrong organization kind came from a key that verified, so it is a misconfigured integration rather than someone hunting for a credential, and it is metered on the key like any other request.

Headers ​

Any response that reached the limiter — that is, every authenticated request, whether it succeeded or was refused with 429 — carries the request budget's state:

x-ratelimit-limit: 120
x-ratelimit-remaining: 118
x-ratelimit-reset: 41

x-ratelimit-reset is the number of seconds until the bucket refills to x-ratelimit-limit. A client that paces itself can spread its remaining tokens over that window instead of waiting for a 429 to find the edge.

A refused request adds:

retry-after: 7

retry-after is in seconds. x-ratelimit-remaining is floored at zero, so it never reports a negative balance.

The headers describe the bucket that answered. On the 429 that replaces a 401 (below), that is the authentication-failure budget, so x-ratelimit-limit there is 30, not 120.

Responses that failed before the limiter — 401, 403, and a 413 decided from content-length — carry no rate-limit headers. Do not treat their absence as a zero balance.

Handling 429 ​

json
{
  "error": {
    "code": "TOO_MANY_REQUESTS",
    "message": "Rate limit exceeded. Try again in 7s."
  }
}

Sleep for retry-after seconds and retry. Add jitter if you run more than one worker, or they will all wake at the same instant and collide again.

Requests are safe to retry after a 429 — nothing was processed. For any other retry of a write, send an idempotency-key — see Idempotency. A replayed response costs the endpoint's tokens like the original did.

Do not retry a 429 immediately without waiting, and do not retry it more than a handful of times. A client that ignores retry-after spends its refill as fast as it arrives and never recovers.

If the limiter itself is unavailable ​

The limiter fails closed: if its backing store cannot be reached, the request is refused rather than waved through, because an outage must not silently remove the only limit on a public, authenticated surface.

That refusal is a 500 with INTERNAL_SERVER_ERROR, not a 429, and it carries no retry-after. Your retry policy for 500 should therefore also be a backoff rather than an immediate retry — see Errors.