resend.emails.send() が返す 200 { id: "..." } を「送信成功」と読んだ瞬間から、そのメール基盤は嘘をつき始めます。公式の email.sent イベントの定義そのものが、それを認めています——「API リクエストが成功したときに発火する。Resend は受信者のメールサーバへの配送を試みる」。つまり 200 は受付証であって、配達証明ではありません。
その先で何が起きたか——受信サーバが受け取ったのか(delivered)、恒久的に拒否されたのか(bounced)、届いたうえで迷惑メール報告されたのか(complained)——は、Webhook にしか流れてきません。Webhook を実装していないシステムは、メールが届いていない事実を「ユーザーからの問い合わせ」で初めて知ることになります。
私はこのポートフォリオ自身を Resend で本番運用しています(問い合わせフォーム、リードマグネット、7通のメール講座、Stripe 決済後の納品メール)。過去には Resend のドメイン認証レコードを apex に誤配置して /api/contact が 502 を返し続ける実障害を出し、DNS 修正・送信者フォールバック・リトライ・冪等キーで根治しました。送信側をいくら固めても、受信側で何が起きたかは Webhook を受けない限り一生わからない——この記事はその穴を塞ぐための実装ガイドです。
内容は Resend 公式ドキュメント(2026年8月時点)と、このリポジトリにインストールされている resend@6.4.1 の型定義・実装に基づきます。公式ドキュメント同士が食い違っている箇所は、平らに均さずそのまま提示します。全体像は Resend トランザクショナルメール本番運用ガイドを、送信側の信頼性設計は冪等性・リトライ・エラー設計ガイドを参照してください。
イベントの全体像:送信ライフサイクルを地図にする
Resend の Webhook イベントは 19種類です(2024年頃の「6〜8種類」という理解は古く、email.failed / email.scheduled / email.received / email.suppressed / suppression.* などが増えています)。まず送信ライフサイクル上の位置関係を掴んでください。
resend.emails.send() → 200 { id } ← ここまでが「API が受け付けた」
|
v
email.scheduled (scheduledAt を指定したときのみ)
|
v
email.sent ────────────────────────────┐
| |
| Resend → 受信側メールサーバ |
v v
email.delivered email.failed
| (送信自体が失敗:クォータ超過、
| APIキー不正、ドメイン未検証など)
+--> email.delivery_delayed --(再試行)--> delivered / bounced
|
+--> email.bounced (受信側が恒久的に拒否=ハードバウンス)
|
+--> email.complained (届いたが「迷惑メール」として報告された)
|
+--> email.opened / email.clicked (開封・クリックの計測)
email.suppressed 抑制リスト上の宛先への送信が止められた
suppression.added / removed 抑制リストの増減そのもの
業務上どう使うかまで含めた対応表です。
| イベント | 公式の定義(要約) | 業務での使い方 |
|---|---|---|
email.sent | API リクエストが成功し、Resend が配送を試みる | 送信ログの起点。ここで「成功」と表示しない |
email.delivered | 受信者のメールサーバへの配送に成功した | 監査ログ・カスタマーサポートの根拠 |
email.delivery_delayed | 一時的な問題で配送できなかった(受信箱満杯、受信サーバの一過性障害など) | 即アラートせず観測。連続したら宛先を疑う |
email.bounced | 受信者のメールサーバが恒久的に拒否した | ハードバウンスなら送信停止。bounce オブジェクトを保存 |
email.complained | 配送には成功したが受信者が迷惑メール報告した | 即座に恒久停止。理由フィールドは存在しない |
email.opened | 受信者がメールを開封した | 参考値。公式も「開封率は常に正確とは限らない」と明記 |
email.clicked | 受信者がリンクをクリックした | click.link で導線分析。click.ipAddress は個人情報 |
email.failed | エラーで送信自体に失敗した(宛先不正、APIキー、ドメイン未検証、クォータなど) | 運用アラート対象。failed.reason を通知に載せる |
email.scheduled | 送信予約が入った | 予約送信の可視化 |
email.suppressed | Resend が抑制リストを理由に送信を止めた | 自前の抑制リストとの同期ズレの検知 |
email.received | Resend がメールを受信した(Inbound) | 受信処理の起点。本文は別 API で取得 |
contact.created / updated / deleted | コンタクトの増減・更新 | 自社 DB とのミラーリング |
domain.created / updated / deleted | ドメインの状態変化 | DNS 検証状況の監視 |
suppression.added / removed | 抑制リストへの追加・削除 | 自前の送信可否テーブルの唯一の入口 |
ペイロードの共通構造
全19イベントが同じ封筒に入っています。最上位は type(イベント種別)・created_at(ISO 8601)・data(イベント固有のデータ)の3キーだけです。
ここに罠が2つあります。ひとつ、created_at は最上位と data の中の両方に存在し、意味が違います。最上位は「Webhook イベントが作られた時刻」、data.created_at は「メールやコンタクトが作られた時刻」で、公式サンプルでも232ミリ秒ずれています。ふたつ、JSON ボディの中に id フィールドはありません。配信ごとの一意 ID は svix-id ヘッダで、冪等化のキーはここから取ります。
email.received(Inbound)だけはさらに例外で、公式が明示しています——「Webhook には本文・ヘッダ・添付は含まれず、メタデータのみ」。本文が要るなら受信メール取得 API を別途呼びます。サーバーレス環境のリクエストボディ上限を考えれば妥当な設計です。
署名検証:ここを飛ばすと誰でも「バウンスした」と偽装できる
Webhook エンドポイントは認証なしで公開された POST 口です。署名を検証しないということは、URL を知っている人が誰でも次のような JSON を投げ込めるということです。
{
"type": "email.bounced",
"created_at": "2026-08-06T00:00:00.000Z",
"data": {
"to": ["important-customer@example.com"],
"bounce": { "type": "Permanent", "subType": "General", "message": "..." }
}
}
真面目に作った実装ほど危険です。「ハードバウンスなら二度と送らない」という正しい運用ロジックが、そのまま正規顧客への配信を恒久停止させる攻撃面になります。請求書も認証メールも届かなくなります。
署名の仕組み
Resend は svix 方式の3ヘッダで署名を渡します。
svix-id 配信ごとに一意な ID(例: msg_p5jXN8AQM9LWM0D4loKWxJek)
svix-timestamp UNIX 秒(文字列)
svix-signature v1,<base64> ※ローテーション中は空白区切りで複数入りうる
署名鍵は whsec_ で始まる署名シークレットです。Webhook 詳細ページで確認でき、作成・取得 API のレスポンスにも signing_secret として含まれます(署名検証ページは一覧 API も返すと書いていますが、一覧のレスポンスサンプルには含まれていません——ここもドキュメント内で記述が食い違っています)。
実際の計算は svix のライブラリ実装から読み取れます。これは Resend の公式ドキュメントに書かれた仕様ではなく、ライブラリ側の事実として扱ってください。
- アルゴリズムは HMAC-SHA256
- 鍵は
whsec_を剥がした残りを base64 デコードしたバイト列 - 署名対象の文字列は
{svix-id}.{svix-timestamp}.{生ボディ} - 出力を標準 base64 にして
v1,を前置 - 比較は定数時間
- タイムスタンプ許容差は 300秒(5分)で、古すぎても未来すぎても拒否する双方向のチェック
最後の300秒は、Resend の公式ページには一度も書かれていません(svix 側も「あなたの許容範囲内か確認せよ」としか書いていない)。ライブラリのソース定数から取った値です。将来変わりうる前提で扱ってください。
このタイムスタンプ検査が、盗聴された正規リクエストをそのまま投げ直すリプレイ攻撃を5分の窓に閉じ込めます。ただし5分以内のリプレイは署名的には有効なので、リプレイ耐性の本体は次章の svix-id による重複排除です。署名検証だけでは足りません。
SDK v6.4.1 での正しい呼び出し
resend@6.4.1 は svix@1.76.1 を依存に含んでおり、自分で svix を入れる必要はありません。実装は薄いラッパーです。
// node_modules/resend/dist/index.js(v6.4.1)の実体
verify(payload) {
const webhook = new Webhook(payload.webhookSecret);
return webhook.verify(payload.payload, {
"svix-id": payload.headers.id,
"svix-timestamp": payload.headers.timestamp,
"svix-signature": payload.headers.signature,
});
}
ここから確定する呼び出し規約が3つあります。
verify()は同期関数。awaitしない。- 失敗時は throw する。Resend SDK の他の API と違い
{ data, error }ではないので、try/catchが必須。 payloadは生のリクエストボディ文字列。JSON.parse済みのオブジェクトを渡すと通らない。
内部実装はバージョンで変わる点に注意してください。svix 同梱は 6.4.1 の事実で、より新しい 6.18.1 は standardwebhooks に置き換わっています。署名方式は同じ(Standard Webhooks 系)で、Resend が実際に送ってくるヘッダ名も svix-id / svix-timestamp / svix-signature のままなので、上の呼び出し方はどちらのバージョンでも通ります。逆に、リクエストから webhook-id を読もうとする実装は誤りです。
3つ目は公式も強い調子で警告しています——「Webhook を検証するときは必ず生のリクエストボディを使ってください。暗号署名はごくわずかな変化にも反応します。フレームワークによってはリクエストを JSON としてパースしてから文字列化しますが、これも署名検証を壊します」。
にもかかわらず、公式ドキュメントの別ページ(受信メールの FAQ)には payload: JSON.stringify(req.body) と書かれたサンプルが載っています。まさに警告されているアンチパターンそのものです。迷ったら常に await request.text() が正解です。
Next.js Route Handler での実装
公式の Next.js サンプルは、そのままではコンパイルも動作もしません。req.headers['svix-id'] と書かれていますが、App Router の NextRequest の headers は Headers インスタンスなので undefined になります。以下はその修正を入れた実装です(送信側の Route Handler の書き方は Next.js App Router での Resend 実装ガイドにまとめています)。
// app/api/resend-webhook/route.ts
import { Resend } from "resend";
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
export const dynamic = "force-dynamic";
/** 応答が遅いと再試行を招く。ハングさせず早めに失敗させる。 */
export const maxDuration = 15;
let client: Resend | null = null;
/** モジュールスコープで new Resend() すると next build を壊すので遅延生成する。 */
const getResend = (): Resend => (client ??= new Resend(process.env.RESEND_API_KEY));
/**
* v6.4.1 の verify() の戻り値は型定義上 `unknown`(上流の最新版では判別可能
* ユニオンが返る)。「署名が正しい」ことと「中身が期待した形」は別問題なので、
* インストール済みのバージョンに依存せず境界で必ず narrow する。
*/
const webhookEnvelope = z.object({
type: z.string().min(1),
created_at: z.string().min(1),
data: z.record(z.string(), z.unknown()),
});
type WebhookEnvelope = z.infer<typeof webhookEnvelope>;
export async function POST(request: NextRequest) {
const secret = process.env.RESEND_WEBHOOK_SECRET;
if (!secret) {
console.error(JSON.stringify({ route: "resend-webhook", phase: "not_configured" }));
return NextResponse.json({ error: "not configured" }, { status: 503 });
}
// 署名は「送られてきたバイト列そのもの」に対して計算されている。
// request.json() を先に呼ぶと再シリアライズで壊れる。生ボディが先。
const raw = await request.text();
const id = request.headers.get("svix-id");
const timestamp = request.headers.get("svix-timestamp");
const signature = request.headers.get("svix-signature");
if (!id || !timestamp || !signature) {
return NextResponse.json({ error: "missing signature headers" }, { status: 400 });
}
let event: unknown;
try {
// verify() は同期・throw ベース。{ data, error } ではない。
event = getResend().webhooks.verify({
payload: raw,
headers: { id, timestamp, signature },
webhookSecret: secret,
});
} catch {
// 署名不一致・タイムスタンプ超過はすべてここに落ちる。理由は出さない。
console.warn(JSON.stringify({ route: "resend-webhook", phase: "bad_signature" }));
return NextResponse.json({ error: "invalid signature" }, { status: 400 });
}
const parsed = webhookEnvelope.safeParse(event);
if (!parsed.success) {
// 署名は正しいのに形が違う=未知のイベント種別が増えた可能性。
// 400 を返すと再試行ループに入るだけなので、記録して 200 で受ける。
console.warn(JSON.stringify({ route: "resend-webhook", phase: "unknown_shape" }));
return NextResponse.json({ received: true }, { status: 200 });
}
return handleEvent({ svixId: id, event: parsed.data });
}
このリポジトリの app/api/stripe-webhook/route.ts も同じ骨格で本番稼働しています(そちらは DB を持たないので、冪等化はイベント ID を Resend の idempotencyKey に流す形で実現しています)。「生ボディを最初に読む → 署名検証 → パース → 冪等化 → 200 で早く返す」という順序は、決済でもメールでも変わりません。決済側の詳細は Stripe 決済の本番運用ガイドにまとめています。
なお、リクエストボディが途中で書き換えられると署名は壊れます。Next.js のミドルウェア(proxy.ts)の matcher から /api を除外しておくのは、実務上ほぼ必須の前提条件です。
補助的な防御:送信元 IP
サーバ側で IP 制限をかけるなら、公式が提示しているアドレスを使えます。
44.228.126.217
50.112.21.217
52.24.126.164
54.148.139.208
2600:1f24:64:8000::/52
ただしこれは署名検証の代替にはなりません。IP は変更されうるので、追加の防御層としてのみ使い、必ず最新値を公式ページで確認してください。
冪等な受信:at-least-once と順序保証なしを前提に組む
公式は配信保証を明言しています——「Resend の Webhook は at-least-once 配信です。すべてのイベントは少なくとも1回配信されますが、まれに複数回配信されることがあります(サーバ側が処理したのに応答が失われたネットワークタイムアウトなど)」。
対処法も公式が指定しています——「重複を扱うには、すべての Webhook リクエストに含まれる svix-id ヘッダを使ってください。これは配信ごとに一意な識別子です。処理済みの svix-id を保存し、重複はスキップしてください」。
順序についても明言があります——「イベントは発生順に送られますが、配信順序は保証されません。同じメールの email.opened が email.delivered より先に届くこともあります。順序が重要な場合は、受信後にペイロードの created_at タイムスタンプで並べ替えてください」。
つまり実装の要件はこの2つに尽きます。
/**
* svix-id の一意制約だけで冪等になる。アプリ側でロックを取らないのが肝で、
* 「INSERT できた側だけが処理する」ので同時到達しても二重処理しない。
*/
async function handleEvent(args: { svixId: string; event: WebhookEnvelope }) {
const claimed = await db.query(
`insert into resend_webhook_events (svix_id, event_type, event_created_at)
values ($1, $2, $3)
on conflict (svix_id) do nothing
returning svix_id`,
[args.svixId, args.event.type, args.event.created_at],
);
// 再送。処理はせず 200 を返して再試行を止める。
if (claimed.rowCount === 0) {
return NextResponse.json({ received: true, duplicate: true }, { status: 200 });
}
// ... 種別ごとの処理(副作用そのものも冪等に書く)
return NextResponse.json({ received: true }, { status: 200 });
}
私は決済基盤でも同じ設計(イベント ID を一意キーにした重複排除)を取り、本番での二重課金0件を達成しています。メールでも同じで、「ハードバウンスなので抑制リストに入れる」処理が2回走っても結果が変わらないよう、副作用そのものも冪等に書いてください(insert ... on conflict do nothing / update の絶対値代入)。
順序保証がないことへの実務的な対処は、状態機械を作らないことです。「delivered を受けてから opened を受ける」という前提のコードは壊れます。各イベントを独立した事実として追記し、必要なときに created_at で並べて解釈してください。
再試行ポリシー:3つの公式ページが違うことを言っている
成功のシグナルは明快です——「イベントを受け取ったら HTTP 200 OK を返して、正常に配信されたことを Resend に知らせてください」。
問題は再試行スケジュールで、公式3ページの記述が一致していません。
| 出典 | 記述 |
|---|---|
| Retries and Replays(詳細ページ) | 即時、5秒、5分、30分、2時間、5時間、10時間、さらに10時間の8段階 |
| Webhooks Introduction の FAQ | 5秒、5分、30分、2時間、5時間、10時間の6段階 |
| How to Store Webhooks Data の FAQ | 「失敗した Webhook 配信を最大24時間まで自動的に再試行する」 |
この記事では、最も詳細な Retries and Replays を基準とします。8段階を足すと再試行ウィンドウはおよそ 27時間35分5秒です。同ページには具体例もあります——「3回失敗してから成功した配信は、最初の試行からおよそ35分5秒後に配信される」。
いずれにせよ設計上の結論は変わりません。「丸1日程度は再送が来る」前提でエンドポイントの冪等性を保つことです。正確な段数に依存した実装を書かないでください。
成功と判定されるステータスコードにも曖昧さがあります。introduction は「200 を受け取れなければ再試行する」と書き、保存ガイドは「エンドポイントが 5xx を返したら再試行する」と書いています。201 や 204 が成功扱いになるかを明記した公式ページは存在しません。素直に 200 を返してください。エンドポイントの応答タイムアウト値も、公式には記載がありません。
失敗が続くとどうなるか
これは2024年時点の知識には無い挙動です。公式の記述——「Webhook エンドポイントがイベントの受信に失敗し始めると、Resend はチームにメール通知を送ります。メールにはエンドポイント URL、最後に失敗した時刻、最後の HTTP レスポンスステータスコードが含まれます。エンドポイントが失敗し続けると、Resend は最終的に自動的に無効化し、2通目の通知を送ります。エンドポイントが復旧したら、ダッシュボードの Webhooks ページから再有効化できます」。
無効化のしきい値(何回失敗すると、どの時間窓で)は公表されていません。復旧後は手動での再有効化と、ダッシュボードからの手動リプレイ(失敗・成功どちらのメッセージもリプレイ可能)で穴を埋めます。公式が挙げるリプレイの用途は「エンドポイント障害後のバックフィル」「ハンドラを直してからの再処理」「テスト用に別エンドポイントへ送る」です。
早く 200 を返す
再試行と自動無効化を避ける最大の実務ポイントは、重い処理をリクエスト内で完結させないことです。受信ハンドラの中で外部 API を叩き、テンプレートをレンダリングし、Slack に通知する——どれか1つでも遅い、あるいは落ちると 200 が返らず、再送とタイムアウトを招きます。ハンドラの責務は「検証 → 冪等クレーム → 追記 → 即 200」までに限定し、副作用はその外へ出してください。
Vercel なら waitUntil でレスポンス後にバックグラウンド処理を継続できます(Vercel Functions の実行モデル)。キューを持っているならジョブを積むだけにしてください。エンドポイント自体にレート制限をかける場合は、Resend の再送を弾いてしまわない設計が必要です(サーバーレスのレート制限)。
バウンスと苦情の運用:ここが Webhook を実装する本当の理由
バウンス種別の正しい読み方
email.bounced の data.bounce は type(バウンス種別)・subType(サブ種別)・message(受信サーバからの詳細メッセージ)を持ちます。SMTP 診断応答の配列 diagnosticCode は OpenAPI 仕様では必須フィールドですが、公式の JSON サンプルにも SDK v6.4.1 の型にも現れません。受け取る側では任意扱いにしておくのが安全です。
type の列挙は、バウンス一覧ページによれば3種類です。
type | 通称 | 意味 | サブ種別の例 |
|---|---|---|---|
Permanent | ハードバウンス | 受信サーバが拒否し、二度と配送されない | General、NoEmail |
Transient | ソフトバウンス | 拒否されたが将来配送される可能性がある | General、MailboxFull、MessageTooLarge、ContentRejected、AttachmentRejected |
Undetermined | 判別不能 | バウンスしたが理由を判別できる情報がなかった | Undetermined |
ここで公式内の食い違いを2つ明示しておきます。
- イベントページは
typeの例を「Permanent,Temporary」と書いていますが、バウンス一覧ページの列挙はTransientです。これは Resend が公開している機械可読な OpenAPI 仕様で決着がついていて、WebhookEventBounce.typeの enum はUndetermined/Transient/Permanentの3値です。Temporaryは来ません(イベントページの例が古いだけ)。ただし実装方針は変わりません——Permanent以外は一括で「恒久ではない」と扱ってください。 subTypeは閉じた列挙ではありません。webhook 側のサンプルに出てくるSuppressedとMessageRejectedは、一覧ページの列挙に含まれていません。
したがって、判定ロジックは次の形以外に安全な書き方がありません。
interface BounceData {
readonly bounce?: { readonly type?: string; readonly subType?: string; readonly message?: string };
}
/**
* 恒久バウンスの判定は type === "Permanent" のみで行う。
* subType は閉じた列挙ではない(公式サンプルにある Suppressed / MessageRejected は
* バウンス一覧ページの列挙に載っていない)ため、分岐の条件に使わない。
* 未知の値はすべて「恒久ではない」側に倒す(誤って正規宛先を止めない)。
*/
function isHardBounce(data: BounceData): boolean {
return data.bounce?.type === "Permanent";
}
公式には運用に直結する注記もあります——「受信箱がオートレスポンダでバウンスを知らせることがあります。transient ステータスはオートレスポンダ由来で恒久的な問題ではない可能性があります」。ソフトバウンス1回で宛先を切らない理由がここにあります。
苦情(complained)の扱い
email.complained は追加フィールドを一切持ちません。形は email.sent と同一です。苦情の種類も、理由コードも、フィードバックループの型も、ドキュメントにも SDK の型にも存在しません。ここは誤解が非常に多い箇所です。
理由がわからない以上、取れる行動は1つだけです。二度と送らない。
もうひとつ、公式が明記している重要な限界があります——「すべての受信事業者が complained イベントを返すわけではありません。特に Gmail / Google Workspace は返しません」。つまり苦情イベントは「届いたら止める」ためのシグナルであって、苦情の総量を測る指標にはなりません。「苦情が0件だから健全」とは読まないでください。
アカウント側の制約も効いてきます。バウンス率は4%未満、スパム率は0.08%未満を維持する必要があり、超えると送信が一時停止される可能性があると公式に明記されています。苦情は率で効くので、1件も無駄にできません。購読管理の設計は一括送信・予約送信・購読管理ガイドにまとめています。
抑制リスト(Suppressions)
Resend 側にもアカウント単位の抑制リストがあり、ハードバウンスやスパム苦情のあとに自動的に追加されます(手動追加も可能)。その増減が suppression.added / suppression.removed として飛んできます。
| フィールド | 型 | 内容 |
|---|---|---|
id | string | 抑制レコードの ID |
email | string | 抑制されたメールアドレス |
origin | bounce / complaint / manual | 抑制された経緯(3値の閉じた列挙) |
source_id | string または null | きっかけになったメールの ID。manual のときは null |
created_at | string | 抑制が作られた ISO 8601 時刻 |
抑制リスト上の宛先へ送ろうとすると email.suppressed が飛びます。サンプルのメッセージには運用上重要な一文があります——「これはバウンス率の指標にはカウントされない」。つまり、抑制リストを効かせておくことは到達率指標を守る行為でもあります。
ここで正直な注記を1つ。SDK v6.4.1 の Resend クラスには suppressions リソースがありません(型定義で確認済み。持っているのは apiKeys / segments / audiences(非推奨)/ batch / broadcasts / contacts / contactProperties / domains / emails / webhooks / templates / topics です)。REST API 側には Suppressions のエンドポイントが存在するため、SDK 経由で触るなら汎用メソッドを使うことになります。
SDK には resend.get / resend.post / resend.put / resend.patch / resend.delete という汎用メソッドが public で用意されているので、未対応エンドポイントはこれで叩けます。ただしエンドポイントのパス・パラメータ・レスポンス形はこの記事では断定しません。公式の Suppressions ページで最新値を確認してください。
実務上の推奨は、自前の送信可否テーブルを唯一の入口にすることです。Resend 側の抑制リストは最後の安全網として残しつつ、送信前のチェックは自分の DB で行います。これなら Resend の API 仕様変更に引きずられませんし、「なぜこの宛先に送っていないのか」を自分たちのデータで説明できます。
/** 送信の直前に必ず通す関門。理由を保持するのは説明責任のため。 */
async function canSend(email: string): Promise<boolean> {
const row = await db.query(
`select 1 from email_suppressions where email = $1 limit 1`,
[email.trim().toLowerCase()],
);
return row.rowCount === 0;
}
到達率そのものを底上げする話(SPF/DKIM/DMARC)はドメイン認証と到達率ガイドを参照してください。バウンスの半分はドメイン設定で防げます。
保存設計:何を保存し、何を保存しないか
保存が必要な理由を、公式は明快に説明しています——「これらのイベントは価値あるデータですが、Webhook は既定では一時的(ephemeral)です」「Resend は全プランでメールデータを 30日間保持します(Enterprise は柔軟な保持期間)。その期間を超えて履歴データにアクセスする必要があるなら、自分のデータベースにイベントを保存することで重要な情報を失わずに済みます」。
コンプライアンス観点も挙げられています。GDPR(いつ誰に何を送ったかの証明)、SOC 2(メール配信の検証が監査要件に含まれうる)、金融規制(取引関連メールを数年保持)。
最小限保存すべき4項目(公式)
- Event ID — 重複排除用の一意な
svix-id - Event type — 何が起きたか(delivered、bounced、opened など)
- Timestamp — イベントが発生した時刻
- Email ID — 元の送信に紐づけるための ID
分析用に任意で足せるものとして、宛先アドレス・件名・タグ・バウンス詳細・クリック URL が挙げられています。
保存してはいけないもの・注意すべきもの
公式の警告——「Webhook データは個人情報(メールアドレス、開封/クリック由来の IP アドレス)を含む可能性があります」。
- メール本文は Webhook に入ってきません(Inbound も同様でメタデータのみ)。わざわざ別 API で取得して保存しないでください。必要がないなら持たないのが最も安全です。
click.ipAddressは個人情報です。クリック分析に本当に必要でなければ、保存前に落としてください。- ペイロードを丸ごと
jsonbに入れるなら、そこに宛先アドレスと件名が入る前提で保持期間とアクセス制御を設計してください。
テーブル例
-- 受信ログ:svix_id を主キーにするだけで冪等性が成立する
create table resend_webhook_events (
svix_id text primary key,
event_type text not null,
event_created_at timestamptz not null, -- ペイロードの created_at(順序復元用)
received_at timestamptz not null default now(),
email_id uuid, -- 元の送信への紐づけ
payload jsonb -- 保存するなら PII を含む前提で扱う
);
create index resend_webhook_events_email_idx on resend_webhook_events (email_id);
create index resend_webhook_events_type_time_idx
on resend_webhook_events (event_type, event_created_at desc);
-- 送信可否:アプリはこのテーブルだけを見て送信を止める
create table email_suppressions (
email text primary key, -- 小文字に正規化して保存する
reason text not null
check (reason in ('bounce', 'complaint', 'manual')),
source_id uuid, -- きっかけになったメール ID
created_at timestamptz not null default now()
);
規模感の見積もりも公式が出しています——「月に 10,000通送ると、平均的なエンゲージメントで 月30,000〜50,000イベントが見込まれます。1イベントはおおむね 1〜2 KB なので、生データで月50〜100 MB程度です」。この規模ならリレーショナル DB で十分で、専用の分析基盤は不要です。
保持期間の指針も示されています。運用用途なら30〜90日で足りることが多く、コンプライアンス要件は業界規制次第(1〜7年になることも)、履歴分析は生イベントを持ち続けるより集計に落とすことを検討する、という3段構えです。
-- 90日より古い生イベントを落とす(集計テーブルへ落としてから実行する)
delete from resend_webhook_events
where event_created_at < now() - interval '90 days';
ローカルでの検証手順
1. Resend CLI で本物のイベントを流す
公式 CLI の resend webhooks listen は「開発中にローカルで Webhook イベントを受け取る。サーバを起動し、一時的な Webhook を登録し、イベントをストリームし、終了時に後片付けする」コマンドです。
# --forward-to は「元の Svix ヘッダを保ったまま」ローカルへ転送する。
# つまり署名検証まで含めた通しの動作確認ができる。
resend webhooks listen \
--url https://hostname.tailnet-name.ts.net \
--events email.bounced \
--forward-to http://localhost:3000/api/resend-webhook
--url(公開 URL)は必須、--events は省略時 all、--port は既定 4318 です。公開 URL の作り方としては、公式は ngrok や VS Code のポートフォワーディングも案内しています。
2. 署名生成を再現してユニットテストにする
CLI は手元では便利ですが、CI では回せません。署名の仕様が分かっているので、テスト側で正しい署名を作れます。
import { createHmac } from "node:crypto";
/**
* テスト用の署名生成。仕様は svix のライブラリ実装から取ったもので、
* Resend 公式ドキュメントに明記された契約ではない点に注意。
* 署名対象は `${id}.${timestamp}.${生ボディ}`、鍵は whsec_ を剥がして base64 デコード。
*/
export function signResendWebhook(args: {
secret: string; // "whsec_..."
id: string; // svix-id
timestamp: string; // UNIX 秒(文字列)
body: string; // 生ボディ(テストでも JSON.stringify の結果を使い回す)
}): string {
const key = Buffer.from(args.secret.replace(/^whsec_/, ""), "base64");
const digest = createHmac("sha256", key)
.update(`${args.id}.${args.timestamp}.${args.body}`, "utf8")
.digest("base64");
return `v1,${digest}`;
}
このヘルパで、少なくとも次の4本は書けます。
- 正しい署名 → 200 を返す
- 署名を1文字変える → 400 を返し、副作用が起きていない
- タイムスタンプを10分前にする → 400 を返す(許容差は5分)
- 同じ
svix-idで2回投げる → 副作用は1回だけ
3本目を書くときは、許容差が5分なので現在時刻から生成すること。固定タイムスタンプを埋め込んだテストは、書いた5分後から永遠に落ち続けます。
3. ダッシュボードからリプレイする
ハンドラを修正したあとの検証には、公式のリプレイ機能が使えます。Webhooks ページ → 対象のエンドポイント → 対象のメッセージ → Replay ボタン、の4ステップで、失敗したメッセージも成功したメッセージもリプレイできます。
なお email.scheduled / email.suppressed / suppression.added / suppression.removed は、SDK v6.4.1 の WebhookEvent 型に含まれていません。SDK の webhooks.create() でこれらを購読しようとすると型エラーになる可能性があるので、その場合はダッシュボード・CLI・REST API から登録してください。ドキュメント側には19種類すべてが載っています。
本番投入前チェックリスト
- 生ボディを
await request.text()で読んでいる(request.json()を先に呼んでいない) - ミドルウェアの matcher から
/apiを除外し、ボディが改変されない経路になっている -
resend.webhooks.verify()をtry/catchで囲んでいる(同期・throw ベース) -
verify()の戻り値を Zod などで narrow してから使っている -
svix-idを一意キーにした重複排除があり、副作用そのものも冪等 - 未知のイベント種別を 400 ではなく 200 で受け、記録している
- 重い処理をハンドラ内で完結させず、早く 200 を返している
- ハードバウンス判定が
type === "Permanent"のみで、subTypeに依存していない -
email.complainedを受けたら恒久的に送信を止めている - 送信前チェックが自前の抑制テーブルを見ていて、
suppression.*で同期している - 保存項目に
svix-id/ イベント種別 / 時刻 /email_idが含まれている -
click.ipAddressの保存要否を判断し、保持期間とアクセス制御を決めている - 署名検証のユニットテストが CI で回っている(成功・改竄・期限切れ・重複)
- Webhook 失敗通知メールの宛先チームを確認している(自動無効化に気づける)
まとめ
Webhook は「あると便利な通知機能」ではありません。送信 API の 200 と実際の到達のあいだにある断絶を埋める、唯一の情報源です。実装で外してはいけない点は5つに集約されます。
- 生ボディで署名検証する。 ここを飛ばすと、正しく作った抑制ロジックがそのまま攻撃面になる
svix-idで冪等化する。 at-least-once かつ順序保証なしが公式の契約- 早く 200 を返す。 遅い応答は再試行を招き、失敗が続けばエンドポイントは自動的に無効化される
type === "Permanent"だけでハードバウンスを判定する。subTypeは閉じた列挙ではない- 苦情は理由が取れない。 止める以外の選択肢は無い
そして、この記事で繰り返し書いたとおり、Resend の公式ドキュメントは複数箇所で自己矛盾しています(再試行の段数、バウンス種別の名称、署名検証サンプルの生ボディ扱い、SDK と型定義の差分)。ドキュメントを疑うのではなく、曖昧な箇所に依存しない実装にするのが正解です。この記事の実装が Permanent 以外を一括で「恒久ではない」に倒し、再試行の段数に依存せず、未知の形を 200 で受けるのは、すべてその方針の帰結です。
まずは既存の送信基盤に対して、email.bounced と email.complained の2つだけを購読するエンドポイントを1本立てるところから始めてください。それだけで、いま自分たちが誰に届いていないのかが見えるようになります。
この記事は Resend 公式ドキュメント(Webhooks / Event Types / Verify Webhooks Requests / Retries and Replays / Email Bounces / How to Store Webhooks Data / CLI、2026年8月時点)と、インストール済みの
resend@6.4.1の型定義・実装に基づき、実運用の判断軸を加えて再構成したものです。署名の許容差5分など一部の数値は svix のライブラリ実装から読み取ったもので、Resend が文書化した契約ではありません。仕様は更新されるため、本番採用時は各公式ページで最新値をご確認ください。