SendGridのEvent Webhookは、processedやdelivered、bounceといったイベントを配列にまとめてPOSTしてくる仕組みです。実装でつまずくのは送信API側ではありません。難所は受信側に集中していて、rawボディを壊さない署名検証、最大24時間の再送に耐える冪等な保存、順序が前後して届くイベントの状態更新の3つに集約されます。この記事は、Twilio SendGridの公式ドキュメントと@sendgrid/eventwebhookの実装例をもとに、Expressでの受信エンドポイント、PostgreSQLでの永続化、SendGrid側障害の切り分けまでをコード付きで整理した内容です。受信基盤を自前で持たずに済ませる判断条件も、年間コストの試算とあわせて示します。
まとめ|Event Webhook実装で先に決める4点と障害時の退避先
先に示す結論は4点です。第一に、署名検証はJSONパース前のrawバイト列に対して行います。ExpressやNestJSのJSONパーサを既定のまま使うと、ボディが再シリアライズされて署名が一致しなくなるため、Webhookのパスだけをパーサから除外する設定が前提になります。
第二に、保存テーブルの主キーはsg_event_idに置きます。SendGridは2xxを受け取るまで再送を続けるので、同じイベントが複数回届くことが設計の前提です。第三に、メールの現在状態はイベント到着順ではなくtimestampの大小で更新します。deliveredの後にprocessedが届くことは起こり得ます。
第四に、受信エンドポイントは保存処理を待たずに即座に応答を返します。DB書き込みやSlack通知を同期で挟むと、負荷の高い時間帯にタイムアウトして再送の山を自分で作ることになります。届かないときの退避先は、SendGrid側の稼働状況を示すstatusページと、管理画面のActivity Feedの2つです。
SendGrid Event WebhookとAPI送信の役割分担と受信データの構造
SendGridのAPIは、送信(Mail Send API)と結果取得(Event Webhook)が完全に分離しています。Mail Send APIのレスポンスが202を返しても、それは受け付けたという意味しか持ちません。実際に届いたか、弾かれたか、開かれたかは、すべてEvent Webhookが後からPOSTしてくる情報でしか分かりません。
Event Webhookが返すdelivered・bounce等11種のイベント区分
公式のEvent Webhook Referenceでは、イベントが配信系と反応系に分けて定義されています。配信系はprocessed・deferred・delivered・bounce・droppedの5種、反応系はopen・click・spamreport・unsubscribe・group_unsubscribe・group_resubscribeの6種です。これに加えてアカウント状態の変化を伝えるaccount_status_changeがあります。Twilio SendGridのEvent Webhook Referenceに各イベントのフィールド定義が並んでいるので、受信側のスキーマはここを写して作ります。
| 区分 | イベント名 | 意味 |
|---|---|---|
| 配信系 | processed | 送信キューに投入された |
| 配信系 | deferred | 受信側に一時的に拒否された |
| 配信系 | delivered | 受信サーバーが受領した |
| 配信系 | bounce | エラーで返送された |
| 配信系 | dropped | SendGrid側で送信を中止した |
| 反応系 | open | 開封が記録された |
| 反応系 | click | 本文リンクが押された |
| 反応系 | spamreport | 迷惑メール報告を受けた |
| 反応系 | unsubscribe | 配信停止が押された |
| 反応系 | group_unsubscribe | 購読グループを停止した |
| 反応系 | group_resubscribe | 購読グループを再開した |
業務システムで最初に必要になるのは配信系の5種です。反応系はマーケティング配信の指標であり、パスワード再設定メールのようなトランザクション用途では受信対象から外して構いません。管理画面のActions to be postedで受け取るイベントを絞れば、POST量そのものを減らせます。
sg_event_idとsg_message_idで見分ける重複と同一メールの追跡
受信したJSONは配列で、1回のPOSTに複数イベントが入ります。ここで扱いを分けるべき識別子は2つです。sg_event_idはイベント1件ごとの一意なIDで、公式ドキュメントは重複排除(deduplication)の用途を明示しています。もう一方のsg_message_idはメール1通の識別子で、同じメールに対するprocessed・delivered・openが同一の値を共有します。
つまり、重複判定はsg_event_id、1通のライフサイクル追跡はsg_message_idという役割分担になります。自社の注文番号やユーザーIDと紐づけたい場合は、送信時にカスタム引数を付けておくと、そのままイベント側にも返ってきます。後から突合キーを足すことはできないので、送信実装の時点で決めておく設計事項です。
署名検証を含む受信エンドポイントの実装手順とrawボディの扱い
Post URLは公開されたHTTPSエンドポイントなので、誰でもPOSTできる状態です。署名検証を入れない受信処理は、外部から偽のbounceを送り込んで配信停止リストを汚染できる状態を放置していることになります。設定自体は管理画面の数クリックで終わります。
署名検証に使うECDSA公開鍵と2つのリクエストヘッダーの取得
署名検証を有効にすると、SendGridはリクエストに2つのヘッダーを付けます。署名がX-Twilio-Email-Event-Webhook-Signature、タイムスタンプがX-Twilio-Email-Event-Webhook-Timestampです。アルゴリズムはHMACではなく楕円曲線デジタル署名(ECDSA)で、検証にはBase64をデコードしてASN.1をアンマーシャルする処理が入ります。公式のSecurity Featuresの解説は、ペイロードを生バイト列のまま扱う必要があると明記しています。
検証用の公開鍵は、管理画面でSignature Verificationを有効化した時点で画面に表示されます。この鍵はソースに直書きせず環境変数に置きます。鍵をローテーションする際は、新旧2本を同時に受け付ける期間を数時間設けると、切り替え中に取りこぼしません。
Expressでrawボディを残す設定と204を返す受信処理のコード
実装で最も多い失敗が、JSONパーサがボディを先に消費してしまうケースです。SendGrid公式のNode.jsライブラリは、Webhookのパスをパーサの対象から外し、bodyParser.rawでBufferのまま受け取る構成を示しています。以下はsendgrid-nodejsのevent-webhook.mdの例を、保存をキューへ逃がす形に組み替えたものです。
const express = require("express");
const bodyParser = require("body-parser");
const { EventWebhook, EventWebhookHeader } = require("@sendgrid/eventwebhook");
const app = express();
const PUBLIC_KEY = process.env.SG_WEBHOOK_PUBLIC_KEY;
app.post(
"/sendgrid/events",
bodyParser.raw({ type: "application/json" }),
(req, res) => {
const ew = new EventWebhook();
const key = ew.convertPublicKeyToECDSA(PUBLIC_KEY);
const sig = req.get(EventWebhookHeader.SIGNATURE());
const ts = req.get(EventWebhookHeader.TIMESTAMP());
if (!ew.verifySignature(key, req.body, sig, ts)) {
return res.sendStatus(403);
}
enqueue(JSON.parse(req.body.toString("utf8")));
res.sendStatus(204);
}
);
検証に失敗したら403、成功したら204での即応答が原則です。enqueueの中身はSQSでもRedisでも構いませんが、同期のDB書き込みをここに置かないという一点だけは守ります。管理画面側の設定手順は国内正規代理店が公開する日本語ドキュメントにあり、Settings内のMail SettingsからFriendly NameとPost URLを入れ、Actions to be postedで対象イベントを選ぶ流れになります。
OAuth2.0検証とIP制限を併用する場合の設定値と適用の順序
署名検証に加えて、SendGridはOAuth 2.0による認証にも対応しています。設定に必要な値はClient ID、Client Secret、Token URLの3つで、SendGridが認可サーバーからアクセストークンを取得し、Authorizationヘッダーに載せてPOSTしてきます。すでに社内に認可サーバーがある場合はこちらの方が運用に乗せやすいでしょう。
両方を有効にする構成も取れますが、順序への注意が必要です。エンドポイント側ではまずトークン検証で弾き、通過したものだけ署名検証に回します。逆順にすると、認証されていないリクエストにもECDSAの計算コストを払うことになります。送信元IPでの絞り込みは、SendGridの送信IPが増減するため単独の防御には向きません。
重複配信と順序逆転に耐えるイベント永続化とリトライ設計の勘所
Event Webhookは、エンドポイントが2xxを返すまで諦めません。Getting startedのドキュメントには、イベント発生後、最大24時間にわたって間隔を広げながら再送すると書かれています。しかもこの24時間は個々のイベントごとのローリング期間です。エラーを返し続ければ、再送は雪だるま式に積み上がります。
24時間の再送仕様に合わせた冪等な保存とsg_event_idの一意制約
再送に対する防御は、アプリケーション側の分岐ではなくDBの制約で作ります。sg_event_idを主キーに置き、重複挿入は例外ではなく無視として扱う形が扱いやすい構成です。
CREATE TABLE sendgrid_events (
sg_event_id VARCHAR(64) PRIMARY KEY,
sg_message_id VARCHAR(128) NOT NULL,
email VARCHAR(320) NOT NULL,
event VARCHAR(32) NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
payload JSONB NOT NULL
);
CREATE INDEX idx_sg_events_msg
ON sendgrid_events (sg_message_id, occurred_at DESC);
挿入はON CONFLICT DO NOTHINGで受けます。これで、同じイベントが3回届いても行は1つしか増えません。生のJSONをpayloadに丸ごと残しておくと、後から必要になったフィールドを再取得せずに拾えます。SendGridのイベント定義は追加されることがあるため、カラム化は自分たちが使う項目だけに絞る方が改修が軽く済みます。
イベントの順序逆転を前提にしたtimestamp基準の状態更新の実装
受信の順序は保証されません。ネットワーク経路の違いや再送の重なりにより、delivered の後に processed が到着するケースも想定範囲です。到着順で上書きすると、配信済みのメールが処理中に戻るという不整合が起きます。状態テーブルの更新は、イベントのtimestampが既存値より新しいときだけ通す条件付きUPDATEにします。
INSERT INTO message_state (sg_message_id, status, status_at)
VALUES ($1, $2, $3)
ON CONFLICT (sg_message_id) DO UPDATE
SET status = EXCLUDED.status,
status_at = EXCLUDED.status_at
WHERE message_state.status_at < EXCLUDED.status_at;
この1行のWHERE句があるかどうかで、運用開始後の問い合わせ対応の手間がまるで変わります。もう1つ、bounceだけは他のイベントに上書きさせない扱いにしておくと安全側に倒せます。恒久エラーで返ってきたアドレスに送り続けると、送信ドメインの評価が下がるためです。
SendGrid障害発生時の切り分け手順とstatusページの確認項目
「メールが届かない」という問い合わせは、送信側・SendGrid・受信側・自社の受信処理のどこで止まっているのかを切り分けないと対処できません。切り分けの順序を決めておけば、深夜の障害対応でも判断がぶれません。
Webhookが届かない時に切り分ける4つの箇所と確認の順番
確認は次の順で進めます。まずSendGridの管理画面にあるActivity Feedで、対象メールのイベントがSendGrid側に記録されているかどうかが確認対象です。ここに無ければ送信APIの呼び出しが失敗しています。記録があってWebhookだけ来ていない場合は、Post URLの設定と、直近の応答ステータスを確認します。
- Activity Feedに該当メールのイベントがあるか
- Event Webhookの設定が有効で、Post URLが現行の本番URLか
- 自社のアクセスログにSendGridからのPOSTが記録されているか
- 署名検証で403を返していないか、応答が2xxになっているか
3番目まで到達しているのにイベントが取り込めていないなら、原因は自社側です。署名検証の失敗は403を返すため、SendGridから見ると再送対象になり、ログにはPOSTだけが繰り返し並びます。この症状が出たら、真っ先にJSONパーサの除外設定を疑います。
SendGrid側障害とアプリ側障害を見分けるstatusページの読み方
SendGridはインシデント情報をSendGrid Statusで公開しています。ここで見るべきはコンポーネント単位の状態です。Email Sendingが正常でEvent Webhookに障害が出ている場合、メール自体は届いているのにイベントだけ遅延している状態で、業務影響の範囲がまったく変わります。
統計値の確認には、管理画面のStatsをそのまま見るのが早い手段です。送信通数と配信率が普段どおりであれば、送信は動いています。障害が長引く場合でも、24時間の再送期間内に復旧すればイベントは後から届くため、受信処理を止めて復旧を待つ判断も可能です。この待てる時間を知っているかどうかで、緊急対応の切迫感が変わります。
Webhook受信基盤を内製する条件と外部サービスに寄せる分岐点
ここまでの実装を自前で持つべきかどうかは、送信量と、イベントを何に使うかで決まります。判断を先に言い切ります。イベントをDBに保存して業務ステータスに反映する必要があるなら内製、通知の可視化だけが目的なら内製しない、という線引きです。
内製で足りる条件とiPaaSやログ基盤に寄せるべき失敗パターン
内製が見合うのは、bounceをユーザーマスタの配信可否フラグに反映する、督促メールの到達を業務画面に出すなど、イベントが自社データの一部になる場合です。この場合、受信・保存・状態更新の3層はどのみち自分たちのDBに必要なので、外部サービスを挟む理由がありません。
反対に、失敗パターンとして繰り返し見られるのが、Slack通知だけのためにWebhook受信サーバーを立てる構成です。冗長化・監視・鍵管理という常設の運用コストを、通知の可視化のために払うことになります。この用途ならiPaaSに寄せるか、SendGridの管理画面のAlertsで済ませる方が合理的です。自前のSMTP基盤まで含めて構成を比較したい場合は、メール配信システムの自作・構築で必要になるSMTP基盤の設計を先に読むと、SaaSに任せる範囲の線引きがはっきりします。
料金と送信量から逆算するEvent Webhook運用の年間コスト試算
Event Webhook自体に追加料金はかかりません。増えるのは受信側の費用です。月10万通を送るシステムで配信系5種を受け取ると、イベント数は単純計算で月50万件前後になります。この規模ならコンテナ1台と小さめのRDSで収まり、クラウド費用は月数千円台に落ち着きます。設計と実装の工数は、署名検証・永続化・監視まで含めて数人日というのが実感に近い規模です。
プラン側の費用は送信通数で決まるため、受信設計より先に送信量の見積もりを確定するのが適正な順序です。国内の円建てプランと米ドル直販で年額に差が出る点は、SendGrid料金の実額とFreeプラン終了後の選択肢で試算しています。既存システムへイベントを取り込む部分の設計や、複数SaaSをまたぐ連携基盤の構築を外部に相談するなら、API開発・システム連携のページに実装範囲をまとめています。
よくある質問
Event Webhookの実装で、設計判断が必要になりやすい論点を5つ挙げます。
Event WebhookとInbound Parse Webhookは何が違いますか?
Event Webhookは自分が送ったメールの結果イベントを受け取る仕組みで、delivered や bounce が対象です。Inbound Parse Webhookは受信したメールそのものを解析してPOSTしてもらう機能で、問い合わせメールをシステムに取り込む用途に使います。設定画面もPost URLも別物なので、同じエンドポイントで両方を受けることは避けてください。返ってくるJSONの構造がまったく異なります。
署名検証を後から有効にしても既存の受信処理は動きますか?
ヘッダーが追加されるだけなので、既存のPOST受信は止まりません。ただしJSONパーサを通している実装のままでは、検証を実装した瞬間に全リクエストが不一致になります。有効化の前に、rawボディを取得できる形へ受信処理を先に変えておき、しばらくは検証結果をログ出力するだけの監視モードで運転してから、403を返す本番モードに切り替える段取りが安全です。
ローカル環境でEvent Webhookをテストするにはどうしますか?
公開URLが必要なので、ngrokなどのトンネルでローカルポートを外部に出し、そのURLをPost URLに設定する方法が手軽です。管理画面のEvent Webhook設定にはテスト送信の機能があり、サンプルイベントをPOSTして疎通だけを先に確認できます。署名検証まで含めて試す場合は、テスト用のWebhook設定を本番とは別に1つ作り、公開鍵を分けておくと事故を防げます。
イベントが二重に届いた場合はどちらを採用すべきですか?
同じsg_event_idであれば内容は同一なので、どちらを採用しても差はありません。先に保存した1件を残し、後続は無視する実装で問題ありません。判断が必要なのはsg_message_idが同じでsg_event_idが異なるケースで、これは別イベントです。状態を更新するときはtimestampの新しい方を採用し、古いイベントで上書きしないようにします。
Webhookの受信が止まったとき送信済みのイベントは復元できますか?
公式ドキュメントの再送仕様では、イベント発生から最大24時間は再送が続きます。この時間内にエンドポイントを復旧させれば、取りこぼした分も自動配信の範囲です。24時間を超えた分は再送されないため、管理画面のActivity Feedから期間を指定して確認するか、Stats系のAPIで集計値を取り直す形になります。復旧の目標時間を24時間より内側に置いておくと、手作業での復元を避けられます。
関連記事
- SendGrid料金の実額と落とし穴:円建てプランとドル建て直販の年額差、Freeプラン終了後の代替比較
- メール配信システムの自作・構築とは:SaaSに寄せずSMTP基盤を自前で持つ場合の設計と到達率確保
- メール配信システムとは:仕組みとメルマガ配信ツールとの違い、導入判断の基準
- SPFとは:bounceを減らす前提になるメール認証の仕組みとレコードの書き方
- DMARCの仕組み:none・quarantine・rejectの違いとなりすまし防御の設計
- SendGridログインの手順と入れない時の対処:2FAとTwilio Login、Basic認証からAPIキーへの移行手順
- センドグリッド(SendGrid)とは:直販と国内版の違い、ドメイン認証と最初の1通を送る実装手順