Skip to content
Claim your free business email

Email API rate limits: the ratelimit headers, the 429, and backoff that never sends twice

Published Updated 15 min readBy the Rasket team

Written for developers and AI automation builders

Email API rate limits and backoff: an hourglass beside a neat stack of envelopes.

An email API rate limit caps the requests your code may make each second, and the response headers show what is left: Rasket allows 10 a second per project and reports the remainder in ratelimit-remaining. Past the limit you get 429 with retry-after; wait that long plus random jitter, then retry with the same idempotency key.

The usual way to hit it: a billing worker loops over 2,000 invoices at nine in the morning, the first 10 sends go out, the rest come back 429, and a loop that sleeps a fixed second wakes every refused worker on the same tick to be refused again. The code below spreads them out, and every output shown is from a real run on 17 October 2026.

How does Rasket's rate limit work?

The limit is a token bucket: it holds 10 tokens and refills at 10 a second. Each request takes one, and a request that finds it empty is refused at once, never queued. The bucket belongs to the project, so every API key and every OAuth-connected app on it shares one bucket. The rate limits reference states the same numbers.

  • Every authenticated request counts, a GET as much as a send. Polling delivery status in a loop starves the sends beside it; webhooks push that status instead.
  • A batch is one token. POST /emails/batch carries up to 100 messages. One at a time, the limit allows 36,000 sends an hour; in batches, up to 1,000 messages a second. That is the API’s ceiling, not a safe sending rate for a new domain, which needs a warm-up schedule.
  • The SMTP relay shares the bucket, one token per relayed message. Over SMTP both the rate limit and the quotas arrive as 451 4.7.1, so read the reply text; the SMTP relay guide has the reply table.

No plan raises it

The limit is the same on every plan, no setting changes it, and it counts reads, sends and relayed SMTP messages alike. A second project has its own bucket, keys and plan, which is the only way to keep one integration from spending another’s requests. If a workload needs more than 10 calls a second that cannot be batched, Rasket does not fit that part of the job today.

What do the rate limit headers say?

Three headers come back on every response; retry-after comes only with a rate-limit refusal. The API refuses a request with no User-Agent, so every sample here sets one.

curl -si "${RASKET_API_URL:-https://api.rasket.com}/domains" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0" | grep -iE '^(HTTP|ratelimit-|retry-after)'
HTTP/1.1 200 OK
ratelimit-limit: 10
ratelimit-remaining: 9
ratelimit-reset: 1
The rate-limit headers, and retry-after
HeaderMeaningValues you will see
ratelimit-limitThe bucket size.10
ratelimit-remainingTokens left after this request took one, so at most 9. A response that took no token, such as a 401, shows 10.0 to 10
ratelimit-resetWhole seconds until the bucket is full.0 when full, otherwise 1
retry-afterWhole seconds to wait.1, never 0

Both of the last two are small because an empty bucket refills in one second and the next token is at most a tenth of a second away, rounded up to whole seconds. The ratelimit-* names come from early drafts of the IETF RateLimit header work, whose current revision (-11, 23 May 2026, not yet an RFC) uses one structured RateLimit field beside RateLimit-Policy instead; Rasket sends only the three early names.

What a 429 looks like: fifteen requests at once

This sends fifteen GETs in parallel and prints what each got back. Run it once; it is a test, not a load generator.

// burst.mjs: fifteen GETs at once: more than the bucket holds. Run it once, not in a loop.
const api = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const headers = {
  authorization: `Bearer ${process.env.RASKET_API_KEY}`,
  "user-agent": "acme-billing/1.0",
};

const responses = await Promise.all(
  Array.from({ length: 15 }, () => fetch(`${api}/domains`, { headers })),
);

for (const [i, response] of responses.entries()) {
  const h = (name) => response.headers.get(name) ?? "-";
  console.log(
    `#${i + 1} ${response.status} remaining=${h("ratelimit-remaining")} reset=${h("ratelimit-reset")} retry-after=${h("retry-after")}`,
  );
}

const refused = responses.find((response) => response.status === 429);
if (refused) console.log(await refused.json());
#1 200 remaining=8 reset=1 retry-after=-
#2 200 remaining=5 reset=1 retry-after=-
#3 200 remaining=7 reset=1 retry-after=-
#4 200 remaining=2 reset=1 retry-after=-
#5 200 remaining=0 reset=1 retry-after=-
#6 200 remaining=6 reset=1 retry-after=-
#7 200 remaining=4 reset=1 retry-after=-
#8 200 remaining=3 reset=1 retry-after=-
#9 429 remaining=0 reset=1 retry-after=1
#10 429 remaining=0 reset=1 retry-after=1
#11 429 remaining=0 reset=1 retry-after=1
#12 429 remaining=0 reset=1 retry-after=1
#13 429 remaining=0 reset=1 retry-after=1
#14 200 remaining=1 reset=1 retry-after=-
#15 429 remaining=0 reset=1 retry-after=1
{
  statusCode: 429,
  name: 'rate_limit_exceeded',
  message: 'Too many requests. You can only make 10 requests per second.'
}

Nine went through, not ten. Their remaining values are 8 down to 0, each once, so the bucket held nine tokens when the burst began: another job on this project had just spent one. The lines are in the order the requests were sent, not the order the server handled them, which is why the values are shuffled and request 14 still got a token.

How to back off without sending twice

This function waits for retry-after, adds jitter so refused workers spread out, retries only what can change, and keeps the same idempotency key on every attempt, so a retry can never become a second email. It needs Node 22 or later.

// send-with-backoff.mjs: Node 22+, no dependencies.
const api = process.env.RASKET_API_URL ?? "https://api.rasket.com";

// Retry these names. Every other refusal, quotas included, is thrown to the caller.
const RETRY = new Set(["rate_limit_exceeded", "concurrent_idempotent_requests"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// maxWaitMs: the longest one wait may block this caller. Past it, the error carries
// retryAfterMs so a queue can run the job again later instead of holding a worker asleep.
export async function sendWithBackoff(message, idempotencyKey, { maxAttempts = 5, maxWaitMs = 30_000 } = {}) {
  const payload = JSON.stringify(message); // outside the try: a bad message is a bug, not a retry
  let delay = 500; // the wait when no retry-after came; doubles after each retry
  for (let attempt = 1; ; attempt++) {
    let response;
    try {
      response = await fetch(`${api}/emails`, {
        method: "POST",
        headers: {
          authorization: `Bearer ${process.env.RASKET_API_KEY}`,
          "content-type": "application/json",
          "user-agent": "acme-billing/1.0",
          "idempotency-key": idempotencyKey, // the same on every attempt
        },
        body: payload,
        signal: AbortSignal.timeout(10_000),
      });
    } catch (error) {
      // No answer (network failure or our timeout): it may or may not have been sent.
      // The key makes the retry safe. Anything else is a bug in this code: rethrow it.
      const lost = error.name === "TimeoutError" || (error.name === "TypeError" && error.cause);
      if (!lost) throw error;
      if (attempt === maxAttempts) throw error;
      await sleep(delay + Math.random() * delay);
      delay = Math.min(delay * 2, maxWaitMs);
      continue;
    }

    const body = await response.json().catch(() => null); // null: not JSON, or cut off
    if (response.ok && body) return { ...body, attempts: attempt };

    // A 200 whose body was lost is retried too: the same key returns the same id.
    const retryable = response.ok || RETRY.has(body?.name) || response.status >= 500;
    if (!retryable || attempt === maxAttempts) {
      const reason = body?.name ? `${body.name}: ${body.message}` : response.statusText;
      throw Object.assign(new Error(`${response.status} ${reason}`), {
        status: response.status,
        apiName: body?.name,
      });
    }

    const retryAfter = Number(response.headers.get("retry-after")); // whole seconds
    const base = retryAfter > 0 ? retryAfter * 1000 : delay;
    if (base > maxWaitMs) {
      throw Object.assign(new Error(`${response.status}: retry in ${base / 1000} s`), {
        status: response.status,
        apiName: body?.name,
        retryAfterMs: base,
      });
    }
    await sleep(Math.min(base + Math.random() * base, maxWaitMs)); // jitter, never past the cap
    delay = Math.min(delay * 2, maxWaitMs);
  }
}

const email = await sendWithBackoff(
  {
    from: "Acme <billing@acme.example>",
    to: ["ronald.williams@example.com"],
    subject: "Invoice 1042",
    html: "<p>Your invoice is attached.</p>",
  },
  "invoice-1042",
);
console.log(email);
  • Retried: rate_limit_exceeded; a 5xx, even one whose body is a proxy’s HTML page; a network failure or timeout; a 200 whose body was cut off; and 409 concurrent_idempotent_requests, an earlier attempt with the same key still running.
  • Thrown at once: a 400, 422 or 401, a quota 429, and any other exception, which is a bug in the calling code rather than a lost answer.
  • Time budget: four refusals in a row cost 4 to 8 seconds of waits before the fifth attempt throws. Each attempt that times out adds up to 10 seconds, so give a workflow step a timeout of at least 90 seconds.
  • Wait cap: maxWaitMs is the longest the caller can block, such as a few seconds in a web request. A rate-limit refusal asks for one second, so only a proxy or an outage trips it. Then requeue the job after retryAfterMs instead of keeping a worker asleep.

It prints { id: '01a148fc-bde5-…', attempts: 1 }; run it again and the same key returns the same id. If you already have a retry library, keep it: give it retry-after, the list of names above, and the same key on every attempt.

Several workers, one bucket

Each worker’s ratelimit-remaining already includes everyone else’s spending. Either funnel sends through one queue with a single consumer that sends batches, so one process owns the budget, or give each of N workers a fixed share: with 4 workers, each waits 500 ms between requests, which uses eight tenths of the bucket and leaves the rest for other jobs. Keep the backoff in every worker for the moments the shares collide. Give each worker its own API key, and GET /logs?status=429 shows which key is being refused.

An AI agent beside production

An assistant connected over MCP spends the same bucket, one token per request, tool calls included. It connects with OAuth, so no API key sits in the model’s context; the MCP setup guide covers that. On top of the project’s bucket, each connected app has budgets of its own:

The budgets on one connected app
BudgetWindowWhat counts
60 tool callsPer minuteEvery call that reaches a tool, including one that returns an error
100 sendsPer rolling hoursend_email calls that were accepted
3 campaignsPer rolling hoursend_broadcast calls that queued or scheduled one
5 importsPer rolling hourimport_contacts calls that started one

The budgets are the same whatever scopes the app was granted; a scope decides only which tools it can call. They are a ceiling for a runaway agent rather than a lock: calls made in parallel can overshoot one slightly. A call a budget refuses is not counted, so retrying does not hold the window open. The two kinds of limit answer differently. The project’s bucket answers before the MCP server sees the call, with HTTP 429. A per-app budget answers HTTP 200 with JSON-RPC error -32000 and retryAfter, in seconds, in its data. Through the MCP SDK the first is a thrown StreamableHTTPError and the second a thrown McpError; one function reads both:

// mcp-limit.mjs: the wait a refused MCP call asks for, with the official SDK.
// npm i @modelcontextprotocol/sdk@1.31.0   (Node 22+)
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { McpError } from "@modelcontextprotocol/sdk/types.js";

// Pass what client.callTool() threw. Returns null when it is not a limit, otherwise
// { seconds, sleep }: sleep is false when the wait is longer than this step may block.
export function mcpLimitWait(error, maxWaitSeconds = 60) {
  let seconds = null;
  if (error instanceof StreamableHTTPError && error.code === 429) {
    seconds = 1; // the project's bucket asks for 1 s; this SDK error carries only the status
  } else if (error instanceof McpError && error.code === -32000 && error.data?.retryAfter) {
    seconds = error.data.retryAfter; // a per-app budget; an hourly one can be a long wait
  }
  return seconds === null ? null : { seconds, sleep: seconds <= maxWaitSeconds };
}

// In the agent loop. Of the tools, only send_email takes an idempotency_key. The
// orchestrator, not the model, makes it from the task and step, so a call the model
// writes again still carries the same one:
// const key = { idempotency_key: `${taskId}-${stepId}` };
// const call = { name, arguments: name === "send_email" ? { ...args, ...key } : args };
// try { return await client.callTool(call); }
// catch (error) {
//   const wait = mcpLimitWait(error);
//   if (!wait) throw error;
//   if (!wait.sleep) return { isError: true, content: [{ type: "text", text: `Limited for ${wait.seconds} s` }] };
//   await new Promise((resolve) => setTimeout(resolve, wait.seconds * 1000));
//   // ...then callTool(call) again, with the same key
// }

An hourly budget can ask for up to an hour, the time until its oldest counted call ages out. When sleep is false, end the step and return the wait as its result, such as “send budget resets in 41 minutes”, so the orchestrator can schedule a resume or ask the person; never hold a run open for it. To keep an agent off production’s bucket entirely, connect it to a separate project.

Pacing a bulk job: batch first, then read the headers

Put up to 100 messages in each request, so 250 invoices are three requests, and sleep for ratelimit-reset only when ratelimit-remaining reaches 0. The same retry rules, in Python with only the standard library.

# send_batches.py: Python 3.10+, standard library only.
import email.utils
import http.client
import json
import os
import random
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone

API = os.environ.get("RASKET_API_URL", "https://api.rasket.com")
RETRY = {"rate_limit_exceeded", "concurrent_idempotent_requests"}


def seconds_to_wait(retry_after, default):
    """Retry-After is whole seconds or an HTTP date (RFC 9110). Rasket sends seconds."""
    try:
        return max(int(retry_after), 1)
    except (TypeError, ValueError):
        pass
    try:
        when = email.utils.parsedate_to_datetime(retry_after)
        return max((when - datetime.now(timezone.utc)).total_seconds(), 1)
    except (TypeError, ValueError):
        return default  # absent or unreadable


def post_batch(messages, idempotency_key, max_attempts=5, max_wait_seconds=30):
    """max_wait_seconds: the longest one sleep may block. Past it, raise and let the queue retry later."""
    delay = 0.5
    for attempt in range(1, max_attempts + 1):
        request = urllib.request.Request(
            f"{API}/emails/batch",
            data=json.dumps(messages).encode(),
            method="POST",
            headers={
                "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
                "Content-Type": "application/json",
                "User-Agent": "acme-billing/1.0",
                "Idempotency-Key": idempotency_key,  # the same on every attempt
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                return json.load(response), response.headers
        except urllib.error.HTTPError as error:
            try:
                body = json.loads(error.read())
            except ValueError:
                body = {}  # not JSON: a proxy's error page, say
            retryable = body.get("name") in RETRY or error.code >= 500
            if not retryable or attempt == max_attempts:
                raise RuntimeError(f"{error.code} {body.get('name')}: {body.get('message')}")
            wait = seconds_to_wait(error.headers.get("retry-after"), delay)
        except (OSError, ValueError, http.client.HTTPException):
            # No answer, or it stopped halfway: refused, reset, timed out, or a 200
            # whose body was cut off. Retry with the same key: it returns the same ids.
            if attempt == max_attempts:
                raise
            wait = delay
        if wait > max_wait_seconds:
            raise RuntimeError(f"retry in {wait:.0f} s")  # longer than this worker should sleep
        time.sleep(min(wait + random.uniform(0, wait), max_wait_seconds))  # jitter, capped
        delay = min(delay * 2, max_wait_seconds)


def send_invoices(job_id, invoices):
    invoices = sorted(invoices, key=lambda invoice: invoice["id"])  # same cut on every run
    for n, start in enumerate(range(0, len(invoices), 100)):  # a batch holds at most 100
        chunk = invoices[start : start + 100]
        messages = [
            {
                "from": "Acme <billing@acme.example>",
                "to": [invoice["email"]],
                "subject": f"Invoice {invoice['id']}",
                "html": "<p>Your invoice is attached.</p>",
            }
            for invoice in chunk
        ]
        result, headers = post_batch(messages, f"{job_id}-batch-{n}")
        for invoice, sent in zip(chunk, result["data"]):
            print(invoice["id"], sent["id"])  # in real code: record it as sent
        if headers.get("ratelimit-remaining") == "0":  # pace before the next request
            time.sleep(int(headers.get("ratelimit-reset") or 1))


send_invoices(
    "invoices-2026-10",
    [{"id": 1042 + n, "email": "ronald.williams@example.com"} for n in range(3)],
)

A batch key covers one exact batch

The key is the job id plus the batch number, and the input is sorted first, so a rerun cuts the same batches and replays each one instead of sending it again. If the input changed, a batch whose contents moved arrives with its old key and a new body, and the API refuses it with 409 invalid_idempotent_request rather than send duplicates. Record each invoice as sent, and rerun only the rest under a new job id. A key is remembered for 24 hours, so a rerun after that sends again.

A batch is all or nothing. One invalid message rejects the whole request with a validation_error that names it, such as emails[3], and nothing is stored, so the script raises instead of retrying: fix that message and run the job again. When a batch is accepted, its ids come back in input order, which is why zip can pair them. seconds_to_wait also reads the HTTP-date form RFC 9110 allows for Retry-After. A read timeout raises TimeoutError, a subclass of OSError, so one clause catches it with the dropped connections.

Rate limit or quota? Read the error name

Three refusals share the status 429, and only one clears by waiting a second. Branch on name, never on the status.

The three 429s
Error nameCountsretry-afterWhat to do
rate_limit_exceededRequests: 10 a secondYesWait, retry with the same key
daily_quota_exceededMessages todayNoHold the send until the count resets
monthly_quota_exceededMessages this billing monthNoStop and alert a person

The daily refusal is the one a fresh account meets first. A new account’s cap applies on top of the plan’s, and the lower of the two wins (the abuse-limits page has the rules):

Daily limits on a new account
Account ageFree planPaid plans
First 24 hours100 emails a day100 emails an hour, 500 a day
Rest of the first week100 emails a day1,000 a day, doubling with each day sent without bounce or spam problems
After that100 emails a dayNo daily limit

Free stays at its plan’s cap throughout because that cap is already the lower line. Rather than work the table out in code, read daily_limit from GET /billing below: it is the number sending is held to right now, with daily_limit_reason saying when a temporary limit set it. The day’s count resets at 00:00 UTC (day_start in the same response). While a new-account limit applies it is also held over a rolling 24 hours, so midnight alone does not reopen it.

The monthly refusal comes when the plan’s volume is used up and pay-as-you-go is off, or a spend cap you set has paused sending. Every email spends the same allowance, campaigns included, so check before a big send: GET /billing returns this month’s and today’s counts and both limits.

curl -s "${RASKET_API_URL:-https://api.rasket.com}/billing" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0" |
  python3 -c 'import json, sys; u = json.load(sys.stdin)["usage"]; print(json.dumps({k: u[k] for k in ("emails", "included_emails", "emails_today", "daily_limit", "daily_limit_reason", "monthly_limit")}, indent=2))'
{
  "emails": 123,
  "included_emails": 100000,
  "emails_today": 123,
  "daily_limit": null,
  "daily_limit_reason": null,
  "monthly_limit": 100000
}

One more case looks like a limit and is not: the API said 200, and the mail is still slow. That is mailbox providers deferring a new or bursty sender after our mail servers hand it over, and it shows up as email.delivery_delayed events. No API backoff changes it; growing volume gradually does.

Frequently asked questions

Does the batch endpoint take an idempotency key per message?

No. One Idempotency-Key header covers the whole batch request, so a retry replays every message in it together or sends none of them. If you need to deduplicate single messages inside a batch, keep your own record of what was sent, or send those messages one at a time with a key each.

Does a refused or invalid request use up the limit?

A request the API rejects, such as a 422 for a broken body or a 404 for an id that does not exist, still takes a token, because the bucket is checked as soon as the key is recognised and before the body is read. A request refused with 429 takes nothing, and neither does one refused as 401, so a client waiting out retry-after does not dig itself deeper.

Do webhook deliveries count against my rate limit?

No. The bucket counts requests your code makes to the API. Webhook deliveries are requests Rasket makes to your endpoint, so they spend nothing, which is why moving from polling to webhooks frees tokens for sending. Inbound mail delivered to your webhook is the same: it costs no tokens.

Does a scheduled email use a token when it goes out?

Only the request that schedules it takes a token. When the scheduled time comes, Rasket sends the message itself, with no request from you, so a thousand emails scheduled for nine in the morning do not compete with your code for the bucket at nine. They still count toward your sending allowance.

Do my teammates' clicks in the dashboard eat into the API limit?

No. Signed-in dashboard use is counted per person, in a separate budget, so a teammate browsing logs does not slow your workers. Every API key and OAuth-connected app on the project, including an assistant connected over MCP, does share the one project bucket.

Sources

  1. RateLimit header fields for HTTP (draft-ietf-httpapi-ratelimit-headers-11) — IETF HTTPAPI Working Group, read 2026-10-17
  2. RateLimit header fields for HTTP, draft -06 (separate Limit, Remaining and Reset fields) — IETF HTTPAPI Working Group, read 2026-10-17
  3. RateLimit header fields for HTTP, draft -07 (RateLimit and RateLimit-Policy) — IETF HTTPAPI Working Group, read 2026-10-17
  4. RFC 6585: Additional HTTP Status Codes, section 4 (429 Too Many Requests) — IETF, read 2026-10-17
  5. RFC 9110: HTTP Semantics, section 10.2.3 (Retry-After) — IETF, read 2026-10-17
  6. socket.timeout: an alias of TimeoutError since Python 3.10 — Python Software Foundation, read 2026-10-17
  7. Rate limits: ten a second, and the headers that report it — Rasket, read 2026-10-17
  8. MCP server: scopes, budgets and the -32000 refusal — Rasket, read 2026-10-17
  9. Errors: every error name and its status — Rasket, read 2026-10-17
  10. 429 Too Many Requests — MDN Web Docs, read 2026-10-17
  • Rate limits — Ten a second per team, and the headers that tell you where you are.
  • Errors — The whole vocabulary, with the status each name carries.
  • Idempotent email sends: retry without sending twice — How to make email sends idempotent and stop duplicate emails: choosing an Idempotency-Key, a tested retry loop, keys for agents, and what each repeat returns.
  • Email webhooks explained: events and signatures — Email webhooks explained: the delivery events, the payload, HMAC signature verification, retries, and complete tested receivers in Node and Python.
  • SMTP relay: what it is and how to set one up — What an SMTP relay is, when you need one, and how to set one up: ports 465 and 587, the login, limits, reply codes, and settings for WordPress and Laravel.
  • MCP server — Connect Claude, ChatGPT or any MCP client: your scopes, no key.
  • Let an AI agent send email with MCP — Connect an agent to an MCP email server: the OAuth sign-in for your own loop, the send_email call, prompt injection, and a person approving every send.
  • How to warm up a new domain or IP address — How to warm up a new domain or IP: what Gmail, Microsoft, Yahoo and M3AAWG publish, schedules from 300 contacts to 50,000 a day, and when to slow down.
  • Rate limit — A rate limit is the ceiling on how fast an API will accept requests.It protects the service from one noisy caller and protects you from a runaway loop.

Start sending this morning

Sign up, verify a domain and send your first email in minutes.