Rate limits and quotas
How the send-route rate limit and the per-plan send quotas interact, and which response headers to build retry logic on.
Sending is capped by two separate mechanisms: a per-request rate limit on how fast you can call the send routes, and send quotas on how many emails you can send per day and per month. They fail with different 429 error types, and only some of them are worth retrying.
Request rate limits
The rate limit applies to POST /v1/emails and POST /v1/emails/batch only. Read routes are not rate limited and never carry ratelimit-* headers.
Each org gets a token bucket per environment: burst capacity 10, refilling at 8 requests per second (the same for every plan today). Each request consumes one token, so you can burst 10 requests instantly and sustain 8 per second. A second, platform-wide bucket sized from upstream SES capacity sits behind it; the response headers always reflect whichever bucket is most constraining, so read the headers rather than hardcoding the numbers.
Headers
Every allowed request on a send route carries:
| Header | Meaning |
|---|---|
ratelimit-limit | Bucket capacity |
ratelimit-remaining | Whole tokens left |
ratelimit-reset | Seconds until the next token; 0 while tokens remain |
A denied request returns 429 with the three headers above plus:
| Header | Meaning |
|---|---|
retry-after | Seconds to wait before retrying, rounded up |
The 429 envelope
{
"error": {
"type": "rate_limit_exceeded",
"message": "Rate limit exceeded. Please slow down."
}
}
Send quotas
Quotas cap email volume, not request rate. They are enforced at accept time, before anything is persisted or enqueued. A denied send stores and sends nothing. A batch is checked once for the whole batch and is all-or-nothing, with one caveat: a batch denied at the daily cap has already consumed daily-cap allowance for the units checked before the denial.
| Plan | Price | Included emails/mo | Overage per 1,000 |
|---|---|---|---|
| Free | $0 | 3,000 (hard stop) | — |
| Starter | $19/mo | 50,000 | $0.40 |
| Growth | $49/mo | 150,000 | $0.40 |
| Scale | $149/mo | 500,000 | $0.40 |
Paid plans can send past their included volume into metered overage, up to a hard ceiling of 5× the included allowance. The free plan stops at its allowance.
The quota checks run in order:
- Spend cap: an optional, org-configured monthly overage spend limit. A send whose projected overage cost crosses it fails with
spend_cap_exceeded. - Monthly quota: the plan allowance (free) or the 5× overage ceiling (paid). Crossing it fails with
monthly_quota_exceeded. - Daily cap: a rolling 24-hour cap. Every org starts at 100 emails per day. The cap does not rise automatically. Contact support to raise it as your sending grows. Exhausting it fails with
daily_quota_exceeded.
Monthly quotas and the spend cap apply to live sends only: test traffic is never billed or counted. The daily cap applies to both test and live.
| Status | type | retry-after header |
|---|---|---|
| 429 | daily_quota_exceeded | Yes — seconds until capacity returns |
| 429 | monthly_quota_exceeded | No |
| 429 | spend_cap_exceeded | No |
Handling 429s
Branch on the error type. The four 429s call for different reactions:
rate_limit_exceededanddaily_quota_exceededare transient. Honorretry-afterexactly, and add jitter so parallel workers don't retry in lockstep.monthly_quota_exceededandspend_cap_exceededwill not clear until the month rolls over, you upgrade the plan, or you raise the spend cap. Don't retry-loop them. Alert instead.
const res = await fetch("https://api.mailfully.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAILFULLY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order-1234-attempt-1",
},
body: JSON.stringify(payload),
});
if (res.status === 429) {
const { error } = await res.json();
if (error.type === "rate_limit_exceeded" || error.type === "daily_quota_exceeded") {
const waitMs = Number(res.headers.get("retry-after")) * 1000;
await sleep(waitMs + Math.random() * 250); // jitter
// retry with the same Idempotency-Key
} else {
// monthly_quota_exceeded / spend_cap_exceeded: alert, don't loop
}
}
Pace proactively by watching ratelimit-remaining instead of waiting for denials, and pair every retry with an idempotency key — failed requests are never cached, so retrying with the same key is safe.