Reference
Rate limits
1,200 requests per 60 seconds, counted per API key, across every endpoint. This is not a billing control — it protects the service from a client stuck in a retry loop, and caps the damage a leaked key can do before anybody notices.
What it counts
| Property | Value |
|---|---|
| Limit | 1,200 requests |
| Window | 60 seconds, sliding |
| Counted against | The API key id — the twelve characters after the prefix. Not your IP, and not your account: two keys on one account get 1,200 each. |
| Scope | Every /v1/ endpoint together, reads and writes alike. There is no per-endpoint budget. |
| Unauthenticated requests | Counted against the source IP instead, since that is the only identity such a request has. This is what bounds brute force against key ids. |
| Exempt | /health and /health/ready. Limiting a health check turns a traffic spike into a container restart loop. |
Keying on the API key rather than the IP is deliberate: customers are servers, so an IP is frequently shared by everyone behind one NAT and is meaningless as an identity — while a stolen key should be bounded on its own regardless of where it is used from.
What a 429 looks like
HTTP/1.1 429 Too Many Requests
retry-after: 37
x-ratelimit-limit: 1200
x-ratelimit-remaining: 0
x-ratelimit-reset: 37
{
"error": {
"type": "rate_limited",
"message": "Too many requests. The limit is 1200 per 60 seconds for this API key.",
"retryAfterSeconds": 37
}
}| Header | Meaning |
|---|---|
| retry-after | Seconds until the window frees up. Present only on a 429. |
| x-ratelimit-limit | The ceiling for this window. Sent on successful responses too. |
| x-ratelimit-remaining | How many requests are left. Watch this and slow down before you hit zero, rather than discovering the limit from an error. |
| x-ratelimit-reset | Seconds until the counter resets. |
The body carries error.retryAfterSeconds, the same number as the header, so a client that only parses JSON does not have to reach for headers.
It is not a sending quota
Three different limits return 429. Read error.type before deciding what to do, because the right response to each is completely different.
| Type | Means | Do |
|---|---|---|
| rate_limited | Too many HTTP requests in the last minute. | Wait the stated seconds and retry in process. |
| daily_limit_reached | Your account’s daily sending cap. | Stop sending today. Retry-After points at midnight UTC. |
| monthly_limit_reached | Your plan’s monthly allowance. | Upgrade, or wait for the 1st. Do not hold a connection open for that. |
Handling it
// Honour retry-after rather than guessing. Retrying sooner is counted
// against the same window and simply extends it.
async function request(path, init, attempt = 0) {
const res = await fetch(`https://api.posthastemail.dev${path}`, init)
if (res.status !== 429 || attempt >= 3) return res
const type = (await res.clone().json().catch(() => null))?.error?.type
// Only the request limit is worth retrying in-process. A daily or monthly
// sending cap means waiting hours or weeks — that belongs in a queue,
// not in a loop holding a connection open.
if (type !== 'rate_limited') return res
const wait = Number(res.headers.get('retry-after') ?? 1)
await sleep(wait * 1000 + Math.random() * 250) // jitter
return request(path, init, attempt + 1)
}Add jitter. A fleet of workers that all retry after exactly the stated interval arrives together and trips the limit again in lockstep, which is how a brief throttle becomes a sustained one.
If 1,200 a minute is genuinely not enough for what you are doing, the usual answer is fewer, larger requests — a list with limit=100 rather than a hundred single-object fetches — or a second key for the workload that needs its own budget. Talk to us if neither fits.
When the limiter is unavailable
The counter is shared across API containers through Redis, so the limit is a single budget rather than one per running process. If Redis is unreachable the limiter is skipped rather than failing requests: an outage in a protective layer must not take down the thing it protects.
Which means an absent x-ratelimit-remaining header is not a promise that nothing is being counted. Do not build a client that depends on the header being there.
NextAccount and usage →Your plan, your remaining quota, usage figures, and verifying the record itself.