Chuyển đến nội dung chính
友田 陽大
Resend & transactional email
Resend
Webhook
冪等性
信頼性
Next.js
TypeScript
メール配信
到達率

Resend Webhook 実装ガイド:署名検証・冪等な受信・バウンス/苦情の運用設計

Resend 公式ドキュメント(2026年8月時点)と SDK v6.4.1 の実装に忠実な Webhook 実装ガイド。19種類のイベント、svix 署名検証の正しい書き方、svix-id による冪等な受信、再試行ポリシー、ハードバウンスと苦情の運用、保存設計までを本番で動く TypeScript で解説します。

Published
Reading time
28 min read
Author
友田 陽大
Chia sẻ

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.sentAPI リクエストが成功し、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.suppressedResend が抑制リストを理由に送信を止めた自前の抑制リストとの同期ズレの検知
email.receivedResend がメールを受信した(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.1svix@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つあります。

  1. verify() は同期関数await しない。
  2. 失敗時は throw する。Resend SDK の他の API と違い { data, error } ではないので、try/catch が必須。
  3. payload は生のリクエストボディ文字列JSON.parse 済みのオブジェクトを渡すと通らない。

内部実装はバージョンで変わる点に注意してください。svix 同梱は 6.4.1 の事実で、より新しい 6.18.1standardwebhooks に置き換わっています。署名方式は同じ(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 の NextRequestheadersHeaders インスタンスなので 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.openedemail.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 の FAQ5秒、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.bounceddata.bouncetype(バウンス種別)・subType(サブ種別)・message(受信サーバからの詳細メッセージ)を持ちます。SMTP 診断応答の配列 diagnosticCode は OpenAPI 仕様では必須フィールドですが、公式の JSON サンプルにも SDK v6.4.1 の型にも現れません。受け取る側では任意扱いにしておくのが安全です。

type の列挙は、バウンス一覧ページによれば3種類です。

type通称意味サブ種別の例
Permanentハードバウンス受信サーバが拒否し、二度と配送されないGeneralNoEmail
Transientソフトバウンス拒否されたが将来配送される可能性があるGeneralMailboxFullMessageTooLargeContentRejectedAttachmentRejected
Undetermined判別不能バウンスしたが理由を判別できる情報がなかったUndetermined

ここで公式内の食い違いを2つ明示しておきます。

  1. イベントページは type の例を「Permanent, Temporary」と書いていますが、バウンス一覧ページの列挙は Transient です。これは Resend が公開している機械可読な OpenAPI 仕様で決着がついていて、WebhookEventBounce.type の enum は Undetermined / Transient / Permanent の3値です。Temporary は来ません(イベントページの例が古いだけ)。ただし実装方針は変わりません——Permanent 以外は一括で「恒久ではない」と扱ってください。
  2. subType は閉じた列挙ではありません。webhook 側のサンプルに出てくる SuppressedMessageRejected は、一覧ページの列挙に含まれていません。

したがって、判定ロジックは次の形以外に安全な書き方がありません。

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 として飛んできます。

フィールド内容
idstring抑制レコードの ID
emailstring抑制されたメールアドレス
originbounce / complaint / manual抑制された経緯(3値の閉じた列挙)
source_idstring または nullきっかけになったメールの ID。manual のときは null
created_atstring抑制が作られた 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.bouncedemail.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 が文書化した契約ではありません。仕様は更新されるため、本番採用時は各公式ページで最新値をご確認ください。

Câu hỏi thường gặp

Resend の Webhook で署名検証は必須ですか?
必須です。Webhook エンドポイントは認証なしの公開 POST 口なので、署名を検証しない限り、URL を知る第三者が「この宛先はバウンスした」「苦情が来た」という偽イベントを投げ込めます。その結果として正規の顧客が抑制リストに入り、請求書や認証メールが届かなくなります。Resend は svix-id / svix-timestamp / svix-signature の3ヘッダで署名を渡しており、SDK v6.4.1 の resend.webhooks.verify() がそのまま検証してくれます。
同じイベントが2回届くのはなぜですか?
Resend の Webhook 配信は公式に at-least-once と明記されているためです。ネットワークタイムアウトなどでサーバ側は処理済みなのに応答が届かなかった場合、同じイベントが再送されます。公式の対処法も明示されていて、リクエストごとに一意な svix-id ヘッダを保存し、既に見た ID をスキップします。加えて配信順序も保証されないため、順序が必要ならペイロードの created_at で並べ替えてください。
200 を返せなかったときはどうなりますか?
再試行されます。詳細ページ(Retries and Replays)のスケジュールは、即時・5秒・5分・30分・2時間・5時間・10時間・さらに10時間の8段階で、合計およそ27時間35分5秒の再試行ウィンドウになります。ただし introduction の FAQ は6段階、保存ガイドは「最大24時間」と書いており、公式3ページで数値が一致しません。失敗が続くとチームにメール通知が飛び、最終的にエンドポイントは自動的に無効化されます。
ハードバウンスとソフトバウンスはどう見分けますか?
data.bounce.type で判定します。Permanent がハードバウンス(恒久的な拒否)、Transient がソフトバウンス(一時的な拒否で将来届く可能性あり)、Undetermined は理由を判別できなかったものです。subType(MailboxFull や MessageTooLarge など)は補助情報として扱ってください。webhook のサンプルに出てくる Suppressed や MessageRejected はバウンス一覧ページの列挙に含まれておらず、閉じた列挙ではないためです。
苦情(email.complained)が来たらどうすべきですか?
その宛先へのマーケティングメールを即座に、恒久的に止めてください。complained は「配信には成功したが受信者が迷惑メールとして報告した」イベントで、公式のペイロードには理由を示す追加フィールドが一切ありません。つまり原因を問い合わせる術はなく、送信を止める以外の選択肢がありません。Resend のアカウント制限ではスパム率0.08%超で送信が一時停止される可能性があると明記されています。
ローカルでどうやって Webhook をテストしますか?
Resend CLI の resend webhooks listen が最短です。一時的な Webhook を登録してイベントをストリームし、終了時に後片付けまで行います。--forward-to を付けるとローカルサーバへ転送され、そのとき元の Svix ヘッダが保持されるので署名検証まで通しで確認できます。加えて、署名生成をテストコード側で再現したユニットテストを書けば、CI で検証ロジックの回帰を防げます。

Tài liệu tham khảo

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

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.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

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

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

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

Cũng đáng đọc