Rate Limits

API Documentation

Rate limits

The REST API uses a token bucket rate limiter with per-key and per-endpoint quotas. This page documents every limit, how to read the rate-limit headers, the correct way to handle 429 responses, and the implementation mistakes that turn a transient throttle into a thundering herd.

Why we rate-limit

Rate limits are a contract, not an obstacle. They protect the API infrastructure from a single misbehaving client (whether intentionally abusive or accidentally runaway), they ensure predictable latency for every customer on shared infrastructure, and they create a clear ceiling that lets you plan capacity without surprises. The limits documented below are calibrated for production senders — if your application reaches them under normal load, the right answer is usually to architect around them (queueing, batching, caching) rather than to retry harder.

Three categories of misuse account for the majority of 429 responses we see in practice. First, polling loops — an application checking message status every second instead of subscribing to webhooks. Second, infinite retries on already-failed requests — a 4xx error that is not 429 will not be fixed by retrying it. Third, parallel sends without coordination — ten worker processes each making 100 requests per second, with no awareness that they share a single API key's quota. The patterns at the bottom of this page exist to help you avoid those three.

Limits are negotiable. The numbers below are defaults. If your sending pattern legitimately exceeds them — black-Friday-scale transactional bursts, multi-region failover producing temporary 2x load, batch reporting jobs that pull millions of events — raise a ticket and we will adjust the per-key quota. Application-level workarounds (sharding across multiple keys, sleeping unnecessarily) tend to be a worse answer than asking for an appropriate ceiling.

Token bucket model

The rate limiter implements a token bucket algorithm rather than a fixed-window counter. The behavioural difference matters: with a fixed window, you can send 100 requests at 11:59:59 and another 100 at 12:00:00, and both pass — effectively 200 requests in two seconds. With a token bucket, the bucket refills at a steady rate and the burst is bounded by the bucket capacity, regardless of where you sit relative to a reset boundary.

How the bucket fills and drains:
• Each API key has a bucket per endpoint with a fixed capacity (the burst limit) and a steady refill rate (tokens per second).
• Each request consumes one token from the appropriate bucket.
• The bucket refills continuously at the steady-state rate. A bucket can never exceed capacity.
• If a request arrives when the bucket is empty, the request is rejected with HTTP 429 and a Retry-After header indicating when the next token will be available.
• The token bucket allows controlled bursts up to capacity, then enforces the steady-state rate sustainably.

For example: the POST /v1/messages endpoint has a bucket capacity of 200 tokens and a refill rate of 200 per second. You can fire 200 requests in the first 100 milliseconds (consuming the full bucket); after that, you sustain 200 per second indefinitely (consuming tokens as they arrive). If you stop sending for 1 second, the bucket refills to capacity and you have another full burst available.

The token bucket model is what most major REST APIs use (Stripe, GitHub, AWS, Atlassian) because it tolerates the natural burstiness of real applications without penalising clients whose traffic is uneven by design. A retry storm hitting at the same instant as a legitimate spike is a different story — that is what jitter exists to prevent, covered below.

Per-endpoint limits

Limits are scoped per API key, not per account. Generating multiple scoped keys for distinct callers (a separate key for the marketing platform, the suppression processor, the analytics dashboard) gives each caller its own bucket — useful when one caller's behaviour should not affect another's headroom.

EndpointBurst capacitySteady rate
POST/v1/messages200 requests200/sec
GET/v1/messages & /v1/messages/{id}100 requests50/sec
POST/v1/suppressions (bulk: 1000 addrs/req)30 requests10/sec
GET/v1/suppressions60 requests30/sec
GET/v1/suppressions/{email}200 requests100/sec
DELETE/v1/suppressions/{email}30 requests10/sec
GET/v1/events (webhook event backfill)30 requests10/sec
GET/v1/stats/* (reporting)20 requests5/sec
POST/v1/webhooks (configuration changes)10 requests1/sec
All other endpoints (default)100 requests30/sec

The shape of these limits reflects the cost of each endpoint. Send messages can burst high because they queue cheaply; reporting endpoints aggregate over large datasets and are intentionally conservative. The suppression bulk-add allows 1,000 addresses per request, so the effective rate is 10,000 addresses per second — enough for a 10 million address ESP migration import to complete in under 20 minutes.

An account-level secondary limit caps the aggregate across all keys at 1,000 requests per second to any single endpoint. This kicks in only when key-level fan-out becomes problematic; most senders never hit it.

Rate-limit headers

Every API response — not just 429 responses — includes the rate-limit headers. Read them on every response and you can proactively slow down before hitting the limit, instead of reactively backing off after the fact.

HTTP/1.1 200 OK Content-Type: application/json X-RateLimit-Limit: 200 X-RateLimit-Remaining: 178 X-RateLimit-Reset: 1714912860 X-RateLimit-Bucket: messages-write
HeaderMeaning
X-RateLimit-LimitBurst capacity of this bucket. Useful for displaying capacity utilisation in monitoring dashboards.
X-RateLimit-RemainingTokens currently available. When this drops near 0, slow down; when it hits 0, the next request returns 429.
X-RateLimit-ResetUnix epoch when the bucket will be at full capacity (assuming no further requests). Approximate — the bucket refills continuously.
X-RateLimit-BucketIdentifier of the bucket consumed by this request. Useful when one application makes calls to multiple endpoints and you want to track each bucket independently.
Retry-AfterOnly present on 429 responses. Number of seconds to wait before the next attempt. Always non-zero.
The proactive pattern. When X-RateLimit-Remaining drops below 10% of X-RateLimit-Limit, your client should start adding small inter-request delays. This avoids the 429 entirely. A simple rule: if remaining < 20, sleep for (Reset - now) / Remaining seconds before the next request. The math works out to "spread the remaining requests evenly across the rest of the window."

Handling 429 responses

When a request hits the rate limit, the response is HTTP 429 with Retry-After indicating the seconds to wait. The minimum correct handler is straightforward: if the response is 429, sleep for the Retry-After duration, then retry. Failing to handle 429 at all means the request is silently dropped — which for transactional sends is far worse than a delayed delivery.

HTTP/1.1 429 Too Many Requests Retry-After: 3 X-RateLimit-Limit: 200 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1714912863 Content-Type: application/json { "error": { "code": "rate_limit_exceeded", "message": "Per-key rate limit of 200 requests/second exceeded on /v1/messages.", "retry_after_seconds": 3, "bucket": "messages-write", "request_id": "req_01HV8K2P9F3MZQX" } }

Two design rules turn 429 handling from "barely works" into "production-grade":

  • Always respect Retry-After. The header tells you exactly how long until the next token is available. Retrying earlier is wasteful and may extend the throttle if the server detects rapid retry loops (some APIs — OpenAI, GitHub — explicitly extend backoff for clients that retry too aggressively).
  • Cap retries. A 429 that persists across 5+ retries usually indicates a structural problem (too many parallel callers, polling loop, runaway script) rather than a transient throttle. After the cap, surface the error to the caller. Never drop the request silently.

Exponential backoff with jitter

If Retry-After is somehow missing (malformed proxy, transient network issue), fall back to exponential backoff with jitter. The wait time doubles after each failure (1s, 2s, 4s, 8s...), and a small random offset is added to each delay. The randomisation matters: without jitter, a hundred clients hitting the limit at the same moment all retry at the same moment, producing a thundering herd that hits the limit again. With jitter, the retries spread out across a window, and the recovery is graceful.

Python

import os import time import random import requests API_BASE = "https://api.cloudserverforemail.com" API_KEY = os.environ["CSE_API_KEY"] def post_message(payload, max_retries=5): headers = {"Authorization": f"Bearer {API_KEY}"} for attempt in range(max_retries): r = requests.post( f"{API_BASE}/v1/messages", json=payload, headers=headers, timeout=10, ) if r.status_code != 429: r.raise_for_status() return r.json() # 429: prefer Retry-After, fall back to exponential backoff retry_after = r.headers.get("Retry-After") if retry_after: wait = float(retry_after) else: # 1s, 2s, 4s, 8s, 16s + jitter up to 50% base = min(2 ** attempt, 30) wait = base + random.uniform(0, base * 0.5) time.sleep(wait) raise RuntimeError(f"Rate-limit retries exhausted ({max_retries} attempts)")

Node.js

async function postMessage(payload, maxRetries = 5) { const headers = { "Authorization": `Bearer ${process.env.CSE_API_KEY}`, "Content-Type": "application/json", }; for (let attempt = 0; attempt < maxRetries; attempt++) { const r = await fetch("https://api.cloudserverforemail.com/v1/messages", { method: "POST", headers, body: JSON.stringify(payload), }); if (r.status !== 429) { if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.json(); } // 429: prefer Retry-After, fall back to exponential backoff with jitter const retryAfter = r.headers.get("Retry-After"); let waitMs; if (retryAfter) { waitMs = parseInt(retryAfter, 10) * 1000; } else { const base = Math.min(Math.pow(2, attempt), 30) * 1000; const jitter = Math.random() * base * 0.5; waitMs = base + jitter; } await new Promise(resolve => setTimeout(resolve, waitMs)); } throw new Error(`Rate-limit retries exhausted (${maxRetries} attempts)`); }

Go

package main import ( "fmt" "math" "math/rand" "net/http" "strconv" "time" ) func postWithBackoff(req *http.Request, maxRetries int) (*http.Response, error) { client := &http.Client{Timeout: 10 * time.Second} for attempt := 0; attempt < maxRetries; attempt++ { r, err := client.Do(req) if err != nil { return nil, err } if r.StatusCode != 429 { return r, nil } // 429: prefer Retry-After, fall back to exponential backoff var wait time.Duration if ra := r.Header.Get("Retry-After"); ra != "" { secs, _ := strconv.Atoi(ra) wait = time.Duration(secs) * time.Second } else { base := math.Min(math.Pow(2, float64(attempt)), 30) jitter := rand.Float64() * base * 0.5 wait = time.Duration((base + jitter) * float64(time.Second)) } r.Body.Close() time.Sleep(wait) } return nil, fmt.Errorf("rate-limit retries exhausted") }
Why jitter is non-negotiable. Marc Brooker's 2015 AWS post on exponential backoff and jitter (still the canonical reference) showed that without jitter, retry storms scale linearly with concurrent clients — 100 clients produce a 100x retry spike. With "full jitter" (random delay between zero and the exponential limit), retry collisions drop to a small constant. The implementation cost of jitter is one line of code; the absence of it is a pattern that turns a 5-second throttle into a 5-minute outage.

Distributed coordination

Rate limiting is straightforward when one process makes the requests. Distributed senders — multiple workers, multiple servers, autoscaled containers — need to coordinate so they collectively respect the per-key limit, not so that each instance independently believes it has the full quota. The pattern that scales:

  1. Centralised token tracker. Use Redis (or any shared cache) to track tokens consumed per bucket. Each worker decrements the shared counter atomically before making a request; if the decrement fails, the worker waits for refill rather than firing the request.
  2. Client-side limiter. A library that mirrors our bucket capacity locally and refills at the same rate. The library queues requests when the local bucket is empty, regardless of what the server says. This bounds your outgoing rate at the source.
  3. Backoff coordination on 429. When one worker hits 429, broadcast the Retry-After to all other workers (Redis pub-sub, or a shared "throttle until" key). All workers pause until the throttle clears, instead of independently retrying.

For most senders, option 2 (a client-side limiter) is enough. Open-source libraries exist for the major languages: aiolimiter (Python), bottleneck (Node.js), golang.org/x/time/rate (Go). They handle the bucket math, queueing, and steady-state pacing without you implementing it from scratch. Configure the limiter to slightly below our limit (90% is a safe default) and you will rarely see a 429.

Proactive client design

The patterns below shift work from runtime (handling 429s) to design time (avoiding them in the first place). Each is worth more than it costs to implement.

  • Subscribe to webhooks instead of polling. The single largest source of unnecessary API traffic in our customer base is applications that poll GET /v1/messages/{id} every few seconds to track delivery status. Webhooks deliver the same information within seconds of the actual event, with zero rate-limit budget consumed.
  • Batch suppression operations. The bulk endpoint accepts up to 1,000 addresses per request. Importing 100,000 addresses one-at-a-time consumes 100,000 tokens; the same operation as 100 batch requests consumes 100 tokens. Two orders of magnitude difference.
  • Cache responses where appropriate. The list of suppressed addresses changes slowly. If you query it every minute to populate a cache, you save thousands of per-send check-this-address requests. If-Modified-Since is supported on read endpoints and returns 304 (not counted against rate limit) when nothing has changed.
  • Use scoped keys to isolate limits. A runaway analytics dashboard hammering /v1/stats/ on the same key as your transactional sends will eventually trigger 429 on send requests. Separating into distinct keys means the dashboard's bad day does not impact transactional delivery.
  • Schedule large operations in the off-peak window. Bulk imports, full-list exports, and reporting jobs that touch large datasets benefit from running outside your peak send hours. The rate limits are the same, but the bucket is more likely to be at capacity, and a temporary throttle does not affect a real-time customer flow.
  • Add jitter to scheduled jobs. A cron job that fires "every minute on the minute" produces a thundering herd of itself. Add 0–30 seconds of random offset; the work spreads across a window and stops competing with itself.

SMTP relay limits

SMTP relay sending is governed differently from REST API rate limits. The SMTP path uses per-IP, per-vMTA throttling at the PowerMTA layer, calibrated to the destination ISP rather than to a generic bucket. The reasoning: ISPs themselves enforce different rates for different domains, and a one-size-fits-all SMTP rate limit produces both wasted capacity (for ISPs that accept faster) and damaged reputation (for ISPs that throttle harder). The PowerMTA configuration adapts to each.

The headline numbers for SMTP relay capacity are determined by your plan tier and IP pool size, not by an API rate limiter. Specifically:

  • Concurrent connections per IP — configured per ISP based on accepted concurrency for that destination (Gmail tolerates 10-20 concurrent inbound connections per source IP; Microsoft is closer to 5-10).
  • Messages per connection — configured per ISP. Most major mailbox providers tolerate 50-100 messages per connection before requiring a new connection.
  • Per-domain throttling — calibrated by the PowerMTA domain block configuration. This is documented in the operational notes on PowerMTA tuning rather than in this REST API reference.
Sending velocity is a separate conversation. The REST rate limits documented above govern the API surface (how fast you can call our API). The SMTP throttling governs the wire (how fast we deliver to receiving MTAs). The two are decoupled — you can submit messages to the REST API faster than they will physically deliver, in which case they queue at PowerMTA awaiting the appropriate sending window.

Common pitfalls

Patterns that recur in incident reports often enough to flag explicitly. Each is operationally damaging; each is preventable in a few lines of code.

  • Retrying on every 4xx, not just 429. A 401 is a credential problem. A 403 is a scope problem. A 422 is a validation problem. Retrying any of those produces the same failure forever, consuming rate-limit budget that legitimate requests need. The retry condition should be exactly: status == 429 OR (status >= 500 AND status <= 599).
  • Sleeping on Retry-After: 0. The header is always non-zero on our responses, but proxies and CDNs occasionally rewrite values. Treat zero as "wait at least 1 second" defensively.
  • Implementing exponential backoff without a cap. A 429 that persists for an hour produces a one-hour wait between retries by attempt 11 (if doubling from 1s without a cap). The legitimate cap is around 30 seconds for most production callers; longer than that and the retry is competing with whatever upstream system needed the request in the first place.
  • No jitter on retries. The thundering herd is the failure mode this entire section exists to prevent. If your retry code does not include random.uniform() or equivalent, it will produce a thundering herd the first time many clients hit 429 simultaneously.
  • Ignoring rate-limit headers on 200 responses. The headers are always present. Reading them lets you slow down before you hit the wall, instead of recovering after. The cost is one if-statement; the benefit is avoiding 429 entirely.
  • Polling tight loops on transient errors. An endpoint returns 503 (server overload). Your client retries every 100ms for the next 60 seconds. By the time the endpoint is healthy, your client has consumed thousands of tokens for nothing. Apply backoff to 5xx responses too, not just 429.
  • Sharing one API key across N parallel workers without coordination. Each worker thinks it has the full quota; collectively they exhaust it instantly. Either coordinate with a shared limiter or shard work onto separate keys.