Appearance
Rate limits
Three limits apply, in order. Almost every integration only ever meets the first.
| Limit | Scope | Budget | 429 message |
|---|---|---|---|
| Per key | One API key | 100 requests / 60 s | API key rate limit exceeded |
| Per account | All your keys and sessions together | 300 requests / 60 s, burst 60 | Account rate limit exceeded |
| Per IP | One network address | Not published | Too many requests from this IP address |
All three return 429 with code rate_limited:
json
{ "error": { "code": "rate_limited", "message": "API key rate limit exceeded" } }Branch on code. Read message to tell the three apart.
One key changes nothing
The per-key limit is the tightest of the three, so a single-key integration behaves exactly as it always has. The account limit only becomes visible if you run several keys at once.
What counts
Every authenticated request against the key, whatever the endpoint or outcome — reads and 4xx responses included. A tight loop of failing requests exhausts the budget as fast as successful ones.
The public health check does not consume any budget. The QR redirect is unauthenticated and consumes no key or account budget; it has its own generous per-IP allowance so that scans of printed mail keep resolving.
The per-account limit
Keys used to be the only unit of accounting, which meant issuing a second key doubled your throughput. It no longer does: all of an account's keys draw on one 300 requests / 60 s ceiling.
Separate keys are still worth using — they isolate failures, and one runaway loop cannot exhaust another key's own 100/60 s budget — but they no longer multiply your total throughput.
The account limit allows a burst of 60 requests back to back before the steady rate binds, so a short parallel fan-out is fine; a sustained one is not.
The per-IP limit
A shield against floods rather than a budget to plan against, so its threshold is not published and does not appear in the headers. Normal integration traffic will not reach it. If you do, the response still carries Retry-After.
It is applied per network address, so many callers sharing one outbound IP — an office, a NAT gateway, several containers behind one address — share it. It sits well above the account limit precisely so your own account limit is what you meet first.
Rate-limit headers
Every /api/v1 response carries your current position:
http
RateLimit-Limit: 100
RateLimit-Remaining: 12
RateLimit-Reset: 34
RateLimit-Policy: 100;w=60;name="key", 300;w=60;name="account"| Header | Meaning |
|---|---|
RateLimit-Limit | Most requests available at once, for the policy closest to running out |
RateLimit-Remaining | Requests left against that policy, right now |
RateLimit-Reset | Seconds until that policy is fully replenished |
RateLimit-Policy | Every policy in force, as quota;w=seconds;name="..." |
Limit/Remaining/Reset describe a single policy — the one that will stop you next. So you can pace against RateLimit-Remaining alone without deciding which limit binds, and RateLimit-Remaining is never greater than RateLimit-Limit. RateLimit-Policy lists every policy's sustained quota when you want the whole picture.
Why RateLimit-Limit can be smaller than the quota in RateLimit-Policy
The account limit is a rate, not a bucket of 300 tokens. Over a minute you can make 300 requests by pacing them, but only 61 back to back — the burst. So on an idle account RateLimit-Limit reads 61 while RateLimit-Policy advertises 300;w=60. Pace against RateLimit-Remaining; plan capacity against RateLimit-Policy.
The per-key limit has no burst allowance, so its two numbers agree: all 100 are available at once.
On a 429, Retry-After gives the exact number of seconds to wait:
http
HTTP/1.1 429 Too Many Requests
Retry-After: 34Headers describe the moment they were sent
With concurrent requests in flight, RateLimit-Remaining is already out of date when you read it. Treat it as a signal to slow down, not as a reservation.
Notes:
- Headers appear on successful and failed
/api/v1responses alike, so you can pace without waiting to be throttled. - The per-IP limit is deliberately absent from
RateLimit-Policy. /api/auth/*responses do not carry these headers.- Browser clients: all five header names are exposed via CORS, so
response.headers.get("RateLimit-Remaining")works from the browser.
Retrying
Prefer Retry-After when it is present, and fall back to exponential backoff with jitter when it is not.
ts
const RETRYABLE = new Set(["rate_limited", "service_unavailable"]);
async function withRetry<T>(
fn: () => Promise<{ data: T; response: Response }>,
attempts = 5,
): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return (await fn()).data;
} catch (err) {
const { code, response } = err as { code?: string; response?: Response };
if (!code || !RETRYABLE.has(code) || attempt >= attempts - 1) throw err;
// The server tells you exactly how long to wait.
const retryAfter = Number(response?.headers.get("Retry-After"));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
// 2s, 4s, 8s, 16s — plus jitter so parallel workers do not resynchronise.
: Math.min(2 ** (attempt + 1) * 1000, 60_000);
await new Promise((r) => setTimeout(r, waitMs + Math.random() * 1000));
}
}
}Do not retry a campaign create without its key
POST /api/v1/campaigns may have succeeded before the 429 — or a 500 may hide a created campaign. Always retry with the sameIdempotency-Key; a fresh key on retry can mail the batch twice.
Staying under the limit
Pace against the headers. Read RateLimit-Remaining and slow down as it approaches zero, rather than waiting to be throttled.
Page in large chunks. limit=100 costs one request where the default 20 costs five — a 5× reduction for the same data.
Import files instead of looping. One CSV import can load thousands of recipients in a single request. Adding them one at a time via POST /api/v1/recipients costs one request each and will hit the limit within about 100 rows.
Pace bulk reads. At limit=100 you can read roughly 10,000 rows per minute on one key. For more, add a delay:
ts
async function* paced<T>(pages: AsyncIterable<T>, perMinute = 90) {
const gap = 60_000 / perMinute; // ~667 ms — a little under the limit
for await (const page of pages) {
yield page;
await new Promise((r) => setTimeout(r, gap));
}
}Poll with backoff. A campaign takes minutes; polling every second for five is 300 requests — three windows' worth — for a value that changed a handful of times. Start at 2 seconds and back off. See Polling & failures.
Split keys by workload. A sending service and a reporting dashboard on separate keys cannot exhaust each other's 100/60 s budget, and a runaway loop in one does not stop the other. It also limits the blast radius of a leak. Note that both still draw on the shared account ceiling.
Other quotas
A key may also be created with an expiry (1–365 days). An expired key returns 401 API key expired, not 429 — retrying will not help. Issue a new key in the web app.
If a key has a usage quota that runs out, the response is 401API key usage exceeded. Also not retryable.
429 is transient, 401 is not
Both mean "you cannot proceed right now", but only 429 resolves by waiting. Route 401s to an operator alert rather than into the retry loop.
Next
- Errors — which codes are retryable
- Idempotency — retrying a send safely
- Production checklist