---
title: Rate limits and quotas
description: How the send-route rate limit and the per-plan send quotas interact, and which response headers to build retry logic on.
---

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

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

```json
{
  "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:

1. **Spend cap**: an optional, org-configured monthly overage spend limit. A send whose projected overage cost crosses it fails with `spend_cap_exceeded`.
2. **Monthly quota**: the plan allowance (free) or the 5× overage ceiling (paid). Crossing it fails with `monthly_quota_exceeded`.
3. **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_exceeded` and `daily_quota_exceeded` are transient. Honor `retry-after` exactly, and add jitter so parallel workers don't retry in lockstep.
- `monthly_quota_exceeded` and `spend_cap_exceeded` will not clear until the month rolls over, you upgrade the plan, or you raise the spend cap. Don't retry-loop them. Alert instead.

```typescript
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](/concepts/idempotency) — failed requests are never cached, so retrying with the same key is safe.
