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.

LimitCounted perApplies to
100 requests / secondAPI keyevery authenticated endpoint
500 requests / minuteclient IPevery endpoint, authenticated or not
10 batch requests / secondAPI keyPOST /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: 47

x-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:

PlanEmails per monthDomains
Free3,0003
Starter75,00010
Growth300,000100
Scale1,500,000250

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