Rate Limits
How the public API meters requests per API key, the headers every response carries, and how to back off correctly.
Every request to a /public/* endpoint is metered against the API key that made it. Limits are per key, not per workspace and not per IP address — two keys never share a budget, whether or not they belong to the same workspace. Issuing a second key for a second integration therefore gives that integration its own headroom.
The two budgets
Each key is metered against two independent sliding windows, and every request is charged to both that apply to it:
| Budget | Applies to | Default |
|---|---|---|
requests | Every request | 300 per 60 seconds |
writes | Everything except GET and HEAD | 120 per 60 seconds |
A read (GET, HEAD) is charged only against requests. Any other method — POST, PUT, DELETE, PATCH — counts as a write and is charged against writes and requests, so a key doing nothing but writes will exhaust writes first. That is the intent: a write costs far more to serve than a read.
Both budgets are checked before either is charged, so a rejected request costs you nothing. A write refused for exceeding writes does not consume a slot from requests, and a write refused because requests is exhausted does not consume a slot from writes. In practice that means exhausting your write budget never stops you reading, and being blocked on one budget never quietly drains the other while you retry.
Bursting
These are sliding windows, not fixed buckets that refill on the minute. A key that has been idle can spend its entire budget in a single instant; it is only held back once the trailing window is genuinely full. And because a rejected request is never counted, a budget always recovers — hammering a limit that has already been reached will not push your recovery further out.
Headers on every response
Successful responses carry your current budget, so a client can pace itself rather than discover the ceiling by hitting it:
| Header | Meaning |
|---|---|
X-RateLimit-Bucket | Which budget the numbers below describe — whichever of the two you are closest to exhausting |
X-RateLimit-Limit | That budget's ceiling |
X-RateLimit-Remaining | Slots left in it |
X-RateLimit-Window | Length of the sliding window, in seconds |
There is deliberately no X-RateLimit-Reset. A sliding window has no single instant at which it refills — it recovers one slot at a time as each earlier request ages out — so a reset timestamp would be a fiction. X-RateLimit-Window tells you how long a slot takes to come back.
When you exceed a limit
The request is rejected with 429 Too Many Requests, a Retry-After header, and a JSON body:
{
"statusCode": 429,
"error": "RATE_LIMITED",
"message": "API key rate limit exceeded (writes). Try again in 12 seconds.",
"bucket": "writes",
"retryAfterSeconds": 12,
"retryAfter": 12
}retryAfterSeconds and retryAfter always carry the same value; both are present so a client written against either of Mailcrate's other 429 responses reads this one correctly. bucket names which of the two budgets you spent — worth reading, because "bucket": "writes" means your reads are still going through.
Wait for Retry-After before retrying. Retrying immediately will not shorten the wait; it will simply be rejected again.
Retry in code
A small wrapper that waits out a 429 and tries again is all most integrations need. Because a rejected request costs nothing, retrying a write is safe; pair it with an idempotencyKey on sends so a retry after a timeout cannot send twice.
export async function withRetry(request: () => Promise<Response>, attempts = 5): Promise<Response> {
for (let attempt = 1; ; attempt++) {
const res = await request();
if (res.status !== 429 || attempt === attempts) return res;
// Retry-After is in seconds. Fall back to the body, then to a growing wait.
const body = await res.clone().json().catch(() => ({}));
const seconds = Number(res.headers.get('retry-after') ?? body.retryAfterSeconds ?? body.retryAfter ?? 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
}
const res = await withRetry(() =>
fetch('https://api.mailcrate.io/public/event', {
method: 'POST',
headers: {
Authorization: `Api-Key ${process.env.MAILCRATE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: 'ada@example.com', name: 'order.placed' }),
}),
);A 429 from the daily or monthly sending quota looks similar but can ask you to wait for hours. Put a ceiling on the wait in a request path, and hand long waits to a queue instead of sleeping.
This body is specific to the 429. Every other failure follows the shape in Errors.
Automated clients
If you're driving the API from an agent or a job that runs in a loop, read X-RateLimit-Remaining on every response and slow down as it approaches zero, rather than treating the 429 as your signal. The headers exist so a well-behaved client never sees a 429 at all.
Journey webhook triggers
The unauthenticated journey trigger URLs (/hooks/journeys/<token>) carry no API key, so they are metered by calling IP address instead — generously, at 600 calls per 5 minutes per address, which is well clear of any normal enrollment volume but bounds a runaway loop. A throttled call is rejected with 429 and a Retry-After header. The body is slightly different from the API's: it has retryAfter (seconds) but no bucket or retryAfterSeconds, so read the header if you handle both.
Raising a limit
The defaults above suit typical integrations. If you have a legitimate workload that needs more — a bulk backfill, or a high-volume event pipeline — get in touch rather than working around the limit with multiple keys; limits are configurable per deployment.