
ヘッドレスCMSのWebhook宛先設計 ― 汎用に徹し、認証が要る宛先だけ名前付きにする
平原 典彦 — Tesseraを開発しているエンジニア。ヘッドレスCMSとAI連携に関心があります。
コンテンツを更新したら、サイトを再ビルドしたい。チームのチャットに通知したい。Zapierで別のツールにつなぎたい——ヘッドレスCMSのWebhookに求められる「宛先」は、じつにバラバラです。素直に作ると、宛先が増えるたびに個別対応が積み上がり、CMSがだんだん「通知連携ツール」になっていきます。
Tesseraはここで一つ割り切りをしました。Webhookは汎用HTTP(署名付きJSON)に徹する。そのうえで、どうしても認証が要る宛先だけを、薄く名前付きで特別扱いする。 この記事は、その宛先設計の考え方の記録です。
出発点:宛先は「URLを1本」渡せば済むようにする
Tesseraのイベント(entry.created / entry.updated / entry.published / schema.updated / media.uploaded など)は、どの経路の変更でも発火します。GraphQLのMutationでも、MCP経由でも、管理画面の手操作でも、CSVインポートでも。発火したイベントは、登録されたエンドポイントのURLへPOSTされます。
大半の宛先は、これだけで十分です。エンドポイントURLを1本登録するだけ。届いたリクエストが本当にTesseraから来たものかは、署名で確かめます。
- 署名ヘッダ
x-tessera-signature: sha256=<hex> - タイムスタンプヘッダ
x-tessera-timestamp(Unix秒) - 署名対象は
timestamp . body(タイムスタンプと生ボディをドットで連結)を、エンドポイントごとのsigningSecretでHMAC-SHA256
受け取り側の検証はこれだけです。
import crypto from "node:crypto";
function verify(req, signingSecret) {
const signature = req.headers["x-tessera-signature"]; // "sha256=..."
const timestamp = req.headers["x-tessera-timestamp"];
const expected =
"sha256=" +
crypto
.createHmac("sha256", signingSecret)
.update(`${timestamp}.${req.rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
汎用のjson宛先(自前のエンドポイント、あるいはZapier / Makeのようなハブ)は、この署名付きの生イベントJSONをそのまま受け取ります。Slack・Discord・Microsoft Teams・Google Chatも、実体は同じ「URLを1本貼るだけ」の仲間です。メッセージの形(Slackなら{text}、Discordなら{content}…)に整形して送るだけで、認証はURL自体が握っている。だから署名を付けてPOSTすれば済みます。
宛先を増やすことが、原則として「新しい認証の仕組みを増やすこと」にならない。 これが設計の起点です。
デプロイフック:ヘッドレスCMSの本命用途を、あえて名前付きにする
例外の1つ目が、デプロイフックです。これはヘッドレスCMSでは定番の用途で、主要なヘッドレスCMSはたいていネイティブ対応しています。Tesseraもdeployという宛先タイプを用意しました。
なぜデプロイフックが要るのか。静的サイト(SSG)は、ビルド時にCMSからコンテンツを引いてHTMLを焼き固めます。リクエストのたびにCMSを叩くのではなく、一度作ったHTMLを配るから速い。裏を返すと、コンテンツを更新しても、再ビルドしない限りサイトには反映されません。だから「公開したら再ビルドを起動する」フックが要る。これがデプロイフックの本命用途です。GraphQLを"いつ"引くか——ビルド時かリクエスト時か——という選択の、ビルド時側を支える仕組みだと言えます。
deploy宛先は、他とはっきり違う挙動を1つ持っています。ボディに中身を入れません。送るのは{}だけです。
デプロイフック(Vercel / Netlify / Cloudflare PagesのBuild Hook)は、リクエストのボディを見ずに「叩かれたらビルドを起動する」だけの入口です。だから何を送っても動く。ならばわざわざコンテンツの中身を第三者のビルドサービスに渡す理由がない。必要なのは「起動の合図」だけなので、合図だけを送る。署名も付けません(受け手が検証しないため)。
宛先を名前付きにする、というと機能を足す話に聞こえますが、deployの場合はむしろ送る情報を削る方向の特別扱いです。
Chatwork:ここだけ「認証が要る」から、ここだけ特別扱いする
例外の2つ目、そして設計上いちばん悩んだのがChatworkです。
Slackや Discordが「URLを貼るだけ」で済むのは、URL自体(Incoming Webhook URL)が認証を兼ねているからです。ところがChatworkのメッセージ送信APIは、そうなっていません。
- 送信先は
https://api.chatwork.com/v2/rooms/{room_id}/messagesという固定のAPIエンドポイント - 認証は
X-ChatWorkTokenヘッダにAPIトークンを載せる - ボディは
application/x-www-form-urlencodedのform送信(body=...)
つまりChatworkに送るには、ユーザーのChatwork APIトークンをTesseraが預かる必要があります。ここが他の宛先と決定的に違う点です。URLは秘密ではなく、トークンが秘密。
そしてsigningSecretとトークンでは、漏れたときの怖さがまるで違います。
signingSecretは、Tesseraが「自分が送った」ことを証明するための鍵。漏れても、できるのはTesseraになりすました偽の通知を送ることくらい。だからDBには平文で保存しています(署名検証のために復元可能である必要がある鍵で、APIキーのようにハッシュ化はしない)。- Chatworkトークンは、第三者サービス(Chatwork)でユーザーの権限そのものを行使できる資格情報。漏れれば、そのユーザーとしてメッセージを送れてしまう。影響範囲がTesseraの外に及びます。
だから、Chatworkトークンだけは保存時にAES-256-GCMで暗号化します。鍵は既存のAUTH_SECRETからscryptで導出するので、新しい必須環境変数は増やしていません。暗号文はv1.<iv>.<tag>.<ciphertext>という形で持ち、配信の直前にだけ復号してヘッダに載せます。
機微度が違うものを、同じ棚に同じ扱いで置かない。平文でよい鍵と、暗号化すべき資格情報を、実装レベルで分ける——名前付き対応を1つ増やすというのは、こういう判断を1つ引き受けることでもあります。だからこそ、名前付き対応は「認証が要る宛先」だけに絞りました。
「信頼性」を、仕様として書いておく
宛先の話とは別に、Webhookで地味に効いてくるのが配信の信頼性です。相手のサーバーが一瞬落ちていた、タイムアウトした——そのとき通知が黙って消えると、気づけません。
Tesseraは、この辺りを「仕様」として明記しています。
- トランザクショナル・アウトボックス:イベントはまず配信キュー(
webhook_deliveries)に書き込みます。本体のコンテンツ更新は、Webhook配信の成否に一切左右されません(通知が失敗しても、コンテンツの保存は成功する)。 - 自動リトライ 最大5回:失敗したら指数バックオフ(約1分→2分→4分→8分…、上限1時間)で再送します。
- 配信ログ:試行回数・最後のエラー・レスポンスステータス・配信時刻を残します。うまくいかない宛先を後から追えます。手動での再送もできます。
- 設定不備は即失敗:Chatworkトークンが無い・復号できないといった「直さない限り成功しない」ケースは、リトライで消耗せずに即座に失敗扱いにします。
これらは派手な機能ではありませんが、あるかないかで運用の安心感がまるで違う部分です。Webhookは「送って終わり」に見えて、実際は「届かなかったときにどうするか」が本体だったりします。だから仕様として書いておく価値がある、と考えています。
まとめ
- Webhookの宛先はバラバラ(再ビルド・チャット通知・ハブ連携)。素直に個別対応すると、CMSが通知連携ツールに肥大化する
- 原則は汎用HTTP:宛先はURLを1本渡すだけ。正当性は
x-tessera-signature(HMAC-SHA256、timestamp.bodyを署名)で担保する。Slack/Discord/Teams/Google Chatも実体は「URLを貼るだけ」の仲間 - デプロイフック(
deploy):SSGの再ビルド起動という本命用途。あえて中身を送らず{}だけ——起動の合図に情報は要らないし、第三者のビルドサービスにコンテンツを渡さない - Chatwork:ここだけURLでなくトークン認証(
X-ChatWorkToken+form送信)。第三者API権限を預かるので、トークンはAES-256-GCMで暗号化保存(鍵はAUTH_SECRETから導出)。平文でよいsigningSecretとの機微度の違いを実装で分ける - 信頼性は仕様:トランザクショナル・アウトボックス/最大5回の指数バックオフ・リトライ(上限1時間)/配信ログ。届かなかったときにどうするかまでを設計に含める
汎用に徹して、例外は「認証が要る宛先」だけに絞る。宛先が10種あっても、特別な資格情報を預かるのは1つだけ——この非対称さが、Webhookの設計をシンプルに保つコツだと思っています。