Engineering
Resend has three 429s. Only one of them should be retried.
Resend answers 429 for a rate limit, a spent daily quota and a spent monthly quota. A retry loop that looks at the status treats all three the same, and two of them do not clear for hours or weeks. How to tell them apart, and how we split the same three cases.

Resend's API answers HTTP 429 for three different things. One clears in about a second. One clears at midnight UTC. One clears when the monthly quota resets or someone changes the plan. A retry loop that looks only at the status code treats all three as the first, which is how an integration ends up sending the same refused request every few seconds for the rest of the day.
Resend documents all three clearly on its errors page. The problem is in the code most of us write around an email API, and in the agents that write it for us: 429 means "back off and retry" to nearly every HTTP client library ever published, so that is what the code does.
The three 429s
As Resend documents them, checked on 28 September 2026:
| Name | What it means | When it clears |
|---|---|---|
rate_limit_exceeded | More than the team's request rate, 10 requests per second by default, shared by every API key on the team | Within seconds. The retry-after header says how many |
daily_quota_exceeded | The Free plan's 100 emails for the day are used. Received mail counts toward it, and each To, Cc and Bcc recipient counts as one | At 00:00 UTC. Paid plans have no daily quota |
monthly_quota_exceeded | The plan's monthly emails are used: 3,000 on Free | When the monthly quota resets or the plan changes |
Only the first is a request to slow down. The other two are an account that has run out, and waiting a few seconds between retries changes nothing about either.
Branch on the name, not the status
The fix is small: read error.name, which the Resend Node SDK returns in { data, error } instead of throwing, and decide from that.
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const sleep = (s: number) => new Promise((r) => setTimeout(r, s * 1000));
type Outcome = { sent: true; id: string } | { sent: false; retryable: boolean; reason: string };
export async function send(
email: Parameters<typeof resend.emails.send>[0],
idempotencyKey: string,
): Promise<Outcome> {
for (let attempt = 0; attempt < 5; attempt++) {
const { data, error, headers } = await resend.emails.send(email, { idempotencyKey });
if (!error) return { sent: true, id: data!.id };
switch (error.name) {
case "rate_limit_exceeded":
// The one 429 that clears on its own. Same request, same key.
await sleep(Number(headers?.["retry-after"] ?? 2 ** attempt));
continue;
case "daily_quota_exceeded":
case "monthly_quota_exceeded":
// An account that has run out. Queue it and tell a person.
return { sent: false, retryable: false, reason: error.message };
default:
return { sent: false, retryable: false, reason: `${error.name}: ${error.message}` };
}
}
return { sent: false, retryable: true, reason: "still rate limited after five attempts" };
}Two details in there matter as much as the switch. The retry sends the same idempotency key, so a request that did get through before the 429 is not sent twice. And the SDK does not retry anything itself: a caller that only checks data drops the email without noticing.
If an agent is holding the tool, give it the same decision in words. A model that sees "429" and nothing else will do what its training says 429 means.
Why the quota is spent sooner than you expect
Two facts about the daily quota catch people out. Received emails count toward it, so on the Free plan a support address that takes a burst of mail leaves less room to send that day. And every recipient counts, so one email to three people in To and Cc uses three.
Resend puts the running count in a header on every Free-plan response, x-resend-daily-quota, with x-resend-monthly-quota beside it. Alerting at 80 is better than learning about it from the 101st send.
How we split the same three cases
We had the same three situations to name when we designed SendRaven's errors, and we made the opposite call on two of them.
A spent plan is 402 plan_limit_reached, not a 429. Nothing about it clears in seconds, so it should not carry the status every retry library reads as "wait and try again". We wrote up the reasoning in 402, not 429. There is no daily quota to hit either: Free is 3,000 emails a month, received mail is not counted on any plan, and paid sending does not stop at a volume unless you set a spend budget.
We do still send two 429s, and they carry opposite instructions. rate_limited is the per-key request limit, 600 a minute: retry after a pause. daily_limit is a cap someone put on an API key on purpose, usually to stop an agent: stop, do not retry. Both now come with a Retry-After header. For the first it is the seconds until a request will be accepted; for the second it is the seconds until 00:00 UTC, so a generic retry loop that honours the header gives up instead of pressing on a limit someone set on purpose. The errors reference has a Retry? column on every row for the same reason.
And the rate limit is per key, not per team. A backfill script on its own key runs into its own limit and leaves the key your password resets use alone.
If you are fixing one of these right now
- Resend 429
rate_limit_exceeded: the retry, batching, and pacing across services - Resend
daily_quota_exceeded: the 100-a-day limit and what counts toward it - Resend 403 "You can only send testing emails to your own email address": the
resend.devtesting domain
The rule
Branch on the error's name, never on its status alone. The status tells a library what class of problem it is. The name tells your code, or your agent, what to do next.



