# Resend 本番実装ガイド：ドメイン認証から冪等な送信・Webhook運用・到達率まで

> Resend 公式ドキュメント（2026年8月時点）に忠実な本番運用ガイド。ドメイン認証の落とし穴、resend@6 の { data, error } 型安全な送信、冪等キーとリトライ設計、Webhook 署名検証、一括・予約送信、購読管理、プラン別コストまでを実コードで体系化します。

- 公開日: 2026-08-06
- 著者: 友田 陽大
- タグ: Resend, メール配信, Next.js, TypeScript, 到達率, SPF/DKIM/DMARC, Webhook, 冪等性
- URL: https://tomodahinata.com/blog/resend-transactional-email-production-guide
- カテゴリ: Resend・メール配信

## 要点

- 送信APIが200を返しても『届いた』証明にはならない。本番のメールが満たすべきは『届く（ドメイン認証と到達率）／追える（Webhookと構造化ログ）／二重に送らない（冪等キー）』の3条件で、この記事はその3つを1本の設計として通す地図です
- 2026年のResendは知識の更新が必要。Audiences は deprecated で Segments が正、Templates・Topics・Suppressions・Automations・受信（Inbound）が増え、Webhook署名検証はSDK内蔵の webhooks.verify、User-Agent ヘッダは必須（無いと403）、プランは Free/Pro/Scale/Enterprise の4段です
- SDKは例外を投げず { data, error } を返す。だからリトライ判断は『HTTPステータス』ではなく『エラー名』で行う。409 の invalid_idempotent_request は再試行しても無駄、concurrent_idempotent_requests は再試行してよい——この2つを取り違えると重複送信か送信失敗のどちらかになります
- SPF関連レコードは apex ではなく send サブドメインに置く。私自身、この配置を誤ってサイトの問い合わせフォームが502を返し続けた実障害を起こし、DNS修正＋送信者フォールバック＋リトライ＋冪等キーで根治しました
- 無料枠は送信と受信の合計で100通/日・3,000通/月、To/CC/BCC は宛先ごとに1通として数える。レート制限は10リクエスト/秒でAPIキー単位ではなく『チーム単位』。この3つを知らないまま設計するとローンチ当日に詰まります（数値は2026年8月時点）

---

「送信APIが200を返した」は「メールが届いた」ではありません。200が保証するのは**Resend がリクエストを受理した**ことだけで、そこから先には受信側MTAの判定、SPF/DKIMの検証、迷惑メールフォルダ、恒久バウンス、苦情による抑制リスト入りが待っています。逆に、200が返らなかったときに何も設計していなければ、その1通は永久に失われます——ユーザーには「送信に失敗しました」とだけ表示され、あなたは誰が何を送ろうとしたのかすら知りません。

本番のメールが満たすべき条件は3つです。**届く**（ドメイン認証と到達率）、**追える**（Webhookと構造化ログ）、**二重に送らない**（冪等キー）。この3つはバラバラの小技ではなく、1本の設計として通ります。

私はこのポートフォリオサイト自体を Resend で本番運用しています。問い合わせフォーム、資料請求（リードマグネット）、7通構成のメール講座（予約送信）、Stripe 決済後の納品メール——いずれも実際に稼働している経路です。そしてその過程で、**Resend のドメイン認証レコードを apex（ルートドメイン）に誤配置し、`/api/contact` が502を返し続ける実障害**を出しました。DNS の修正に加えて、送信者フォールバック・指数バックオフのリトライ・冪等キーを入れて根治しています。この記事は、その設計をそのまま地図にしたものです。

各テーマの深掘りは個別記事に分けました（本稿がクラスタの入口＝ピラーです）。コードは**このリポジトリが本番で使っている `resend@6.4.1` の型定義に一致**させています。

---

## まず2026年の Resend に知識を更新する

2024〜2025年の記事や、その時期までの知識で書かれたAI生成コードには、**いま実行すると壊れる**内容が混ざっています。最初にここを直します。

| 古い理解（捨てる） | 2026年の正しい理解（公式） |
|---|---|
| コンタクト管理は **Audiences** を使う | **Audiences は deprecated**（SDK の型定義に `@deprecated Use segments instead`）。新規実装は **Segments**。Contact はチーム全体でグローバルな1エンティティになり、複数 Segment に所属できる |
| Resend は「メール送信API」だけ | **Templates・Topics・Segments・Suppressions（抑制リスト）・Automations・受信（Inbound）** が加わった。受信メールは `emails.receiving` で取得する |
| Webhook 検証は `npm install svix` してから | **SDK 内蔵の `resend.webhooks.verify()` が第一の経路**。検証ライブラリは SDK が同梱するので追加インストール不要。同梱物はバージョンで変わり（`6.4.1` は `svix`、`6.18.1` は `standardwebhooks`）、それでも**受け取るヘッダ名も呼び出し方も同じ**なので、依存ライブラリ名を前提にコードを書かないこと |
| 必要なヘッダは `Authorization` だけ | **`User-Agent` ヘッダが必須**。無いとAPIに到達する前に **403（エラーコード `1010`）** で弾かれる。SDK と CLI は自動で付けるが、生 `fetch` 実装は要注意 |
| プランは Free / Pro / Enterprise | **Free / Pro / Scale / Enterprise の4段**。専用IPは Scale 以上の有料アドオン |
| 無料枠は「送信100通/日」 | **送信と受信の合計**で 100通/日・3,000通/月。**To・CC・BCC は宛先ごとに1通**として数える |
| レート制限はAPIキー単位 | **チーム単位で10リクエスト/秒**。チーム内の全APIキーが同じ枠を共有し、バースト許容枠は無い |
| `send()` は失敗すると例外を投げる | **投げない**。常に `{ data, error }`（v6 では `headers` も）を返す。例外が飛ぶのは別の2経路だけ（後述） |

数値はすべて **2026年8月6日時点**の公式ページの記載です。仕様も価格も更新されるため、本番採用時は公式の該当ページで最新値を確認してください。

補足として、npm 上の `resend` は2026年8月6日時点で **6.18.1** が最新であり、そこでは `suppressions` や `automations` も SDK のリソースとして生えています。本記事が基準にしている **6.4.1 にはこの2つが無い**ので、その環境から抑制リストAPIを叩くなら `resend.post()` / `resend.get()` の汎用メソッドを使うか、SDK を上げてください。ここは「バージョンによって触り方が変わる」ことを正直に押さえておく価値があります。

---

## 全体アーキテクチャ：どこで何が壊れるか

メール配信の障害切り分けが難しいのは、**壊れうる場所が5つあるのに、アプリから見えるのは最初の1つだけ**だからです。まず地図を頭に入れてください。

```text
[あなたのアプリ]
    │  ① POST https://api.resend.com/emails
    │     Authorization: Bearer re_xxx  /  User-Agent: 必須  /  Idempotency-Key: 任意
    ▼
[Resend API]  ── 認証 → バリデーション → 冪等キー照合 → キュー投入 → 200 { id }
    │  ② SMTP 送出（SPF/DKIM 署名済みの封筒として送出）
    ▼
[受信側 MTA（Gmail / Outlook / 企業のメールサーバ）]
    │  ③ 受理 / 一時拒否（遅延） / 恒久拒否（バウンス） / 迷惑メール判定
    ▼
[受信箱 or 迷惑メールフォルダ or バウンス or 苦情（スパム報告）]
    │  ④ バウンス・苦情のフィードバック
    ▼
[Resend]  ── 抑制リスト（Suppressions）へ自動登録し、以後の送信をスキップ
    │  ⑤ Webhook POST（署名付き・at-least-once・順序保証なし）
    ▼
[あなたのアプリ /api/resend-webhook]  ── 生ボディで署名検証 → 200 を返す
```

各地点の壊れ方と、その症状は次のとおりです。

| 壊れる場所 | 典型的な原因 | アプリから見える症状 |
|---|---|---|
| ① リクエスト | `User-Agent` 無し、APIキー無効、`from` が未検証ドメイン | 403。ローカルの `curl` では再現しないことがある |
| ② 送出 | DNSレコードの配置ミス（apex に SPF/MX を置いた等） | 403 `validation_error`。ドメイン検証が完了しない |
| ③ 受信側判定 | レピュテーション低下、迷惑メール判定、恒久バウンス | **APIは200のまま。アプリからは完全に不可視** |
| ④ フィードバック | 苦情率上昇、抑制リスト入り | 以後その宛先への送信が `suppressed` になる |
| ⑤ Webhook | 生ボディを使わず署名検証が失敗、5xxの返却、エンドポイント自動無効化 | 状態が更新されない。バウンスに気づけない |

**③はAPIの戻り値では絶対に検知できません。** だから⑤の Webhook が「あれば便利な機能」ではなく必須の可観測性になります。ここが、単発の送信スクリプトと本番システムの分かれ目です。

---

## 5分で送る：最小実装

### 1. APIキーを作る

ダッシュボード、API、CLI、MCP サーバのいずれからも作成できます。作成時に選ぶのは権限（`full_access` または `sending_access`）で、`sending_access` なら送信先ドメインをさらに絞り込めます。**送信しかしないサービスに `full_access` を渡さない**のが原則です。キー名は最大50文字、そして**値は作成時に一度しか表示されません**。

### 2. `resend.dev` の制約を理解する

検証済みドメインを持たない状態でも、共有送信元 `onboarding@resend.dev` を使って送信できます。ただしこれは**テスト専用**で、**Resend アカウントに紐づく自分のメールアドレス宛にしか届きません**。それ以外に送ると403で、こういう本文が返ります。

```text
You can only send testing emails to your own email address (your-email-address@domain.com).
To send emails to other recipients, please verify a domain at resend.com/domains, and change
the `from` address to an email using this domain.
```

なお Resend には**サンドボックスも本番承認プロセスもありません**。無料アカウントでもサインアップ直後から本番送信できます（AWS SES との明確な違いです）。

### 3. 最小コード

```bash
npm install resend
```

```ts
import { Resend } from "resend";

// 引数を省略すると process.env.RESEND_API_KEY を自動で読む。
// ただしキーが無いと「コンストラクタが throw する」ので、
// 本番コードではモジュールスコープで生成しないこと（後述）。
const resend = new Resend(process.env.RESEND_API_KEY);

const { data, error } = await resend.emails.send({
  from: "Acme <onboarding@resend.dev>",
  to: ["delivered@resend.dev"],
  subject: "hello world",
  html: "<p>it works!</p>",
});

// SDK は API エラーで throw しない。必ず error 側を分岐する。
if (error) {
  console.error(error.name, error.statusCode, error.message);
} else {
  console.log(data?.id);
}
```

テストで実在しないアドレスを使うと**バウンス率を自分で悪化させます**。Resend が用意している専用アドレスを使ってください：`delivered@resend.dev`（配信成功）、`bounced@resend.dev`（バウンス）、`complained@resend.dev`（苦情）、`suppressed@resend.dev`（抑制）。

送信本文は `html` / `text` / `react` のうち**最低1つ**が必須です（型で強制されます）。`text` を省略すると HTML から自動生成され、空文字を渡すとその自動生成をオプトアウトできます。

---

## 本番までの7つの関門

ここからが本題です。「動いた」から「本番で任せられる」までに越える関門を7つに分けました。各関門の実装詳細は個別記事に委ねます。

### 関門①：ドメイン認証と到達率

独自ドメインの検証は避けて通れません。そして**最も多い失敗は、レコードの置き場所を間違えること**です。Resend が生成する SPF 関連レコード（`feedback-smtp.<region>.amazonses.com` の MX と `v=spf1 include:amazonses.com ~all` の TXT）は **apex ではなく `send` サブドメイン**に置きます。DKIM の TXT は `resend._domainkey`、DMARC の TXT は `_dmarc`——この非対称性が事故のもとです。公式のトラブルシュートでも「レコードが正しい場所（ルートドメインではなく `send` サブドメイン）にあるか」が確認項目の2番目に挙がっています。

| レコード | 正しい置き場所 | 置いてはいけない場所 |
|---|---|---|
| SPF の MX（`feedback-smtp.*`） | `send.example.com` | apex |
| SPF の TXT（`v=spf1 …`） | `send.example.com` | apex |
| DKIM の TXT（`p=…`） | `resend._domainkey.example.com` | apex、`send.` |
| DMARC の TXT（`v=DMARC1 …`） | `_dmarc.example.com` | `send.` |
| トラッキングの CNAME | 設定したトラッキング用サブドメイン | apex |

DNS プロバイダの入力欄には**相対名**（`send.example.com` ではなく `send`）を貼ります。また、MX の値の末尾にドメインを勝手に足すプロバイダがあり、`feedback-smtp.eu-west-1.amazonses.com.example.com` になって検証が失敗します。その場合は値の末尾にピリオドを付けて FQDN であることを明示します。

私が起こした障害はまさにこれで、DKIM・SPF・MX・DMARC の4件すべてを apex に置いていました。症状は「フォーム送信が毎回502」。ドメインが検証されていないので送信が403で拒否され、リトライも無かったため問い合わせがすべて消えていました。

なお Resend は **1024bit の DKIM 鍵で署名し、2048bit をサポートしません**（RFC 8301 が検証側の最小サポート長として1024bitを定めていることを根拠に、方針として公開されています）。セキュリティ要件で2048bitを求められている場合は、事前に確認が必要です。

アカウント全体の閾値も押さえてください。**バウンス率4%未満、苦情率0.08%未満**——これを超えると送信が一時停止されることがあります。

DNSレコードの完全な一覧、DMARC の `p=none → quarantine → reject` の進め方、トラッキングサブドメインの扱いは [Resend ドメイン認証・到達率ガイド](/blog/resend-domain-authentication-spf-dkim-dmarc-deliverability-guide) で詳説します。

### 関門②：型安全な送信経路

**Resend API はブラウザから呼べません。** `api.resend.com` は `Access-Control-Allow-Origin` を返さないので、クライアントJSからの呼び出しは必ずCORSで失敗します。これはバグではなく**ガードレール**です——もし成功していたら、あなたのAPIキーは全世界に公開されていたことになります。したがってキーは `RESEND_API_KEY` に置き、**`NEXT_PUBLIC_` を付けない**（付けた瞬間クライアントバンドルに埋め込まれます）。

Next.js App Router では Route Handler にサーバー側の送信を閉じ込めます。実装上のポイントは3つ。

```ts
// app/api/contact/route.ts（このサイトの本番コードの抜粋）
export const dynamic = "force-dynamic";

let resendClient: Resend | null = null;

// ① 遅延初期化。モジュールスコープで new Resend(...) すると、
//    キー未設定時にコンストラクタが throw して「ビルド／レンダリングごと」落ちる。
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;
}
```

② SDK のオプションは **camelCase**（`replyTo` / `scheduledAt` / `topicId`）です。REST は snake_case ですが、SDK 内部で変換されます。**ホワイトリストに無いキーは黙って捨てられる**ので、`reply_to` と書くと型エラーにならないケースでも実際には送られません。

③ `template` と `html` / `text` / `react` は**型レベルで排他**です。テンプレート送信時のみ `from` と `subject` が任意になります。

そして例外が飛ぶ経路は2つだけ、という点も重要です。ひとつは上記のコンストラクタ。もうひとつは `react:` オプションを使ったときに `@react-email/render`（v5以降はオプショナルな peer 依存）が入っていないケースで、これは `{ error }` ではなく素の `Error` が飛びます。公式の Next.js サンプルが `try/catch` を持っているのはこのためです。

Zod による入力検証、レート制限、スパムゲート、構造化ログまで含めた完成形は [Next.js App Router × Resend 実装ガイド](/blog/resend-nextjs-app-router-route-handler-react-email-guide) にまとめました。レート制限そのものの設計は [Next.js サーバーレスのレート制限](/blog/nextjs-serverless-rate-limiting-vercel-guide)、フォーム側は [React Hook Form と Server Actions](/blog/react-hook-form-nextjs-server-actions-useactionstate-guide) を参照してください。

### 関門③：冪等性とリトライ

**リトライしうる送信には必ず冪等キーを付けます。** Resend は `POST /emails` と `POST /emails/batch` で `Idempotency-Key` をサポートし、同じキーの再送は**24時間以内なら実際には送らずに同じレスポンスを返します**。キーは1〜256文字。推奨形式は `<イベント種別>/<エンティティID>`（例：`welcome-user/123456789`）です。

```ts
const { data, error } = await resend.emails.send(
  {
    from: "Acme <notifications@mail.example.com>",
    to: [order.email],
    subject: "ご注文ありがとうございます",
    html,
  },
  // 第2引数。ペイロード内のカスタムヘッダでは重複排除されない。
  // 決済イベントIDのような「決定的な値」から作ること。
  { idempotencyKey: `order-receipt/${order.id}` },
);
```

冪等キーを入れたら、次はリトライの判断基準です。ここで**HTTPステータスではなくエラー名で分岐する**のが要点になります。理由は単純で、同じ409に**正反対の意味**が2つあるからです。

| エラー名 | HTTP | 意味 | 取るべき行動 |
|---|---|---|---|
| `rate_limit_exceeded` | 429 | 秒間リクエスト超過 | バックオフして再試行 |
| `application_error` / `internal_server_error` | 500 | Resend 側の一時障害 | バックオフして再試行 |
| `concurrent_idempotent_requests` | 409 | 同じキーの処理が進行中 | **後で再試行してよい** |
| `invalid_idempotent_request` | 409 | 同じキーで**異なるペイロード** | **再試行しても無駄**。実装バグを疑う |
| `invalid_idempotency_key` | 400 | キーが1〜256文字の範囲外 | キーを直す（キー無しで再送も可） |
| `validation_error` | 403 | 未検証ドメインからの送信など | 送信者を直す。リトライでは解決しない |
| `daily_quota_exceeded` / `monthly_quota_exceeded` | 429 | 送信枠の上限 | 同じ枠内での再試行は無意味 |

`invalid_idempotent_request` が出たときは、ペイロードに `Date.now()` や `Math.random()` が混ざっていないかを疑ってください。「同じキーなのに中身が違う」のは、ほぼ常にアプリ側の非決定性です。

このサイトでは、この分類表をそのまま純粋関数に落としています。

```ts
// lib/email-delivery.ts（抜粋）— 表に無い名前は HTTP ステータスで判定にフォールバックし、
// ステータスが無い（fetch が例外を投げた等＝そもそも届いていない）ときだけ retryable に倒す
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",
});
```

`sender_rejected` という3つ目の分類があるのがポイントです。「同じものを再試行する」「**違うもの**を再試行する」「諦める」は本質的に別の行動で、真ん中を潰すとリードが消えます。送信者が拒否されたときだけ一度だけ既知の有効な送信元へ切り替える——これが私の障害を「フォーム全滅」から「ログに警告が出るだけ」に変えた設計です。

補足として、公式のエラー一覧と SDK の定数で `invalid_from_address` のステータスが食い違っています（ドキュメントは422、SDK の定数は403）。どちらが正しいと断定はできませんが、**名前で分類してステータスは補助情報にする**設計にしておけば、この種の揺れに巻き込まれません。

なお冪等性そのものの効き目については、決済基盤の案件で**本番の二重課金0件**を維持できた実績があります。メールでも考え方は同じで、リトライの安全性は「送る側が決定的なキーを持てるか」に尽きます。テスト可能なリトライ関数の書き方、タイムアウト設計、バックオフの検証方法は [Resend の冪等性・リトライ・エラー設計](/blog/resend-idempotency-retry-error-handling-reliability-guide) に分けました。決済側の冪等性は [Stripe 本番実装ガイド](/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions) が対応します。

### 関門④：テンプレート

本文をコードに直書きしていると、文言修正のたびにデプロイが必要になります。Resend の Templates はダッシュボードまたはAPIで管理し、**公開（publish）済みのテンプレートだけが送信に使えます**（作成直後は下書き）。

```ts
// 作成と公開をチェーンで一度に行える（create() は PromiseLike で .publish() を持つ）
await resend.templates.create({ /* … */ }).publish();
```

送信時は `template.id`（IDまたはエイリアス）と `variables` を渡します。制約は明確です：**1テンプレートあたり変数は最大50個**（作成・更新のAPIリファレンスと「Working with Variables」ページの記載。ダッシュボードの Templates introduction ページだけが今も「20個」と書いていて公式内で食い違うので、API側の50を基準にしてください）、変数キーは ASCII 英数字とアンダースコアのみで最大50文字、値は文字列なら2,000文字まで。`FIRST_NAME` / `LAST_NAME` / `EMAIL` / `UNSUBSCRIBE_URL` は**予約語**で使えません。テンプレートが使う変数が1つでも欠けていると、送信はバリデーションエラーになります。

一方、型安全に本文をコンポーネントとして書きたいなら React Email（`react:` オプション）です。公式のルールとして、コンポーネントは **JSX ではなく関数呼び出しで渡します**（`WelcomeEmail({ name: "John" })`）。両者の使い分けとデザインの実務は [Resend テンプレート・React Email 設計ガイド](/blog/resend-templates-variables-react-email-design-guide) を参照してください。

### 関門⑤：一括送信・予約送信・購読管理

**一括送信**は `resend.batch.send()` で**1リクエストにつき最大100通**。しかも**バッチ1回はレート制限上1リクエストとして数えられる**ので、スループットの最大のレバーになります。制約は2つ：添付ファイルは未対応、そして既定の `batchValidation: 'strict'` では**1件でも不正なら全体が失敗**します。`'permissive'` にすると通った分だけ送り、失敗を `errors: { index, message }[]` で返します。

**予約送信**は `scheduledAt` に ISO 8601 か自然言語（`"in 1 hour"`、`"tomorrow at 9am"` 等）を渡します。**最大30日先**まで。`emails.update()` で再スケジュール、`emails.cancel()` でキャンセルできますが、**キャンセルは一方通行**（再スケジュール不可）です。そして見落としやすい罠がひとつ——**予約に使ったAPIキーを削除すると、その予約メールは送信されません**。キーのローテーション手順に予約分の考慮を入れてください。

このサイトのメール講座は7通を予約送信で作成し、購読解除トークンに後続日のメールIDを埋め込んで、解除時に `emails.cancel()` でまとめて止めています。DBもcronも使っていません。

**購読管理**では、`{{{RESEND_UNSUBSCRIBE_URL}}}`（三重波括弧）が Broadcasts と Automations の解除リンク用マージタグです。トランザクションメールに解除リンクを付ける場合は自分で `List-Unsubscribe` ヘッダを付けます。RFC 8058 のワンクリック解除には `List-Unsubscribe-Post: List-Unsubscribe=One-Click` も必要で、POST に対して空の200または202を返し、**48時間以内に送信を止める**必要があります。Gmail と Yahoo は1日5,000通超の一括送信者にこの準拠を求めています。

```ts
await resend.emails.send({
  from: "Acme <news@mail.example.com>",
  to: [contact.email],
  subject: "今週のアップデート",
  html,
  headers: {
    "List-Unsubscribe": `<https://example.com/unsubscribe?t=${token}>`,
    // 公式のコードサンプルには載っていないが、RFC 8058 準拠には必須
    "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
  },
});
```

バッチの分割戦略、予約のロールバック、Segments と Topics を使った購読管理の実装は [Resend の一括・予約送信と購読管理ガイド](/blog/resend-batch-scheduled-broadcasts-audiences-topics-unsubscribe-guide) にまとめました。

### 関門⑥：Webhook で結果を知る

前述のとおり、**受信側でどうなったかはAPIの戻り値では分かりません**。Webhook を受けて初めて、配信・バウンス・苦情・遅延・抑制が見えます。

実装の要点は「生ボディ」です。

```ts
import { Resend } from "resend";
import { type NextRequest, NextResponse } from "next/server";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: NextRequest) {
  const secret = process.env.RESEND_WEBHOOK_SECRET;
  if (!secret) return new NextResponse("Not configured", { status: 500 });

  // 生のボディ文字列。req.json() でパースしてから stringify すると署名が壊れる。
  const payload = await req.text();

  try {
    // verify は同期関数で、検証失敗時は例外を投げる（{ data, error } ではない）
    const event = resend.webhooks.verify({
      payload,
      headers: {
        id: req.headers.get("svix-id") ?? "",
        timestamp: req.headers.get("svix-timestamp") ?? "",
        signature: req.headers.get("svix-signature") ?? "",
      },
      webhookSecret: secret,
    });

    // ここで event.type によって分岐する
    return new NextResponse(null, { status: 200 });
  } catch {
    return new NextResponse("Invalid webhook", { status: 400 });
  }
}
```

**公式ドキュメントのサンプルはそのままでは動きません。** `NextRequest` の `headers` は `Headers` インスタンスなので、`req.headers['svix-id']` は `undefined` になります（`.get()` が必要）。さらに別のページのサンプルは `payload: JSON.stringify(req.body)` と書いていて、これは同じドキュメントが「やってはいけない」と警告している当のアンチパターンです。上のコードは両方を修正した版です。

運用面で押さえるべき性質は3つ。

- **at-least-once**：同じイベントが複数回届きうる。`svix-id` ヘッダを保存して重複をスキップする。
- **順序保証なし**：`email.opened` が `email.delivered` より先に届くことがある。並べ替えるならペイロードの `created_at` を使う。
- **失敗すると自動無効化される**：エンドポイントが失敗し続けるとメール通知が来て、最終的にエンドポイントが自動で無効化される。復旧後はダッシュボードから再有効化する。

再送スケジュールについては、**公式の3ページが3通りの記述をしています**（6段階、8段階、そして「最大24時間」）。ここは断定を避け、「数時間から24時間程度の再送窓がある」前提で設計するのが安全です。

イベント種別ごとの `data` の中身、バウンス種別（恒久/一時）の扱い、苦情から抑制リストへの流れは [Resend Webhook・署名検証・バウンス処理ガイド](/blog/resend-webhooks-signature-verification-bounce-complaint-guide) に分けました。エンドポイントを守るヘッダ設計は [Next.js のセキュリティヘッダとCSP](/blog/nextjs-security-headers-csp-nonce-middleware-guide) が参考になります。

### 関門⑦：可観測性とコスト

`resend@6` のレスポンスには `headers` が含まれるので、**レート制限と消費枠をコードから読めます**。

| ヘッダ | 意味 |
|---|---|
| `ratelimit-limit` | ウィンドウ内で許可されるリクエスト数 |
| `ratelimit-remaining` | 現在のウィンドウの残り |
| `ratelimit-reset` | リセットまでの秒数 |
| `retry-after` | 次のリクエストまで待つべき秒数 |
| `x-resend-daily-quota` | 使用済みの日次送信枠（無料プランのみ送出） |
| `x-resend-monthly-quota` | 使用済みの月次送信枠 |

これらは IETF のレート制限ヘッダのドラフト第6版に準拠しています。`retry-after` を無視した固定間隔のリトライは、429を429で塗り重ねるだけなので避けてください。

アプリ側のログについては、**単一行のJSON**で出すことを強く勧めます。ホスティング環境のログビューアは複数引数の `console.*` を丸めてしまうことがあり、障害の最中に肝心の詳細が消えます。そして**フォーム送信の中身（PII）はログに出さない**——出すのは列挙値・ステータスコード・件数だけです。

```ts
// 1イベント1行。フィールドは enum / status / count に限定する。
function log(level: "info" | "warn" | "error", phase: string, fields: Record<string, unknown> = {}) {
  console[level](JSON.stringify({ route: "contact", phase, ...fields }));
}
```

データ保持は Free / Pro / Scale とも **30日**（Enterprise は柔軟）。既定では**メッセージ本文が Resend 側に保存されます**。本文保存を無効化するのは有料の追加オプション（月額$50）で、対象は**3条件すべて**を満たすチームだけです：Pro または Scale を1か月以上契約している、稼働中のWebサイトを持つドメインから送信している、3,000通以上をバウンス率5%未満で送信済み。医療・金融など機微な内容を送るなら、設計段階から前提に入れてください。

---

## トランザクションとマーケティングを分ける

同じドメインから「パスワードリセット」と「ニュースレター」を送ってはいけません。**苦情はコンテンツ単位ではなくドメイン単位で効いてくる**からです。ニュースレターの苦情率が上がると、パスワードリセットの到達率が巻き添えで落ちます。逆は起きません（誰もパスワードリセットをスパム報告しないので）。つまりこれは**一方向のリスク**であり、分離しない理由がありません。

Resend も公式に、ルートドメインではなくサブドメインからの送信を推奨しています。`account.example.com`（トランザクション）と `updates.example.com`（マーケティング）のように用途で分ければ、レピュテーションが独立します。ただし**サブドメインはそれぞれ独立したドメインオブジェクトとして追加・検証する必要がある**ため、無料プラン（独自ドメイン1つ）では両立できません。ここは有料プランを選ぶ実務上の分岐点です。

開封・クリック追跡も分離の対象です。**既定では無効**で、有効化には専用のトラッキングサブドメイン（CNAME）の検証が必要ですが、公式は「トランザクションメールでは無効のままにする」ことを勧めています。トラッキングによるリンク書き換えは認証リンクを壊す原因としても知られています（Supabase は自社ドキュメントで、リンクトラッキングは検証リンクを壊すことで知られていると明記しています）。ニュースレターだけ追跡を有効にする、というのが健全な形です。

購読管理のモデルも整理しておきます。2026年の Resend は3つの概念で構成されています。

| 概念 | 誰が制御するか | 見え方 | 役割 |
|---|---|---|---|
| **Contact** | システム | 内部 | メールアドレス単位のグローバルなエンティティ |
| **Segment** | あなた（送信者） | 内部のみ。受信者からは見えない | **誰に送るか**の絞り込み |
| **Topic** | 受信者 | 購読解除ページに表示される | **何を送るか**のラベル。受信者が種類ごとに拒否できる |

公式の言い方が的確です——「Segments はターゲティングのため、Topics は設定を守るため」。Broadcast に Topic を付けずに送ると、受信者が解除したときに**あなたからの全メールが解除されます**。Topic の既定購読（opt-in / opt-out）は**作成後に変更できない**点にも注意してください。

なお、バウンスや苦情で抑制リストに入った宛先は**チーム全体・全ドメインで送信がスキップ**されます。そして重要なのは、**抑制は自動でも、Contact の `unsubscribed` フラグは自動では立たない**ことです。そこはあなたのアプリの仕事です。

---

## コストの考え方

2026年8月6日時点の公式料金ページの値です（Scale は段が他にもあり、判断に効く段だけを抜粋しています）。**送信量（トランザクション）とコンタクト数（マーケティング）は別建て**で、それぞれ独立にプランを選べます。

| プラン | 月額 | 月間送信数 | 日次上限 | 独自ドメイン | 超過料金（1,000通あたり） |
|---|---|---|---|---|---|
| Free | $0 | 3,000 | 100 | 1 | なし |
| Pro | $20 | 50,000 | なし | 10 | $0.90 |
| Pro | $35 | 100,000 | なし | 10 | $0.90 |
| Scale | $90 | 100,000 | なし | 1,000 | $0.90 |
| Scale | $350 | 500,000 | なし | 1,000 | $0.70 |
| Scale | $650 | 1,000,000 | なし | 1,000 | $0.65 |
| Enterprise | 個別 | 個別 | なし | 柔軟 | 個別 |

**同じ100,000通/月でも Pro は$35、Scale は$90** です。差額が買っているのは送信量ではなく、Slack サポート・ドメイン1,000個・AIクレジット500・専用IPの適格性です。ここを取り違えると素直に損をします。

コストの軸は送信量だけではありません。

- **超過課金**：有料プランは1,000通単位で従量課金され、**月間枠の5倍で強制的に停止**します（上限変更はサポート経由）。チーム設定の "Transactional Overages" で明示的に有効化する必要があります。
- **Automation Runs**：全プランに10,000実行/月が含まれ、超過分は有料プランで**1実行$0.0015**。
- **AIクレジット**：Free 5 / Pro 100 / Scale 500 / Enterprise 柔軟。**繰り越し不可**で毎月リセット。
- **専用IP**：月$30のアドオン。ただし要件が厳しく、Scale プランかつ**1日3,000通超**の送信が必要です。しかも公式自身が「月9万通未満だとIPを温め続けられない」「送信が不安定だとかえってレピュテーションを損なう」「Resend は専用IPのアドレス一覧を開示しないのでIP許可リストには使えない」と、**効かないケース**を明記しています。到達率の銀の弾丸ではありません。

料金体系の注意点をもう1つ。公式の料金ページのFAQは「月額プランのみ」と書いていますが、ナレッジベースには「年額は Enterprise で提供」とあります。正しい理解は**セルフサーブは月額のみ、年額は Enterprise の営業経由**です。

SES・SendGrid・Postmark との比較、実際の送信量での損益分岐は [メール配信サービスの技術選定](/blog/resend-vs-sendgrid-ses-postmark-email-service-selection-guide) にまとめました。

---

## 本番リリース前チェックリスト

実際に私が Resend を本番に出すとき確認している項目です。

- [ ] 独自ドメインを**検証済み**（SPF の MX と TXT が `send` サブドメイン、DKIM が `resend._domainkey`、DMARC が `_dmarc`）
- [ ] **トランザクションとマーケティングで送信サブドメインを分離**し、追跡はトランザクション側で無効のまま
- [ ] APIキーは `sending_access`＋ドメイン限定を基本にし、`full_access` は必要な場所だけ
- [ ] `RESEND_API_KEY` は**サーバー環境変数のみ**。`NEXT_PUBLIC_` を付けていない
- [ ] Resend クライアントは**遅延初期化**（モジュールスコープで `new Resend(...)` していない）
- [ ] リトライしうる送信すべてに**決定的な `idempotencyKey`** を付けた
- [ ] リトライ判断を**ステータスではなくエラー名**で分岐している
- [ ] 送信失敗時にリードを失わない経路がある（送信者フォールバック、キュー、または再送導線）
- [ ] Webhook を **生ボディ**で署名検証し、`svix-id` で**重複排除**している
- [ ] Webhook は**順序保証なし**の前提で状態遷移を書いている
- [ ] バウンス・苦情イベントを受けて**自アプリ側の購読状態も更新**している
- [ ] 一括送信は**100通/リクエスト**の上限と `strict` / `permissive` の違いを把握している
- [ ] 一斉配信に `List-Unsubscribe` と `List-Unsubscribe-Post` を付け、48時間以内に停止できる
- [ ] ログは**単一行JSON**で、PIIを含まない
- [ ] レート制限（**チーム単位10 req/s**）と枠消費をヘッダで監視している
- [ ] 無料枠の「**送信＋受信の合計**」「**To/CC/BCC は宛先ごとに1通**」を理解した上で見積もった

---

## まとめ：3つの条件を1本の設計として通す

Resend は「5分で送れる」ところまでは本当に速い。本番との差は、そこから先の3つに集約されます。

- **届く**：SPF は `send` サブドメイン、DKIM は `resend._domainkey`、DMARC は `_dmarc`。トランザクションとマーケティングを別ドメインに分ける。バウンス4%・苦情0.08%の閾値を監視する。
- **追える**：Webhook を生ボディで検証し、`svix-id` で重複排除し、順序保証が無い前提で状態を進める。ログは単一行JSONでPIIを含めない。
- **二重に送らない**：決定的な `idempotencyKey` を付け、リトライ判断はエラー名で行い、409の2つの意味を取り違えない。

そしてもう1つ。**「送信に失敗した」で終わらせない経路を必ず用意してください。** 私が502を出し続けた期間に失ったのは、技術的には「1つのDNSレコードの位置」でしたが、事業的には「届いていたはずの問い合わせ」でした。リトライも、送信者フォールバックも、構造化ログも、すべてはそのために書きます。

各テーマの実コードは本クラスタの個別記事に揃えました。まずは自分のドメインの DNS を開いて、SPF レコードが apex ではなく `send` に載っているかを確認するところから始めてください。

> この記事は [Resend 公式ドキュメント](https://resend.com/docs)（Emails API / Domains / Webhooks / Segments・Topics / Pricing、2026年8月時点）と、インストール済み `resend@6.4.1` の型定義に基づき、実運用の判断軸を加えて再構成したものです。仕様・価格は更新されるため、本番採用時は各公式ページで最新値をご確認ください。
