Skip to content

Email API rate limits explained: the headers, the 429, and backoff that keeps the key

Published Updated 10 min readBy the Rasket team

An hourglass standing beside a neat stack of envelopes, sand mid-fall, drawn as white and violet outlines on black.

What an email API rate limit is

An email API rate limit is the number of requests the API will accept from you in a window of time before it starts refusing them. Email API rate limits count calls, not messages: a request that sends one email and a request that sends a hundred in a batch each cost one. The refusal is a 429, it is temporary by definition, and the response tells you how long to wait.

The purpose is fairness inside the API rather than policing what you send. A loop that goes wrong in one team’s worker should not slow down another team’s password resets, and the cleanest way to guarantee that is to give every team the same budget of requests per second and answer over budget quickly and cheaply. Everything below is about reading that budget and staying inside it without guessing.

Note what the API does not do: it does not queue an over-budget request and answer it late. A refusal is immediate, which keeps the API’s latency honest for everybody else, and it puts the waiting on your side, where you can decide whether the send is worth waiting for at all. That is the trade a rate limit makes, and the headers exist so the wait is a number you read rather than one you guess.

How Rasket's limit works

The limit is 10 requests a second per team, enforced with a token bucket that holds 10 tokens. Every request takes one token; the bucket refills at 10 a second up to its capacity; a request that arrives when the bucket is empty is refused. The bucket belongs to the team, so it is shared by every API key the team has: three services with three keys share 10 a second between them, not 30.

  • Bursts are fine. A full bucket lets 10 requests through in the same instant. What it will not let through is an 11th before a token has come back, so a steady 10 a second is the sustained rate and a burst of 10 is the most it absorbs.
  • Every endpoint counts. A GET on an email and a POST that sends one take the same token. Reading a delivery status in a tight loop can starve the sends next to it.
  • A batch is one token. POST /emails/batch takes up to 100 messages in one request, and one Idempotency-Key covers the whole batch. A job that needs 1,000 messages out is ten requests, not a thousand.

Ten a second is a lot of transactional mail — 36,000 sends an hour without the batch endpoint — and the limit exists to catch loops, not to shape traffic. If your product needs more, the rate limits guide says what to do; the answer usually starts with batches.

The headers on every response

Three headers ride on every response, refused or not, and a fourth arrives only with a refusal. The names follow the IETF RateLimit header fields draft and are lower-case.

The four rate-limit headers
HeaderValueWhen
ratelimit-limitThe bucket size: 10.Every response
ratelimit-remainingTokens left in the bucket at the time of this response.Every response
ratelimit-resetSeconds until the bucket is full again.Every response
retry-afterWhole seconds to wait before trying again. Never zero.Only on a 429
HTTP/1.1 200 OKContent-Type: application/jsonratelimit-limit: 10ratelimit-remaining: 7ratelimit-reset: 1
{ "id": "3f0c1d2e-9a4b-4c8d-b7e6-5f1a2b3c4d5e" }

A refusal has the same three headers with ratelimit-remaining at zero, plus retry-after, and a body in the shape every Rasket error uses.

HTTP/1.1 429 Too Many RequestsContent-Type: application/jsonratelimit-limit: 10ratelimit-remaining: 0ratelimit-reset: 1retry-after: 1
{ "statusCode": 429, "name": "rate_limit_exceeded", "message": "Too many requests." }

Two details matter for the code that reads these. retry-after is whole seconds, never fractional and never zero, so a client that sleeps for exactly that long is always on the safe side of the refill. And ratelimit-reset is when the bucket is full, not when the next token arrives, so a job that waits for it after every send is waiting far longer than it needs to. The pacing sample below reads remaining and only sleeps when it hits zero.

Reading them without a client

Nothing here needs the official client. With fetch the headers are on the response object, and Number() on a missing header gives NaN rather than a throw, so check the status before you trust retry-after.

const response = await fetch("https://api.rasket.com/emails", {  method: "POST",  headers: {    authorization: `Bearer ${process.env.RASKET_API_KEY}`,    "content-type": "application/json",    "user-agent": "acme-billing/1.0",    "idempotency-key": "invoice-1042",  },  body: JSON.stringify(message),});
const remaining = Number(response.headers.get("ratelimit-remaining"));if (response.status === 429) {  const retryAfter = Number(response.headers.get("retry-after")); // whole seconds, never 0  await sleep(retryAfter * 1000);  // ...and send again with the same idempotency-key}

Backoff done right

The wrong loop is the one everybody writes first: catch the error, sleep a fixed second, try again, forever. It fails in three ways. It ignores the wait the API asked for, it retries errors that will never change, and every worker that was refused in the same second comes back in the same second, which is how a small burst turns into a standing one. The right loop fixes each of those.

import { Rasket, RasketApiError, RasketConnectionError } from "rasket";
const rasket = new Rasket({  apiKey: process.env.RASKET_API_KEY,  userAgent: "acme-billing/1.0",});
type SendBody = Parameters<typeof rasket.emails.send>[0];
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export async function sendWithBackoff(body: SendBody, idempotencyKey: string) {  let delay = 500;  for (let attempt = 1; ; attempt++) {    try {      // The key is fixed across attempts: a repeat is a replay, never a second email.      return await rasket.emails.send(body, { idempotencyKey });    } catch (error) {      const retryable =        error instanceof RasketConnectionError ||        (error instanceof RasketApiError && (error.statusCode === 429 || error.statusCode >= 500));      if (!retryable || attempt === 5) throw error;
      // A 429 says exactly how long to wait. Anything else doubles from half a second.      const base =        error instanceof RasketApiError && error.rateLimit?.retryAfterSeconds          ? error.rateLimit.retryAfterSeconds * 1000          : delay;      await sleep(base + Math.random() * base * 0.25); // jitter: up to a quarter more      delay *= 2;    }  }}
await sendWithBackoff(  {    from: "Acme <orders@send.acme.example>",    to: ["ronald.williams@example.com"],    subject: "Invoice 1042",    html: "<p>Your invoice is attached.</p>",  },  "invoice-1042",);
  • Read retry-after. On a 429 the client exposes it as rateLimit.retryAfterSeconds. Use it as the base wait; the API knows when the token is coming back and you do not.
  • Add jitter. A random fraction on top of the base wait, here up to a quarter, spreads a fleet of workers out so they do not all knock again at once.
  • Retry only what can change. A 429, a 5xx and a lost connection. A 422 is a broken payload, a 401 is a broken key, and neither improves with patience.
  • Keep the same Idempotency-Key. This is the line that makes the loop safe. A refused send did not send anything, so a repeat with the same key either goes through the first time or replays a response the earlier attempt got. Within the 24-hour window there is no way to send twice.

The last point is worth stating plainly because it is the fear that makes people retry too little. A 429 on a keyed send is always safe to retry. The message never reached the queue; the request was turned away at the door with the headers above and nothing else happened. The idempotent sends guide covers the harder case, a timeout where you do not know whether it reached the queue, and the answer there is the same key too.

Pacing a bulk job

A worker that sends thousands of invoices should not wait to be refused. Read remaining from the response it already has and sleep only when the bucket is empty; or better, put a hundred invoices in one batch and spend one token on them.

// Pace a bulk job off the headers instead of a sleep you tuned once.for (const invoice of invoices) {  const { body: email, rateLimit } = await rasket.emails.send(messageFor(invoice), {    idempotencyKey: `invoice-${invoice.id}`,  });  record(invoice.id, email.id);
  // rateLimit = { limit: 10, remaining: 3, resetSeconds: 1 }  if (rateLimit?.remaining === 0) {    await sleep(rateLimit.resetSeconds * 1000);  }}

Rate limits versus sending quotas versus daily caps

Three different limits answer 429, and confusing them costs real time, because only one of them is fixed by waiting a second. A rate limit counts requests in a window of seconds. A daily cap counts messages in a day. A monthly quota counts messages against your plan. Each has its own error name, and the name is how your code tells them apart.

The three limits that answer 429
Rate limitDaily capMonthly quota
CountsRequestsMessagesMessages
WindowOne secondOne dayThe billing month
Error namerate_limit_exceededdaily_quota_exceededmonthly_quota_exceeded, or marketing_quota_exceeded on the broadcast meter
Waiting a second helpsYesNoNo
FixWait and retry with the same keyQueue until the day rolls overChange plan, or wait for the month
Best forCatching a runaway loop within a secondBounding the damage of a bug before it costs a day of reputationMatching what a team sends to what it pays for

The practical rule: branch on name, not on the status code. rate_limit_exceeded goes into the backoff loop above. The three quota names do not; a loop that retries a monthly quota every second with jitter is a loop that runs until the first of the month. Put those sends back on a queue, alert somebody, and let a human decide whether the answer is a bigger plan or a bug. The errors guide lists every name with its status, and the glossary entry has the short form of this section.

Broadcasts are metered separately

A broadcast sends one ordinary email per recipient, on the marketing meter, so a large send to a segment does not consume the transactional monthly quota and a receipt does not consume the marketing one. Both go through the same per-second rate limit, because that is about requests, and a broadcast is a handful of them.

What the clients expose

Neither official client hides the headers. Every successful call returns them parsed, and every refusal carries them on the error, so the loop above never has to read a raw response.

Node

A call such as rasket.emails.send(body, options) resolves to an object with body, rateLimit, requestId and idempotentReplayed, and rateLimit has limit, remaining, resetSeconds and an optional retryAfterSeconds. The optional field is present only on a refusal, which you meet as a RasketApiError carrying statusCode, name, message, requestId and the same rateLimit. A RasketConnectionError means nothing answered at all and has no headers to carry.

Python

The same shape in snake case. result.rate_limit has limit, remaining, reset_seconds and, on a refusal, retry_after_seconds; a refusal raises RasketApiError with .name, .status_code, .request_id and .rate_limit, so the wait is error.rate_limit.retry_after_seconds.

import osimport time
from rasket import Rasket, RasketApiError
rasket = Rasket(    api_key=os.environ["RASKET_API_KEY"],    user_agent="acme-billing/1.0",)
for invoice in invoices:    while True:        try:            result = rasket.emails.send(                message_for(invoice),                idempotency_key=f"invoice-{invoice.id}",            )        except RasketApiError as error:            if error.name == "rate_limit_exceeded" and error.rate_limit:                time.sleep(error.rate_limit.retry_after_seconds or 1)                continue  # the same invoice, with the SAME idempotency_key            raise        break
    record(invoice.id, result.body["id"])
    # result.rate_limit: limit, remaining, reset_seconds (retry_after_seconds on a 429)    if result.rate_limit and result.rate_limit.remaining == 0:        time.sleep(result.rate_limit.reset_seconds)

Whichever client, the shape of a correct sender is the same three lines: derive the key from the work, read the limit from the response, and let the batch endpoint carry the bulk. The idempotency guide has the rules for the key, and the rate limits guide has the headers with their exact semantics.

Frequently asked questions

What is Rasket's API rate limit?

Ten requests a second per team, from a token bucket that holds ten. A full bucket absorbs a burst of ten in the same instant and then refills at ten a second. The bucket is shared by every API key on the team, so three services with three keys share the same ten, and every endpoint draws from it.

Does a batch of 100 emails count as 100 requests?

No. POST /emails/batch accepts up to 100 messages in one request, and that request takes one token like any other. One Idempotency-Key covers the whole batch. A job that needs a thousand messages out is ten requests, which is why batching is the first answer to a rate limit rather than a longer sleep.

Is it safe to retry a 429 on a send?

Yes, and with the same Idempotency-Key. A refused send never reached the queue; the request was turned away with the rate-limit headers and nothing else happened. Wait the whole seconds in retry-after, add a little jitter, and send again. Inside the 24-hour window the key guarantees the repeat cannot produce a second email.

What is the difference between ratelimit-reset and retry-after?

ratelimit-reset is on every response and says how many seconds until the bucket is full again, which is longer than you need to wait for the next token. retry-after appears only on a 429 and is the wait the API actually asks for, in whole seconds, never zero. Pace off ratelimit-remaining, and sleep on retry-after only when you are refused.

Why did I get a 429 that waiting does not fix?

Because it was a quota, not the rate limit. Read the name in the body: rate_limit_exceeded clears within a second, while daily_quota_exceeded, monthly_quota_exceeded and marketing_quota_exceeded count messages over a day, a month or the broadcast meter and clear when the period rolls over or the plan changes. Branch on the name, not on the status code.

How do the Node and Python clients expose the limit?

Every successful call returns the parsed headers: rateLimit with limit, remaining and resetSeconds in Node, rate_limit with limit, remaining and reset_seconds in Python. A refusal raises RasketApiError with the same object attached, and on a 429 it adds retryAfterSeconds or retry_after_seconds, so the wait is one property read rather than a header parse.

Sources

  1. RateLimit header fields for HTTPIETF HTTPAPI Working Group, read 2026-09-16
  2. RFC 6585: Additional HTTP Status CodesIETF, read 2026-09-16
  3. RFC 9110: HTTP SemanticsIETF, read 2026-09-16
  • Rate limitsTen a second per team, and the headers that tell you where you are.
  • ErrorsThe whole vocabulary, with the status each name carries.
  • IdempotencyRetry a send without sending it twice.
  • Rate limitA 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.
  • Idempotent email sends: never send twiceA timeout does not tell you whether the send happened. What an idempotency key is, how the 24-hour window behaves, how to choose keys, and what a batch does.

Start sending this morning

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