Rate Limits & Quotas
Understand API rate limits and plan your integration
Two Rate Limits
Requests are counted twice, against two independent limits. Both return
429 RATE_LIMITED. A third case shares the same code: too many requests on
the same account at once, see
Concurrent requests on one account.
| Limit | Counted per | Applies to |
|---|---|---|
| 100 requests / second | API key | every authenticated endpoint |
| 500 requests / minute | client IP | every endpoint, authenticated or not |
| 10 batch requests / second | API key | POST /v1/emails/batch, on top of the 100/second; up to 100 emails per call |
The per-key limit is the one an integration meets first: a loop that sends
without pacing hits 100/s long before it hits 500/min. Its error message names
the limit — Rate limit exceeded: 100 requests/second per API key.
The per-minute limit is per client IP, not per tenant. Two workspaces calling from the same egress IP share it; one workspace calling from several IPs gets one allowance per IP. It is counted before authentication, so requests rejected with a 401 — a wrong, revoked or missing API key — count against it as well.
POST /v1/templates/render-preview has its own per-IP limit of 60 requests /
minute, and that one is on top of the 500/minute, not instead of it: a
preview request spends one unit of each, and whichever budget runs out first
returns the 429. The per-key limit still applies to it.
A batch send counts as one request on the 100/second
limit, whatever the number of emails in it, and also against its own limit of
10 batch calls per second per key. Over it the 429 carries Retry-After: 1
and the message Rate limit exceeded: 10 batch requests/second per API key.
On the monthly email quota every email of the batch counts, one email per distinct recipient.
Rate Limit Headers
Responses carry the state of the per-IP limit:
x-ratelimit-limit: 500
x-ratelimit-remaining: 245
x-ratelimit-reset: 47x-ratelimit-reset is the number of seconds remaining in the current window,
not a Unix timestamp. When the per-IP limit is exceeded, the 429 also carries a
retry-after header, in seconds.
These headers describe the per-IP limit only: 500/minute, or 60/minute on
render-preview. The per-key limit of 100/second is not reflected in them: you
can be at x-ratelimit-remaining: 400 and still get a 429 for sending 100
requests in the same second. A per-key 429 does carry these headers — the
per-IP counter runs before authentication — but they describe the per-IP limit,
not the one you hit, and it never carries retry-after.
Check Rate Limit Status
Before hitting the limit, check the headers:
curl -i https://api.tratto.email/v1/emails \
-H "Authorization: Bearer tratto_live_..."Returns headers with remaining requests and reset time.
Rate Limit Exceeded (429)
Both limits answer with the same code. Over the per-IP limit:
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded, retry in 47s.",
"docs": "https://docs.tratto.email/en/docs/error-codes"
}
}Over the per-key limit, message is
Rate limit exceeded: 100 requests/second per API key.
The number in the message is the seconds left in the current window, so it
varies. The body carries no retryAfter field: after a per-IP 429, wait the
retry-after seconds. A per-key 429 has no retry-after to read, but its
window is one second, so a short backoff is enough.
Retry Strategy
Use exponential backoff with jitter, honour retry-after when it is set,
and do not retry QUOTA_EXCEEDED, which shares the 429 status:
async function sendWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, options);
if (res.status !== 429 || attempt === maxRetries) return res;
const { error } = await res.clone().json();
if (error?.code === 'QUOTA_EXCEEDED') return res; // retrying will not help
const retryAfter = Number(res.headers.get('retry-after'));
const delay = retryAfter
? retryAfter * 1000
: 2 ** attempt * 1000 + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
}
}Plan Limits
Each plan has two caps:
| Plan | Emails per month | Domains |
|---|---|---|
| Free | 3,000 | 3 |
| Starter | 75,000 | 10 |
| Growth | 300,000 | 100 |
| Scale | 1,500,000 | 250 |
The monthly quota is in emails, and each distinct recipient counts as one email (requires API 0.21.0 or
later): every distinct address in to, cc and bcc of an email counts as one, ignoring case, and an
address repeated in the same email counts once. An email to one person counts as one email, an email to
three distinct people counts as three. A campaign or a flow sends one email per recipient, so for them the
count is the number of emails sent. An email can have at most 50 distinct
recipients: see Recipients.
AI template generation in the dashboard has an allowance of its own, 50 generations per month on every plan, reset at 00:00 UTC on the 1st.
Test sends of templates and campaigns are free and have a daily cap of their own, counted per workspace and reset at midnight UTC (requires API 0.24.0 or later): 20 a day on Free, 100 a day on Starter and Growth, 200 a day on Scale. See Test sends.
Webhooks are included from Starter onward (Starter, Growth and Scale), not on Free: see Plan availability.
Attachments are included from Starter onward too (requires API 0.29.0 or later). Each workspace can hold 1 GB of attachments at once, and uploads are limited to one per second per API key: see Attachments.
A dedicated IP is included on Scale only, one per workspace, enabled on request once the workspace sends at least 3,000 emails a day (each recipient counts as one, like the monthly quota). It is not available on other plans: see If you have a dedicated IP.
The caps belong to the account, not the workspace
The plan belongs to the account, the person who owns the workspace, and
its caps are shared by every workspace that account owns, active or suspended.
Everything counts, whoever does it: a send, an AI generation or a new domain
in any of the owner's workspaces, by the owner or by any member, uses the same
allowance. A QUOTA_EXCEEDED can therefore come from usage you did not make
yourself, in another workspace of the same account or by another member.
There is no per-day cap on a plan. The only daily limit in the product is the test-mode one, 100 test emails per day (each recipient counts as one), see Test Mode.
When an email has more distinct recipients than the account has emails left, the request
fails with QUOTA_EXCEEDED, which also returns 429, and nothing is sent: the
check is all or nothing. Unlike RATE_LIMITED it will not clear by retrying: see
Error Codes.
A scheduled email counts in the UTC month it is scheduled for, not the month you created it. A month's counter, the one compared with the limit and shown in the dashboard as Emails used this month, includes the live emails scheduled for that month, one per distinct recipient, whether they have already gone out or are still waiting. Canceling a scheduled email gives its quota back, and rescheduling it into another month moves the quota: see Quota of scheduled emails.
The domain limit counts every domain in every workspace of the account,
whatever its status: pending (still being verified), verified and failed
all take a place. It is checked only when you add one (POST /v1/domains).
Deleting a domain (DELETE /v1/domains/{id}) frees its place, so a domain that
failed verification and is no longer needed is the first one to delete. Over
the cap the API answers 429 QUOTA_EXCEEDED:
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Domain limit reached for the free plan: 3 domains across all the workspaces of this account, counting those still being verified and those that failed.",
"docs": "https://docs.tratto.email/en/docs/error-codes",
"suggestion": "Delete a domain that failed or is no longer used, or ask the account owner to upgrade the plan at https://app.tratto.email/settings/billing."
}
}Read your own limits from the API
Do not hard-code the numbers above. GET /v1/workspace returns the limits
that are actually enforced, and null means no limit. limits.emailsPerMonth
(emails per UTC month, every distinct address in to, cc and bcc
counting as one email) and limits.domains are the caps of the account that owns the workspace,
shared with its other workspaces, not an allowance of this workspace alone.
limits.membersPerAccount is the number of team members of the account,
shared by every workspace it owns, a person in several workspaces counting
once, pending invites included (requires API 0.28.0 or later;
limits.membersPerWorkspace is still returned with the same number, kept for
compatibility; see Team).
limits.webhooks says whether the workspace's plan includes webhooks
(requires API 0.23.0 or later, see Webhooks).
limits.testSendsPerDay is the daily cap of test sends of this workspace,
template and campaign test sends together, reset at midnight UTC (requires API
0.24.0 or later):
curl https://api.tratto.email/v1/workspace \
-H "Authorization: Bearer tratto_live_..."{
"data": {
"plan": "free",
"limits": { "domains": 3, "emailsPerMonth": 3000, "membersPerAccount": 1, "membersPerWorkspace": 1, "workspaces": 1, "webhooks": false, "testSendsPerDay": 20 }
}
}This is the same table the enforcement reads, so it cannot disagree with what you are actually allowed to send.
The month is a UTC calendar month
The quota is not a rolling 30-day window. The counter is keyed on the UTC
calendar month (2026-09), and it rolls over on the first send of the new
month. There is no scheduled reset job: an account that sends nothing in
October still starts November at zero, on its first November send.
There is no "Pro" plan, and no plan without a monthly email cap. The plans
are the ones in the table above, and GET /v1/workspace always reports one of
them.
Concurrent requests on one account
Because the caps are counted per account, requests that land on the same
account at the same moment are counted one after the other. When too many
arrive together, from any of the account's workspaces or keys, the API answers
429 RATE_LIMITED with a Retry-After: 1 header:
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many concurrent requests on this account. Retry after 1 second.",
"docs": "https://docs.tratto.email/en/docs/error-codes"
}
}Wait the Retry-After seconds and send the same request again. The retry function above already does this. Spreading
requests out, rather than firing them in parallel, makes this 429 rarer.
One account sustains about five requests per second that consume its monthly quota (sends, campaign batches, automation steps, AI generations), across all its workspaces and keys. Above that rate the responses slow down first, then this 429 becomes frequent.
The Node SDK @tratto/email does not retry a 429 on its own, and its
TrattoError does not carry the response headers, so wait one second before
trying again:
import { TrattoError } from '@tratto/email';
async function sendWithRetry(tratto, email, maxRetries = 3) {
for (let attempt = 0; ; attempt++) {
try {
return await tratto.emails.send(email);
} catch (error) {
const retryable = error instanceof TrattoError
&& error.code === 'RATE_LIMITED' && attempt < maxRetries;
if (!retryable) throw error; // QUOTA_EXCEEDED included
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
}
}Burst Behavior
Neither limit is averaged, and neither allows a burst above it. The per-key limit counts requests in each clock second. The per-IP limit opens a one-minute window on your first request and resets when that window ends. Spread requests out instead of sending them in spikes.
Next: Error Codes
Edit this page on GitHub
Last updated on