2026-11-12(木) 15:00

🔏 Next.js の Route Handler で Resend の Webhook の署名を検証する

Next.jsResend
Next.js の Route Handler で Resend の受信 Webhook を受け取り、node:crypto だけで svix 方式の署名を検証する手順を解説します。ステータスコードの使い分けや after() による後処理、Vercel Firewall でのハマりどころも扱います。

目次

前提と注意事項

この記事では以下を前提としています。

  • 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 に置きます。

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 ハンドラーを実装します。シークレットの確認、生の本文の取得、署名の検証、イベント種別の判定、受信の記録までを行います。

app/api/webhooks/resend/inbound/route.ts
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 のエッジが遮断していました。このときは、以下の応答が返っていました。

Vercel のエッジが返した応答
{"error":{"code":"403","message":"Forbidden"}}

リクエストは関数まで届かないため、Vercel のランタイムログには何も出ません。一方で Resend は 403 を受けて、再送を続けていました。

対処として、Firewall に「Request Path が /api/webhooks/ で始まる場合は Bypass する」ルールを追加します。このルールは、国で拒否するルールより上に置く必要があります。署名を検証しているため、このパスは公開してよいと判断しました。

Vercel の Firewall で、Request Path が /api/webhooks/ で始まる場合に Bypass するルールの設定画面 Vercel の Firewall のルールの一覧で、Bypass のルールを国の Deny のルールより上に置いた状態

シークレットを設定したのに 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 を安全に受け付けたい方にとって参考になれば幸いです。