# Resend Idempotency Keys, Retries, and Error Classification: Never Lose a Lead to One Failed Send

> Resend's Node SDK never throws — it returns { data, error }. Sort failures into retryable / sender_rejected / fatal, then wire up Idempotency-Key (1–256 chars, 24 hours), exponential backoff, timeouts, and deterministic tests, in production TypeScript.

- Published: 2026-08-06
- Author: 友田 陽大
- Tags: Resend, メール配信, 冪等性, 信頼性, TypeScript, テスト, 可観測性
- URL: https://tomodahinata.com/en/blog/resend-idempotency-retry-error-handling-reliability-guide
- Category: Resend & transactional email
- Pillar guide: https://tomodahinata.com/en/blog/resend-transactional-email-production-guide

## Key points

- Resend's Node SDK does not throw on API failures or network failures — it returns `{ data, error, headers }`. An implementation built only on try/catch lets failures pass straight through, so branch on `error` and reserve try/catch for the two real throw paths: the constructor and React rendering
- Sort failures into retryable / sender_rejected / fatal. Whether you keep that middle case — 'resending the same payload is useless, but swapping the sender still delivers' — as its own branch is what decides if a lead survives
- The idempotency key is the `Idempotency-Key` header: 1–256 characters, kept for 24 hours, supported only on POST /emails and POST /emails/batch. There are two distinct 409s — invalid_idempotent_request is pointless to retry, concurrent_idempotent_requests is safe to retry later
- Classify by error name first and treat the HTTP status as a fallback. The official Errors page and the SDK constants disagree on the status of at least one error, so name-first classification is the more durable design
- Inject the send function, sleep, and randomness, and the whole retry policy becomes a pure function — backoff delays, the sender swap, and timeouts can all be pinned by deterministic tests with no network and no clock

---

Most email integrations start with roughly these lines.

```ts
const { data, error } = await resend.emails.send({ from, to, subject, html });
if (error) return NextResponse.json({ error: "送信に失敗しました" }, { status: 502 });
```

This works. But **the moment your provider hiccups, that send is gone for good**. The visitor sees "sending failed," nothing arrives in your inbox, and there is nothing left to recover from. On a contact form, that is one lead deleted.

The problem is not only the missing retry. It is that **the code does not distinguish between kinds of failure**. A momentary network blip succeeds a few hundred milliseconds later; an unverified `from` domain fails identically a thousand times in a row. And some failures deliver fine the instant you change the sender. This article starts from Resend's actual error contract and builds out **retries, idempotency keys, error classification, timeouts, deterministic tests, and observability** — using the TypeScript I run in production on this portfolio as the worked example.

For the big picture, see the [Resend production guide](/en/blog/resend-transactional-email-production-guide); for wiring it into Next.js, see the [App Router / route handler guide](/en/blog/resend-nextjs-app-router-route-handler-react-email-guide). This post is the one about making the send survive.

---

## 1. Sort failures into three kinds

Resilience does not start with picking a retry count. It starts with **counting the distinct answers to "what is the right next action for this failure?"** For email, there are exactly three.

| Kind | What is happening | The right next action | Typical causes |
|---|---|---|---|
| `retryable` | Transient. The same request succeeds shortly after | Resend the same thing with exponential backoff | Rate limit, provider 5xx, socket reset, DNS failure, timeout |
| `sender_rejected` | The payload itself was refused, but a different sender gets through | Swap once to a known-good sender and resend | The `from` domain is not verified |
| `fatal` | Configuration, permission, or input is wrong. Nothing changes the outcome | Give up immediately and log | Invalid API key, missing required field, 404 |

Most implementations stop at two kinds — retry or don't. **The middle kind earns its place** because it is the only case where retrying is useless *and* the send is still salvageable. Fold it into `fatal` and every lead that arrives while domain authentication is broken disappears. Fold it into `retryable` and you just make a guaranteed failure take three times as long.

I learned this middle case the hard way. **I had misplaced every domain-authentication record (DKIM / SPF / MX / DMARC) on the apex**, and my contact API returned 502 on every single send. Fixing DNS is the real cure, but the only thing that could save the inquiries arriving while DNS was broken was a design that **swaps the sender once to a known-good address**.

> **Design implication**: cut your categories by **the next action**, not by "how bad" the error is. Same action, same category; different action, separate category. Applied to email, that lands on exactly three, and they map one-to-one onto branches in the code.

Fixing domain authentication itself is covered in the [domain authentication and deliverability guide](/en/blog/resend-domain-authentication-spf-dkim-dmarc-deliverability-guide). This post handles the other half: staying up while it is broken.

---

## 2. Know exactly what Resend errors look like

### 2.1 The SDK does not throw

Here is the shared response type of the Resend Node SDK (`resend@6.4.1` in this repository; the latest on npm was 6.18.1 as of 2026-08-06).

```ts
// data and error are mutually exclusive. v6 adds headers (rate-limit headers)
type Response<T> = ({ data: T; error: null } | { error: ErrorResponse; data: null })
  & { headers: Record<string, string> | null };

type ErrorResponse = {
  message: string;
  statusCode: number | null;   // null on a network-layer failure
  name: RESEND_ERROR_CODE_KEY; // union of string literals
};
```

In other words, **`resend.emails.send()` does not throw for API errors or for network failures**. Reading the SDK bundle (v6.18.1), a failure of `fetch` itself comes back as `{ data: null, headers: null, error: { name: "application_error", statusCode: null, message: "Unable to fetch data. The request could not be resolved." } }`. The docs say the same thing: do not use try/catch with `resend.emails.send()`, because the SDK returns `{ data, error }` instead of throwing.

That rule, however, only covers the HTTP and network layer. Two throw paths genuinely exist.

| Where it throws | Condition | What it means in practice |
|---|---|---|
| `new Resend(key)` | No key passed and no `RESEND_API_KEY` | Constructing at module scope **fails at import time** — you cannot even return a graceful 500 |
| `react:` rendering | `@react-email/render` not installed (an optional peer dependency since v5.0.0) | A plain `Error` is thrown instead of `{ error }` |

So the correct shape is: **branch on `error`, and still keep a try/catch around it**. Lazy client construction removes the first path.

```ts
// Never new Resend(...) at module scope (avoids the import-time throw)
let resendClient: Resend | null = null;
function getResend(): Resend {
  if (!resendClient) {
    const apiKey = process.env.RESEND_API_KEY;
    if (!apiKey) throw new Error("RESEND_API_KEY is not configured");
    resendClient = new Resend(apiKey);
  }
  return resendClient;
}
```

One more thing worth knowing: the SDK also **`console.error`s every error in development**, suppressed only when `NODE_ENV` is production. The output you saw locally is not there in production, so the logs in the last chapter are yours to write.

### 2.2 The error names (from the SDK constants)

The SDK's type definitions carry an error-name to HTTP-status map. These are the v6.4.1 values.

| Error name | Status | Error name | Status |
|---|---|---|---|
| `missing_required_field` | 422 | `invalid_idempotency_key` | 400 |
| `invalid_idempotent_request` | 409 | `concurrent_idempotent_requests` | 409 |
| `invalid_access` | 422 | `invalid_parameter` | 422 |
| `invalid_region` | 422 | `rate_limit_exceeded` | 429 |
| `missing_api_key` | 401 | `invalid_api_key` | 403 |
| `suspended_api_key` | 403 | `invalid_from_address` | 403 |
| `validation_error` | 403 | `not_found` | 404 |
| `method_not_allowed` | 405 | `application_error` | 500 |
| `internal_server_error` | 500 | | |

Newer versions extend **the error-name union** with `restricted_api_key`, `invalid_attachment`, `daily_quota_exceeded`, `monthly_quota_exceeded`, and `security_error`. One caveat, though: **the constant map above is gone from the newer distributions** — the only error-related public export left is the `ErrorResponse` type. So the statuses for those newer names come from the **official Errors page**, not from the SDK: `restricted_api_key` is **401**, `invalid_attachment` is 422, the quota errors are 429, and `security_error` is **451**. The 451, and the fact that insufficient permissions arrive as 401 rather than 403, are exactly the details you will get wrong if you write from memory.

### 2.3 Why "classify by name, use the status as a fallback"

Here is the key point of this chapter. **The official Errors page and the SDK constants assign different statuses to the same error in at least one place.**

- `invalid_from_address` — **422** in the Errors page table, **403** in the SDK constants.
- `validation_error` — the Errors page itself lists it under both **400 and 403**, and the pagination error example shows `"statusCode": 422`.

I am not going to declare which one is "right" (the value you actually receive may vary by environment and by date). I do not need to. **If the primary key of your classification is the error name, the classification is unchanged whichever status comes back.** Use the status only as a fallback for names you do not recognise — that single decision keeps your implementation from being dragged around by documentation drift.

---

## 3. The exact idempotency-key contract

Build the **duplicate-send guard before the retry**. In the other order, the day you add retries is the day two identical emails arrive.

| Item | Specification |
|---|---|
| HTTP header | `Idempotency-Key` |
| Over SMTP | The email header `Resend-Idempotency-Key` |
| Length | 1–256 characters |
| Retention | 24 hours |
| Supported endpoints | `POST /emails` and `POST /emails/batch` **only** |
| Recommended format | A UUID, or `<event-type>/<entity-id>` (e.g. `welcome-user/123456789`) |
| Recommended batch key | A key that represents the whole batch (e.g. `team-quota/123456789`) |

The behaviour is straightforward. **If a send with the same idempotency key already happened within the last 24 hours, Resend does not send again and returns the same response.** That is precisely why resending with the same key is safe — and why a retry without a key is nothing but a double-send waiting to happen.

### 3.1 The key goes in the second argument (not in the payload)

```ts
await resend.emails.send(
  { from: "Acme <onboarding@resend.dev>", to: ["delivered@resend.dev"],
    subject: "hello world", html: "<p>it works!</p>" },
  // Only here does it become the Idempotency-Key header
  { idempotencyKey: "welcome-user/123456789" },
);
```

Watch out: **parts of the official docs (the AI-oriented prompt blocks) show `idempotencyKey` inside the payload object**. That shape does not exist on the `CreateEmailOptions` type, so it fails type-checking — and if you bypass the types, the SDK simply never reads that field and drops it. It breaks in the worst possible way: **no error, no warning, and idempotency silently disabled**. When you review AI-generated code in this area, look here first.

Likewise, putting your own identifier in the payload `headers` (say `X-Entity-Ref-ID`) does not deduplicate anything. The only mechanism Resend guarantees deduplication for is `Idempotency-Key`.

### 3.2 There are two 409s, and they mean opposite things

| Name | Status | Meaning | What to do |
|---|---|---|---|
| `invalid_idempotency_key` | 400 | The key is outside 1–256 characters | Retry with a valid key, or with no key at all |
| `invalid_idempotent_request` | 409 | The same key was used with a **different payload** | **Retrying is useless.** Change the key or the payload. Almost always your bug |
| `concurrent_idempotent_requests` | 409 | The original request with that key is **still in progress** | **Safe to retry later** (Resend says so explicitly) |

When `invalid_idempotent_request` shows up, suspect **a value inside the payload that changes on every call**. Embedding a generated timestamp in the body, or picking a greeting with `Math.random()`, produces "same key, different payload" on every retry. `concurrent_idempotent_requests` is the opposite: it is **the normal signal that two workers picked up the same event at once**. Classify it as `retryable`, wait a moment, and the original request's result comes back.

Payment idempotency follows the same logic, and the "derive the key deterministically from the event id" rule from my [Stripe production guide](/en/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions) transfers directly. This site's Stripe webhook does exactly that.

```ts
// A Stripe retry of the same event dedupes at Resend.
// The only requirement: the key is derived deterministically from the event id.
const { error } = await client.emails.send(
  { from, to: [purchase.email], subject, html, text },
  { idempotencyKey: `stripe-fulfill:${eventId}` },
);
```

---

## 4. Implementation: a pure function with injected I/O

From here on I use this site's `lib/email-delivery.ts` as the worked example. The module is **provider-agnostic and I/O-free**: the send function, the sleep, and the randomness all arrive as arguments.

### 4.1 The classifier

```ts
/** Anything absent falls to fatal (never loop on an unknown permanent error) */
const RESEND_ERROR_KINDS: Readonly<Record<string, DeliveryErrorKind>> = Object.freeze({
  rate_limit_exceeded: "retryable",
  application_error: "retryable",
  internal_server_error: "retryable",
  concurrent_idempotent_requests: "retryable",
  invalid_from_address: "sender_rejected",
  validation_error: "sender_rejected",
});

export function classifyDeliveryError(error: DeliveryError | null | undefined): DeliveryErrorKind {
  // Object.hasOwn, not a bare index: an error named `constructor` or `toString`
  // would otherwise resolve up the prototype chain to a truthy non-kind value
  const byName =
    error?.name && Object.hasOwn(RESEND_ERROR_KINDS, error.name)
      ? RESEND_ERROR_KINDS[error.name]
      : undefined;
  if (byName) return byName;

  const status = error?.statusCode;
  // No status = it never reached the provider (DNS failure, socket reset, abort).
  // Nothing was sent, so a retry cannot double-send
  if (status == null) return "retryable";
  if (status === 429 || status >= 500) return "retryable";
  return "fatal";
}
```

Three notes.

1. **`validation_error` sits under `sender_rejected`** because that is the error Resend returns for an unverified `from` domain. It is also Resend's generic 403 validation bucket, so the swap can occasionally fire for an unrelated cause. The cost of that is **one extra request**, after which the original error surfaces anyway — strictly better than dropping a lead.
2. **No status means retryable** because nothing reached the provider, so nothing was sent. That reasoning is different from a failure that might have landed (a timeout), where the idempotency key is what makes the retry safe (section 4.3).
3. **The table has a weakness, and I would rather name it.** Quota exhaustion (`daily_quota_exceeded` / `monthly_quota_exceeded`) is a 429, so with the name absent from the table it falls through to the status and becomes `retryable` — yet it does not recover in a few hundred milliseconds. With three attempts and exponential backoff the damage is two wasted requests, but registering those names explicitly as `fatal` is the more honest design.

### 4.2 Exponential backoff with jitter, and the derived key

```ts
/** A deterministic exponential floor plus up to 50% random padding */
export function backoffDelayMs(attempt: number, baseDelayMs: number, jitter: number): number {
  const exponential = baseDelayMs * 2 ** (attempt - 1);
  return Math.round(exponential * (1 + jitter * 0.5));
}
```

The floor keeps the schedule pinnable in tests; **the jitter stops a group of simultaneously failing sends from retrying in lockstep**. Provider trouble hits many requests at once, so if everyone waits by the same formula they all come back at the same instant and shove a recovering provider straight back over. Randomness is an argument, so tests pass `random: () => 0` and assert exact delays.

```ts
const FALLBACK_KEY_SUFFIX = ".fb";
export const MAX_IDEMPOTENCY_KEY_LENGTH = 256;

// The fallback changes the payload (from), so the same key would return 409
// invalid_idempotent_request. Hence a derived key. Truncating first matters:
// if the caller's key is already at the limit, appending overflows 256 chars
function fallbackKey(idempotencyKey: string): string {
  const room = MAX_IDEMPOTENCY_KEY_LENGTH - FALLBACK_KEY_SUFFIX.length;
  return `${idempotencyKey.slice(0, room)}${FALLBACK_KEY_SUFFIX}`;
}
```

"Truncate, then append" looks like a detail, but it is **the line that keeps the resilience code from betraying its own purpose**. A bug where the fallback dies with a 400 only when the caller's key is exactly at the limit reproduces only during an incident. Also note that **the swap does not sleep**: what was refused is the payload, not congestion, so there is nothing to wait for.

### 4.3 Timeouts

A timeout is not a nice-to-have. **A provider that hangs never reaches your retry logic.** The handler simply sits in `await send(...)` until the platform kills it, taking the failure log with it.

```ts
// Synthetic error name for an attempt that exceeds the ceiling. It carries no
// status, so it classifies as retryable — which is correct precisely because the
// retry reuses the same idempotency key: if the timed-out attempt did land, the
// provider's own dedupe absorbs the retry
export const TIMEOUT_ERROR_NAME = "request_timeout";

export const maxDuration = 25;   // retries spend several round trips plus backoff
const SEND_TIMEOUT_MS = 8_000;   // the per-attempt ceiling fits inside that budget
```

For the execution model behind those numbers, see the [Vercel Functions and Fluid Compute guide](/en/blog/vercel-functions-fluid-compute-streaming-cron-guide).

### 4.4 The loop: termination guaranteed by construction

```ts
for (let attempt = 1; ; attempt++) {
  senderAttempt++;
  // Retries of the same sender reuse the key verbatim so the provider dedupes
  const key = usedFallbackSender ? fallbackKey(idempotencyKey) : idempotencyKey;
  // …send, timeout race, normalise thrown values…
  if (!response.error) return { ok: true, messageId: response.data?.id ?? null };

  const kind = classifyDeliveryError(response.error);
  // The sender swap happens once. A refused payload has nothing to wait for
  if (kind === "sender_rejected" && !usedFallbackSender &&
      Boolean(fallbackFrom) && fallbackFrom !== sender) {
    sender = fallbackFrom as string;
    usedFallbackSender = true;
    senderAttempt = 0;  // give the new sender a fresh budget
    continue;
  }
  if (kind === "retryable" && senderAttempt < maxAttempts) {
    await sleep(backoffDelayMs(senderAttempt, baseDelayMs, random()));
    continue;
  }
  return { ok: false, kind /* …attempt trail… */ };
}
```

It looks unbounded, but **termination is guaranteed structurally**. The loop only continues for "a retryable failure below `maxAttempts`" or "the single sender swap," so whatever the provider returns it exits within `2 * maxAttempts` iterations. The scariest thing in resilience code is a loop that spins only during an outage. **Bounding it by structure rather than by a counter** removes that fear.

Resetting `senderAttempt` on the swap follows the same reasoning: **attempts burned on a broken sender say nothing about a known-good one**. Carry them over and the path you built to save the lead dies on its first transient blip.

The call site just passes the send itself as a closure.

```ts
result = await deliverEmail({
  from: configuredFrom,
  // Swapping to the same address would just repeat the identical failure
  fallbackFrom: configuredFrom === DEFAULT_FROM ? undefined : DEFAULT_FROM,
  idempotencyKey: safeIdempotencyKey(request.headers.get("Idempotency-Key")),
  timeoutMs: SEND_TIMEOUT_MS,
  send: (from, idempotencyKey) =>
    client.emails.send(
      { from, to: [recipient], replyTo: data.email, subject, html, text },
      // Only this option dedupes. Without it, the retries above double-send
      { idempotencyKey },
    ),
});
```

> **An important precondition**: `onboarding@resend.dev`, the fallback sender, **can only deliver to the account owner's own address**. So this swap is sound for owner notifications and nothing else. Doing the same on mail addressed to a visitor or customer just fails at the fallback too, so do not pass `fallbackFrom` there. In my own repository, only the contact notification (addressed to me) has this path enabled.

---

## 5. Living with the rate limit

Resend's default rate limit is **10 requests per second per team**. The scope is what matters: it is **per team — not per API key and not per domain**. As the docs illustrate, if service A sends 6 requests and service B sends 4 in the same second, you have hit the limit. There is also **no burst allowance**: with a limit of 10, the eleventh request in the same second gets a 429. (Your current limit is visible at `https://resend.com/settings/usage`, and trusted senders can request an increase.)

The response headers follow the sixth IETF standard draft.

| Header | Meaning |
|---|---|
| `ratelimit-limit` | Maximum number of requests allowed within a window |
| `ratelimit-remaining` | How many requests you have left in the current window |
| `ratelimit-reset` | How many seconds until the limits reset |
| `retry-after` | How many seconds to wait before a follow-up request |

Your consumed quota is readable from `x-resend-daily-quota` (only sent to free-plan users) and `x-resend-monthly-quota`. The full header list and how to read it for cost live in the [Resend production guide](/en/blog/resend-transactional-email-production-guide); here I only need the four that inform a wait decision.

Because SDK v6 responses include `headers`, you can read these straight off the result.

```ts
const { data, error, headers } = await resend.emails.send(payload, { idempotencyKey });
// Use it to decide whether to delay the next batch when the budget is nearly gone
const remaining = Number(headers?.["ratelimit-remaining"] ?? Number.NaN);
```

To be honest, though: **I could not find an official page that demonstrates this pattern**. It is inferred from the type (`headers` on `Response<T>`) and from the SDK implementation. Adopt it knowing that it leans on SDK internals rather than a documented guarantee.

**A 429 needs a two-way decision.**

1. **`rate_limit_exceeded`** — recovers after a short wait. This is the region where backoff-and-resend is correct. Resend's own suggested action is to read the response headers and reduce your rate, introduce a queue mechanism, or reduce concurrent requests per second.
2. **`daily_quota_exceeded` / `monthly_quota_exceeded`** — will not recover for hours, or until next month. That is territory for pausing sends, parking them in a queue, and alerting — not for retrying.

There is also a structural escape hatch: **a batch request counts as one request** against the rate limit. Folding a one-at-a-time loop into `POST /emails/batch` changes your throughput per unit of rate limit outright. Details live in the [batch, scheduled sends, and Broadcasts guide](/en/blog/resend-batch-scheduled-broadcasts-audiences-topics-unsubscribe-guide). And limiting traffic at your own endpoint is cheaper than reacting to Resend's 429 — that design is in the [serverless rate limiting guide](/en/blog/nextjs-serverless-rate-limiting-vercel-guide).

---

## 6. Testing: cover every branch without a clock or a network

Retry tests break if they actually wait. A test that sleeps three seconds is slow and flaky. **Injecting sleep and randomness** makes the problem disappear.

```ts
it("retries a transient failure with exponential backoff and succeeds", async () => {
  // No-op clock: it only records what would have been slept
  const slept: number[] = [];
  const sleep = async (ms: number) => { slept.push(ms); };
  const send = vi.fn<(from: string, key: string) => Promise<ProviderResponse>>()
    .mockResolvedValueOnce(fail("rate_limit_exceeded", 429))
    .mockResolvedValueOnce(fail("internal_server_error", 500))
    .mockResolvedValueOnce(ok("msg_2"));

  const result = await deliverEmail({
    send, from: "a@x.test", idempotencyKey: "k",
    sleep, random: () => 0, baseDelayMs: 100,
  });

  expect(result.ok).toBe(true);
  expect(slept).toEqual([100, 200]);
  // Every retry of the same sender reuses the key so the provider dedupes
  expect(send.mock.calls.map(([, key]) => key)).toEqual(["k", "k", "k"]);
});
```

What you pin is not "did it succeed" but **the resilience promises themselves**. The invariants I actually hold are: retries of the same sender **never change the key**; the swap uses a **derived key ending in `.fb`**; a key already at the limit still produces a derived key **within 256 characters**; `sender_rejected` **does not sleep** (`slept` is empty); `fatal` **stops after one attempt**; a hung send becomes a timeout classified as retryable and is **retried with the same key**; and the **attempt trail survives on the failure path** — because that trail is the content of the incident log.

### 6.1 Exercising the integration without sending

- **Point the SDK at a mock server.** The base URL can be overridden with the `RESEND_BASE_URL` environment variable (and `RESEND_USER_AGENT`). Aim it at a local HTTP server and you can reproduce 429s, 5xx, and "no response at all" on demand. These variables are **undocumented on the docs site — I confirmed them in the SDK bundle**, so treat them as something that can change between versions. (Newer versions expose the same thing as `baseUrl` / `userAgent` constructor options.)
- **Use the test addresses.** `delivered@resend.dev` simulates delivery, `bounced@resend.dev` a bounce (it generates an SMTP 550 5.1.1 response), `complained@resend.dev` a spam complaint, and `suppressed@resend.dev` a suppression. Plus-labelling works too (`delivered+signup@resend.dev`), except on `suppressed`. Two caveats: **test sends still count against your sending quota**, and mail to `@example.com` / `@test.com` is **blocked with a 422**. Handling bounces and complaints properly is covered in the [webhook signature verification and bounce/complaint guide](/en/blog/resend-webhooks-signature-verification-bounce-complaint-guide).

### 6.2 Harmless verification in production

What you usually want to confirm in production is "is the path alive," not "did mail arrive." Two harmless moves:

1. **Send twice with the same idempotency key.** Within 24 hours Resend does not actually send the second one and returns the same response. **If the same email id comes back, that is proof the idempotency key is really in effect** — zero side effects, and it verifies retry safety itself.
2. **Hit a path that stops short of sending.** This site's contact API returns 200 — **without sending** — for requests caught by the spam gate (dwell time below the threshold, or the honeypot filled in). So posting a request with `elapsedMs` set to 0 exercises rate limiting, JSON parsing, and schema validation in production while sending exactly zero emails. Check whether your own handler has a legitimate "stops just before the send" path like that.

---

## 7. Observability: it is the successful fallback that deserves a warn

Adding retries creates a new state: **succeeding while broken**. The configured sender is being rejected, yet mail arrives via the fallback. The visitor sees nothing, the email lands, and nobody notices. **The day the fallback dies too is the day it surfaces — as a total outage.**

```ts
// A successful fallback delivery means the configured sender is broken while the
// lead was still saved. It is invisible to the visitor, so this is the only place
// it can be raised
log(result.usedFallbackSender ? "warn" : "info", "sent", {
  attempts: result.attempts.length,
  usedFallbackSender: result.usedFallbackSender,
});

log("error", "delivery_failed", {
  kind: result.kind, errorName: result.errorName,
  statusCode: result.statusCode, attempts: result.attempts.length,
});
```

There are three rules for these logs. The first two — **one line of JSON** (multi-argument `console.*` calls get merged or trimmed by whatever log viewer your host provides) and **no PII** (record the kind, the error name, the status, and the attempt count; the attempt trail is PII-free by construction) — are route-handler hygiene in general, so I leave them, and the `log()` implementation itself, to the [App Router / route handler guide](/en/blog/resend-nextjs-app-router-route-handler-react-email-guide). The third is specific to retries.

**Make failures readable by kind.** With `kind`, `errorName`, `statusCode`, and `attempts` present, the log alone answers "was today's 502 a `rate_limit_exceeded` that gave up after three retries, or an `invalid_api_key` that stopped at one?" Drop any one of the four and you are back to inferring the retry count from the source during an incident.

One operational warning to close on. **If your production build strips `console` output as an optimisation, verify once that these lines actually survive into production** (the concrete way to narrow Next.js's `removeConsole` is in the guide linked above). Discovering mid-incident that the logs were never there is the most expensive way to check.

---

## 8. Production checklist

- [ ] You branch on `{ data, error }` **and still keep a try/catch** (the constructor and `react:` throw paths)
- [ ] `new Resend(...)` is **not at module scope**
- [ ] Failures are sorted into **retryable / sender_rejected / fatal**, keyed on the **error name** first
- [ ] Unknown names fall to **fatal** (never loop on an unknown permanent error)
- [ ] Retries use **exponential backoff with jitter**, with the attempt bound guaranteed structurally
- [ ] **Every send passes `idempotencyKey` as the second argument** (not inside the payload)
- [ ] The idempotency key **does not change between retries** (no timestamps, no randomness)
- [ ] The sender swap uses a **derived key** that stays within 256 characters
- [ ] `concurrent_idempotent_requests` is retryable; `invalid_idempotent_request` is fatal
- [ ] Quota 429s are **distinguished** from `rate_limit_exceeded`
- [ ] Each send has a **timeout** that fits inside the function's overall time budget
- [ ] Logs are **single-line JSON**, PII-free, and a **successful fallback is logged at warn**
- [ ] **Deterministic tests** with injected sleep and randomness cover every branch
- [ ] Customer-facing mail does **not** fall back to `onboarding@resend.dev`

---

## Wrapping up

Resilience is not about raising the retry count. It is about **expressing "what should happen next after this failure" completely, in types and branches**. With Resend, every ingredient is already there: the SDK returns `{ data, error }` instead of throwing, error names are a union of string literals, and `Idempotency-Key` returns the same response for 24 hours across 1–256 characters. All that remains is to keep the middle case — "resending the same payload is useless, but changing the sender delivers" — instead of crushing it.

I arrived at this design through my own outage: **every inquiry returning 502 while domain authentication was broken**. You do not have to pay that tuition. There is exactly one thing to do today — check whether your `resend.emails.send()` has an `idempotencyKey` in its second argument. If it does not, start there, before you add any retries.

Whether Resend is the right provider at all is a separate question; the comparison lives in the [email service selection guide](/en/blog/resend-vs-sendgrid-ses-postmark-email-service-selection-guide).

> This article is based on the [Resend official documentation](https://resend.com/docs) (Idempotency Keys / Errors / Rate Limit / API Reference Introduction / Send test emails, as of August 2026) and on the type definitions and bundled implementation of the installed Node SDK (`resend@6.4.1`, plus 6.18.1, the latest on npm), reorganised with the judgement calls that production use demands. Status codes and limits change, and official pages genuinely disagree with each other in places, so verify the current values on the relevant official page before you adopt anything in production.
