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, carry different headers, and only some of them are worth retrying.
Every quota below counts recipients, not messages. One message addressed to five people — across to, cc and bcc — consumes five units of every allowance on this page. The plan tables say "emails" because that is how the allowance is priced, but the thing being counted is recipients.
Request rate limits
The send rate limit applies to POST /v1/emails and POST /v1/emails/batch only. Domain add and delete have their own limit of 10 a minute per environment. 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). Each request consumes one token, so you can burst 10 requests instantly and sustain 8 per second. Read the limits from the ratelimit-* headers.
Headers
The ratelimit-* headers appear on allowed send requests, on domain add and delete, and on rate_limit_exceeded; other 429s carry none. 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 request denied by the rate limiter returns 429 rate_limit_exceeded with those three headers 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 checked when a send is accepted. A denied send stores and sends nothing, and a batch is all-or-nothing: a denied batch saves nothing, queues nothing and uses no daily allowance.
See Quotas and plan limits for what each plan includes, the overage ceiling and the Free plan's marketing share.
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. - Marketing allowance: for
type: "marketing"sends only, the separate monthly marketing ceiling. Crossing it fails withmarketing_quota_exceeded. - Daily limit: a rolling 24-hour limit. Free allows 200 recipients a day. Exhausting it fails with
daily_quota_exceeded. Upgrade to a paid plan to remove the daily limit.
A paid plan can still return 429 volume_limit_reached at a very high daily volume; contact contact@mailfully.com to raise it.
Monthly quotas, the marketing allowance and the spend cap apply to live sends only: test traffic is never billed or counted. Test mode allows 200 recipients a day on every plan, counted separately from live. Past that, test sends return 429 daily_quota_exceeded until the 24-hour window rolls.
New accounts
A newly signed-up organization sends live mail under an additional review ceiling while staff look at the account. A live send refused by that ceiling returns 429 account_under_review rather than daily_quota_exceeded:
{
"error": {
"type": "account_under_review",
"message": "Your account needs further review before it can send more live email. Reviews take 24 hours on average."
}
}
Test mode is never subject to it, so you can integrate and test end to end from the first minute. Verifying a sending domain is the one thing that moves the review along from your side.
Which 429 carries which headers
type | retry-after | ratelimit-* | Retry? |
|---|---|---|---|
rate_limit_exceeded | Yes (exact) | Yes | Yes, transient |
daily_quota_exceeded | Yes (coarse) | No | Yes, after the wait |
volume_limit_reached | Yes (coarse) | No | Alert; clears as the window moves, or contact contact@mailfully.com |
account_under_review | Yes (coarse) | No | Alert; clears when the review ends or the window moves |
monthly_quota_exceeded | No | No | No, alert |
marketing_quota_exceeded | No | No | No, alert |
spend_cap_exceeded | No | No | No, alert |
Handling 429s
Branch on the error type:
rate_limit_exceededis transient and itsretry-afteris exact. Honor it, and add jitter so parallel workers don't retry in lockstep.daily_quota_exceeded,volume_limit_reachedandaccount_under_reviewclear within the rolling day without action;account_under_reviewalso ends when staff approve the account. Theirretry-afteris a coarse estimate, not a precise refill time. Back off ondaily_quota_exceeded; for the other two, alert a person rather than looping. A batch with more recipients than your daily limit is never accepted; split it.monthly_quota_exceeded,marketing_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.
The daily retry-after describes the wait for a single recipient. A batch needing several recipients' worth of allowance may still be refused after that wait. Back off exponentially across repeated refusals rather than retrying on the header alone.
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-shipped",
},
body: JSON.stringify(payload),
});
if (res.status === 429) {
const { error } = await res.json();
switch (error.type) {
case "rate_limit_exceeded": {
const waitMs = Number(res.headers.get("retry-after")) * 1000;
await sleep(waitMs + Math.random() * 250); // jitter
// retry with the same Idempotency-Key
break;
}
case "daily_quota_exceeded": {
// The header is a coarse estimate: back off across attempts.
const hintMs = Number(res.headers.get("retry-after")) * 1000;
await sleep(hintMs * 2 ** attempt + Math.random() * 250);
break;
}
default:
// volume_limit_reached / account_under_review also clear as the
// 24-hour window moves, but retrying right away won't help.
// monthly_quota_exceeded /
// marketing_quota_exceeded / spend_cap_exceeded: alert, don't loop.
alertOps(error.type, error.message);
}
}
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.
