前提と注意事項
この記事では以下を前提としています。
- Next.js 16.4.2 を使用しています(App Router、Vercel 上で動作)。
- Resend の受信 Webhook(
email.received)を Route Handler(POST /api/webhooks/resend/inbound)で受け取ります。 - 署名の検証には、
svixのWebhook.verify()や Resend の SDK を使わず、Node.js 標準のnode:cryptoだけを使います。 - 添付ファイルの取得ジョブは、1分ごとに動く cron でも処理しています(cron の設定はこの記事では扱いません)。
- 受信したメールの DB への登録や、添付ファイルの取得ジョブの詳細な実装は扱いません。記事中の
registerReceivedEmail()とrunDocumentJobs()は、それらの処理を呼び出す関数として登場します。 - Resend 側で Webhook のエンドポイントを登録し、シークレットを取得する手順は扱いません。
Resend の基本的な使い方については、以下の記事で解説しています。
✉️ メール配信サービスのResendを試す開発者のためのメール配信サービスとして展開しているResendを使ってメール送信するまでの手順を解説します。ritaiz.comこの記事のゴール
Resend の email.received Webhook を Route Handler で受け取り、署名を検証してから処理し、応答のステータスコードを適切に使い分けるところまでをゴールとします。
Webhook の URL は、誰でも POST できる状態で公開されます。そのため、リクエストが本当に Resend から送られたものかを、署名で確かめる必要があります。
構成概要
全体の流れは以下のとおりです。
Resend
↓ POST(svix-id / svix-timestamp / svix-signature ヘッダー付き)
Route Handler
↓ 署名を検証
受信の記録(DB)
↓ 200 を応答
after()(応答後に添付取得のジョブを実行)Resend Webhook の署名の仕組みを理解する
Resend の Webhook は、svix 方式で署名されています。まず、検証に使う情報を整理します。
リクエストには、以下の3つのヘッダーが付きます。
svix-id: メッセージの ID です。svix-timestamp: 送信時刻(UNIX 秒)です。svix-signature: 署名です。
署名は、次の手順で作られています。
${svix-id}.${svix-timestamp}.${生の本文}という文字列を、HMAC-SHA256 で署名します。- 鍵には、シークレット
whsec_...の先頭のwhsec_を外し、残りを base64 で復号した値を使います。 svix-signatureはv1,<base64>の形式です。空白区切りで複数の署名が入ることがあります(鍵の切り替え中など)。そのため、どれか1つと一致すれば通します。- タイムスタンプは、現在時刻との差が ±5分以内のものだけを許容します。古いリクエストの再生を防ぐためです。
署名を検証する関数を実装する
以下のように、node:crypto の createHmac と timingSafeEqual を使って、検証用の関数 verifyResendWebhook() を実装します。ここでは lib/resend-webhook.ts に置きます。
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_S = 5 * 60;
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
function decodeSecret(secret: string): Buffer | null {
const raw = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
if (!BASE64_RE.test(raw)) return null;
return Buffer.from(raw, "base64");
}
export function verifyResendWebhook(
rawBody: string,
headers: { "svix-id": string | null; "svix-timestamp": string | null; "svix-signature": string | null },
secret: string,
now: number = Math.floor(Date.now() / 1000)
): boolean {
const id = headers["svix-id"];
const timestamp = headers["svix-timestamp"];
const signature = headers["svix-signature"];
if (!id || !timestamp || !signature) return false;
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(now - Number(timestamp)) > TOLERANCE_S) return false;
const key = decodeSecret(secret);
if (!key) return false;
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
for (const part of signature.split(" ")) {
const [version, value] = part.split(",", 2);
if (version !== "v1" || !value || !BASE64_RE.test(value)) continue;
const given = Buffer.from(value, "base64");
if (given.length === expected.length && timingSafeEqual(given, expected)) return true;
}
return false;
}シークレットとヘッダーの形式を確かめる
Buffer.from(..., "base64") は、base64 として崩れた文字列でもエラーにせず、黙って読み込んでしまいます。そのため、復号する前に正規表現 BASE64_RE で形を確かめています。
同じ理由で、svix-timestamp も /^\d+$/ で数字だけであることを先に確認します。
署名を比較する
署名の比較には、通常の === ではなく timingSafeEqual を使います。これは、比較にかかる時間から値を推測されにくくするための関数です。
ただし、timingSafeEqual は2つの値の長さが違うと例外を投げます。そのため、先に given.length === expected.length で長さを比べています。
また、svix-signature は空白区切りで複数の署名が入ることがあります。そこで、for ループで1つずつ比較し、どれか1つでも一致すれば true を返します。
Route Handler を実装する
次に、POST ハンドラーを実装します。シークレットの確認、生の本文の取得、署名の検証、イベント種別の判定、受信の記録までを行います。
import { NextRequest, NextResponse, after } from "next/server";
import { verifyResendWebhook } from "@/lib/resend-webhook";
// 受信の記録と取得ジョブの実行(この記事では扱いません)
import { registerReceivedEmail, runDocumentJobs } from "@/lib/inbound-email";
export const maxDuration = 300;
export async function POST(req: NextRequest) {
const secret = process.env.RESEND_INBOUND_WEBHOOK_SECRET;
if (!secret) return NextResponse.json({ error: "not_configured" }, { status: 500 });
const rawBody = await req.text();
const ok = verifyResendWebhook(
rawBody,
{
"svix-id": req.headers.get("svix-id"),
"svix-timestamp": req.headers.get("svix-timestamp"),
"svix-signature": req.headers.get("svix-signature"),
},
secret
);
if (!ok) return NextResponse.json({ error: "invalid_signature" }, { status: 400 });
const payload = JSON.parse(rawBody);
if (payload.type !== "email.received") {
return NextResponse.json({ ok: true, ignored: true, type: payload.type });
}
if (!payload.data?.email_id) return NextResponse.json({ error: "invalid_payload" }, { status: 400 });
// DB に受信の記録と取得ジョブを作る(失敗したら 500 で再送してもらう)
const row = await registerReceivedEmail(payload.data);
if (!row) return NextResponse.json({ error: "db_error" }, { status: 500 });
if (row.created) {
// 応答を返した後に、添付の取得を始める
after(async () => {
await runDocumentJobs({ limit: 3, targetEmailId: row.email_id });
});
}
return NextResponse.json({ ok: true, email_id: row.email_id, created: row.created });
}本文を生のまま受け取る
本文は、req.text() で生の文字列のまま受け取ります。req.json() でパースしてから JSON.stringify し直すと、空白やキーの順が変わることがあります。その結果、署名の計算対象が変わってしまい、署名が一致しなくなります。
署名の検証が終わってから、JSON.parse(rawBody) でパースします。
ステータスコードを使い分ける
Resend は、200 以外の応答を受けると Webhook を再送します。そのため、再送されても意味があるかどうかで、ステータスコードを使い分けます。
| ケース | ステータスコード | 再送 | 理由 |
|---|---|---|---|
| 署名が不正 | 400 | 不要 | 再送されても結果は変わりません |
| シークレットが未設定 | 500 | 必要 | 設定するまで Resend が再送し続けるため、設定後に取りこぼしません |
| email.received 以外のイベント | 200 | 不要 | 無視してよいイベントなので、再送させません |
| email.received だが email_id がないなど形が不正 | 400 | 不要 | 再送されても直りません |
| DB の失敗 | 500 | 必要 | Resend の再送に任せます |
DB の失敗で再送された場合、同じ email_id のリクエストが再び届きます。この重複は、DB の一意制約で吸収し、既存の行を返す設計にしています。
after() で応答の後に処理する
Webhook の中では、Resend の API や Storage は呼びません。受信を記録して 200 を返すだけにします。
ただし、取得ジョブを1分ごとの cron に任せるだけでは、取り込みが最大で1分ほど遅れます。そこで、Next.js の after() を使い、応答を返した後に、そのメールのジョブを取りに行きます。
after()の処理が失敗しても、cron が拾います。cron は保険の役割なので、取りこぼしはありません。after()の処理も、関数の実行時間に含まれます。そのため、maxDurationを 300 に設定しています。
トラブルシューティング
Resend が 403 を受けて再送し続け、Vercel のランタイムログに何も出ない
原因は、Vercel の Firewall です。「日本以外からのアクセスを拒否」するカスタムルールを入れていたため、米国から送られる Resend の Webhook を、Vercel のエッジが遮断していました。このときは、以下の応答が返っていました。
{"error":{"code":"403","message":"Forbidden"}}リクエストは関数まで届かないため、Vercel のランタイムログには何も出ません。一方で Resend は 403 を受けて、再送を続けていました。
対処として、Firewall に「Request Path が /api/webhooks/ で始まる場合は Bypass する」ルールを追加します。このルールは、国で拒否するルールより上に置く必要があります。署名を検証しているため、このパスは公開してよいと判断しました。
シークレットを設定したのに 500 が返り続ける
環境変数 RESEND_INBOUND_WEBHOOK_SECRET は、再デプロイするまで反映されません。設定しただけでは、not_configured の 500 が返り続けます。設定後は、再デプロイを実行してください。
ログに出す情報
ログには、email_id と処理結果だけを出します。件名、宛先、送信元などのメールの内容は、ログに出さないようにします。
まとめ
この記事では、Next.js の Route Handler で Resend の Webhook の署名を検証する手順を解説しました。ポイントを整理すると以下のとおりです。
- svix 方式の署名は、
node:cryptoの HMAC-SHA256 だけで検証できます。 - シークレットとヘッダーは、正規表現で形を確かめてから使い、比較には長さを確認した上で
timingSafeEqualを使います。 - 本文は
req.text()で生のまま受け取ります。パースし直すと署名が一致しなくなります。 - ステータスコードは、再送させるかどうかで使い分けます。
- 時間のかかる処理は、
after()で応答の後に実行します。 - Vercel Firewall で国を制限している場合は、Webhook のパスを Bypass するルールを、国の Deny より上に置きます。
自分で公開した Webhook の URL を安全に受け付けたい方にとって参考になれば幸いです。