Skip to main content
友田 陽大
Resend & transactional email
Resend
メール配信
冪等性
信頼性
TypeScript
テスト
可観測性

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
Reading time
22 min read
Author
友田 陽大
Share

Most email integrations start with roughly these lines.

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; for wiring it into Next.js, see the App Router / route handler 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.

KindWhat is happeningThe right next actionTypical causes
retryableTransient. The same request succeeds shortly afterResend the same thing with exponential backoffRate limit, provider 5xx, socket reset, DNS failure, timeout
sender_rejectedThe payload itself was refused, but a different sender gets throughSwap once to a known-good sender and resendThe from domain is not verified
fatalConfiguration, permission, or input is wrong. Nothing changes the outcomeGive up immediately and logInvalid 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. 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).

// 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 throwsConditionWhat it means in practice
new Resend(key)No key passed and no RESEND_API_KEYConstructing 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.

// 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.errors 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 nameStatusError nameStatus
missing_required_field422invalid_idempotency_key400
invalid_idempotent_request409concurrent_idempotent_requests409
invalid_access422invalid_parameter422
invalid_region422rate_limit_exceeded429
missing_api_key401invalid_api_key403
suspended_api_key403invalid_from_address403
validation_error403not_found404
method_not_allowed405application_error500
internal_server_error500

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_address422 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.

ItemSpecification
HTTP headerIdempotency-Key
Over SMTPThe email header Resend-Idempotency-Key
Length1–256 characters
Retention24 hours
Supported endpointsPOST /emails and POST /emails/batch only
Recommended formatA UUID, or <event-type>/<entity-id> (e.g. welcome-user/123456789)
Recommended batch keyA 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)

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

NameStatusMeaningWhat to do
invalid_idempotency_key400The key is outside 1–256 charactersRetry with a valid key, or with no key at all
invalid_idempotent_request409The same key was used with a different payloadRetrying is useless. Change the key or the payload. Almost always your bug
concurrent_idempotent_requests409The original request with that key is still in progressSafe 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 transfers directly. This site's Stripe webhook does exactly that.

// 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

/** 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

/** 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.

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.

// 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.

4.4 The loop: termination guaranteed by construction

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.

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.

HeaderMeaning
ratelimit-limitMaximum number of requests allowed within a window
ratelimit-remainingHow many requests you have left in the current window
ratelimit-resetHow many seconds until the limits reset
retry-afterHow 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; 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.

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. And limiting traffic at your own endpoint is cheaper than reacting to Resend's 429 — that design is in the serverless rate limiting 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.

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.

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.

// 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. 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.

This article is based on the Resend official documentation (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.

Frequently asked questions

Does the Resend Node SDK throw?
Not for API errors and not for network failures — everything comes back as `{ data, error, headers }`. When `fetch` itself fails you get `{ name: 'application_error', statusCode: null, message: 'Unable to fetch data. The request could not be resolved.' }` in `error`. There are exactly two throw paths: the constructor when no API key is configured, and rendering when you use the `react:` option without `@react-email/render` installed. So the correct shape is: branch on `error`, and still wrap the call in try/catch.
What should I use as an idempotency key?
Resend recommends a UUID or any string that uniquely identifies that specific email, and suggests the format `<event-type>/<entity-id>` (for example `welcome-user/123456789`). The length must be 1–256 characters. What matters most is that the key does not change between retries — mixing in `Date.now()` or `Math.random()` turns every retry into a different request and removes the protection entirely. For externally triggered sends such as a Stripe webhook, derive the key deterministically from the event id.
Should I retry on a 409?
There are two 409s and they call for opposite actions. `invalid_idempotent_request` means the same key was used with a different payload; Resend states plainly that retrying is useless unless you change the key or the payload. That is almost always a bug on your side. `concurrent_idempotent_requests` means the original request with that key is still in progress, and Resend states it is safe to retry later. Classify the first as fatal and the second as retryable.
How should I wait after a 429?
Read the response headers: `ratelimit-limit`, `ratelimit-remaining`, `ratelimit-reset` (seconds until the limits reset), and `retry-after` (seconds to wait before the next request). SDK v6 includes `headers` on the response, so you can read them straight off the result. Note that 429 covers more than rate limiting — `daily_quota_exceeded` and `monthly_quota_exceeded` also return 429, and those do not recover in a few seconds. Branch on the name, not the status.
Is there any point retrying when the `from` address is rejected?
No. What is being rejected is the payload itself, so sending the same thing again produces the same result. What does work is swapping the sender once to a known-good address. I lived through this: my domain-authentication records were all misplaced on the apex, and my contact API returned 502 on every submission. Part of the permanent fix was exactly this one-time sender swap. Because the swap changes the payload, the idempotency key has to be derived too.
How do I exercise the retry paths without actually sending email?
Three layers work well in practice. (1) In unit tests, inject the send function, sleep, and randomness so every branch runs with no network and no clock. (2) In integration tests, point the SDK at a mock server with the `RESEND_BASE_URL` environment variable (it exists in the SDK bundle but is not documented on the docs site). (3) When you do send, use the test addresses `delivered@resend.dev`, `bounced@resend.dev`, `complained@resend.dev`, and `suppressed@resend.dev` — but remember that test sends still consume your sending quota.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Transactional email infrastructure on Resend — design, implementation, and production operations

"We sent it" is not "it arrived." I close that gap in code and DNS rather than in operational vigilance: sender-domain authentication (SPF/DKIM/DMARC), a typed send path validated with Zod, idempotency keys and backoff for resilience, bounce and complaint handling over webhooks, RFC 8058 one-click unsubscribe, and structured logs that never carry PII. This site's own contact form, gated resources, email course and post-purchase mail all run on Resend in production — including the outage where misplaced authentication records took sending down entirely.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading