Webhook
概要
Webhookは、コンテンツ(エントリ)・スキーマ(コンテンツタイプ)・メディアの変更を、あなたの指定した URLへHMAC-SHA256署名付きでPOST通知する機能です。GraphQL Mutation・MCP・管理画面・CSV インポートのどの経路の変更でも発火します。公開時にサイトを再ビルドする、Slack/Discordへ 通知する、AIエージェントのパイプラインを起動する、といった連携に使えます。
Webhookはコンテンツタイプ単位で登録します(メディアイベントを除く)。配信は数分間隔の バッチで行われ、失敗時は指数バックオフで自動リトライします。送信形式はjson(生イベント)、slack / discord / teams / googlechat(各サービス向け整形メッセージ)、deploy(Vercel/Netlify/Cloudflare Pagesのデプロイフック)、chatwork(Chatworkのルームへ通知)から選べます。具体的な繋ぎ方は宛先別の使い方を参照してください。
イベント一覧
| イベント | 発火タイミング |
|---|---|
entry.created | エントリの新規作成 |
entry.updated | エントリの更新 |
entry.deleted | エントリの削除(アーカイブ) |
entry.published | エントリが公開状態(published)になったとき。作成・更新の結果公開になった場合は、entry.created/updated と併せて発火します |
schema.created | コンテンツタイプ(スキーマ)変更提案の承認による新規作成 |
schema.updated | コンテンツタイプの更新(承認時) |
schema.deleted | コンテンツタイプの削除 |
media.uploaded | メディア(アセット)のアップロード。ワークスペース全体のイベントで、コンテンツタイプに紐づかないためフィルタ無し(全対象)のWebhookにのみ配信されます |
ペイロード
送信形式が json の場合、ボディは以下の形の生イベントです。 いずれのイベントも event / occurredAt(ISO 8601)/ workspaceId を共通で含みます。
エントリ(entry.*)
entry.data は変更後のフィールド値マップです。entry.deleted では data を含みません。
{
"event": "entry.created",
"occurredAt": "2026-08-03T12:34:56.789Z",
"workspaceId": "3b7bd841-798a-4304-9d37-c0b4ee9252a9",
"contentType": "blogPost",
"entry": {
"id": "afb75a3a-e716-4a97-a2cf-fff7d038b251",
"status": "published",
"data": { "title": "はじめまして", "slug": "hello" }
}
}entry.deleted の例:
{
"event": "entry.deleted",
"occurredAt": "2026-08-03T12:40:00.000Z",
"workspaceId": "3b7bd841-798a-4304-9d37-c0b4ee9252a9",
"contentType": "blogPost",
"entry": { "id": "afb75a3a-e716-4a97-a2cf-fff7d038b251", "status": "archived" }
}スキーマ(schema.*)
{
"event": "schema.updated",
"occurredAt": "2026-08-03T12:41:00.000Z",
"workspaceId": "3b7bd841-798a-4304-9d37-c0b4ee9252a9",
"contentType": "blogPost",
"schema": { "apiId": "blogPost", "displayName": "記事" }
}メディア(media.uploaded)
{
"event": "media.uploaded",
"occurredAt": "2026-08-03T12:42:00.000Z",
"workspaceId": "3b7bd841-798a-4304-9d37-c0b4ee9252a9",
"asset": { "id": "53312353-f018-4a62-a12f-79900009912a", "fileName": "cover.png", "mimeType": "image/png", "sizeBytes": 24182 }
}送信形式が slack / discord の場合は、上記の生ペイロードではなく、各サービスの Incoming Webhookが受け付ける1行メッセージ({ "text": "..." } / { "content": "..." })を送信します。googlechat も { "text": "..." }、teams はMessageCard({ "@type": "MessageCard", …, "text": "..." })で1行メッセージを送信します。chatwork も同様に1行メッセージをapplication/x-www-form-urlencoded(body=...)で送信します。deploy はボディを見ない宛先向けのため、コンテンツを含まない 最小の {} のみを送ります。
ヘッダーと署名検証
各配信リクエストには次のヘッダーが付きます。
| ヘッダー | 内容 |
|---|---|
x-tessera-event | イベント名(例 entry.created) |
x-tessera-delivery | 配信ID。リトライや手動再送でも同じ値になるため、受信側の冪等性キーに使えます |
x-tessera-timestamp | 署名時刻(Unix秒)。署名対象に含まれ、鮮度確認(リプレイ対策)に使います |
x-tessera-signature | sha256=<hex>。<x-tessera-timestamp>.<生ボディ> を署名シークレットでHMAC-SHA256した値 |
user-agent | Tessera-Webhooks/1.0 |
署名シークレットはWebhook作成時(および再発行時)に一度だけ画面表示されます。受信側は、送られてきた x-tessera-timestamp と生のリクエストボディから同じ計算を行い、x-tessera-signature と一致するか、タイムスタンプが新しいかを検証します。
import crypto from "node:crypto";
// rawBody は「生の」リクエストボディ文字列(JSON.parse前の値)を使うこと。
// パース→再文字列化するとバイト列が変わり、署名が一致しなくなる。
export function verifyTesseraWebhook(
rawBody: string,
headers: Record<string, string | undefined>,
signingSecret: string,
): boolean {
const timestamp = headers["x-tessera-timestamp"];
const signature = headers["x-tessera-signature"]; // 例: "sha256=abcd..."
if (!timestamp || !signature) return false;
// リプレイ対策: タイムスタンプが一定以上ずれていたら拒否(例: 5分)
const ageSec = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSec) || ageSec > 300) return false;
const expected =
"sha256=" +
crypto.createHmac("sha256", signingSecret).update(`${timestamp}.${rawBody}`).digest("hex");
// タイミング安全な比較(長さ不一致は先に弾く)
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}署名ヘッダーが付くのは json / slack / discord / teams / googlechat です(各サービスは署名を検証しませんが付与します)。deploy(URLが秘密)と chatwork(X-ChatWorkTokenによるトークン認証)は、その宛先の認証方式に従うためx-tessera-signature は付きません。
配信とリトライ
- 配信は数分間隔のバッチワーカーが行います(イベント発生から実際の送信まで数分の遅延が生じ得ます)。
- 受信側が 2xx を返せば成功(
delivered)。 それ以外・タイムアウト(10秒)・接続失敗は失敗として扱います。 - 失敗時は指数バックオフ(約1分→2分→4分…、上限1時間)で自動リトライし、最大5回で
failedになります。 - 同じ配信はリトライ・再送でも
x-tessera-deliveryが同一です。受信側は冪等に処理してください(同じ配信IDを二重処理しない)。 - 受信側は速やかに2xxを返し、重い処理は非同期に回すことを推奨します(10秒でタイムアウトします)。
設定方法
- 管理画面のコンテンツ一覧(
/entries)で対象コンテンツタイプを開き、ヘッダーの「…」メニュー →「Webhook設定」から登録します。 - 送信先URL・購読イベント・送信形式(
json/slack/discord/teams/googlechat/deploy/chatwork)を指定します(本番はhttpsのみ・内部アドレス不可)。chatworkを選んだ場合はAPIトークンの入力欄が表示され、トークンは暗号化して保存されます。 - 登録済みWebhookは編集(URL・イベント・形式の変更)、有効/無効の切替、削除ができます。
- 署名シークレットは再発行(ローテーション)できます。再発行すると古いシークレットは 無効になるため、受信側の設定を差し替えてください。
- 「最近の配信」で配信結果(状態・応答/エラー・試行回数)を確認でき、失敗した配信は手動で再送できます。
宛先別の使い方
送信形式(format)ごとに、代表的な宛先への繋ぎ方をまとめます。
自前エンドポイント / Zapier・Make 等(json)
- 送信形式に
jsonを選び、受信できるURLを登録します。生イベントJSONがHMAC署名付きでPOSTされます。 - 自前サーバーでは上の「ヘッダーと署名検証」のサンプルで署名を検証してから処理してください。
- Zapier「Webhooks by Zapier」やMake「Custom webhook」の受信URL(Catch Hook)をそのまま登録すれば、 ペイロードのJSONを各ツールで受け取り、Gmail送信・スプレッドシート追記など数百のアプリへ分岐できます (ハブ側は署名検証を省くのが一般的です)。
Slack / Discord
- SlackはIncoming Webhook、DiscordはチャンネルのWebhook URLを発行し、送信形式に
slack/discordを選んで貼り付けます。 - 生JSONではなく1行メッセージ(例「[Tessera] エントリ公開: 「blogPost」 エントリ …」)が投稿されます。 署名検証は不要です。
Microsoft Teams
- 従来のIncoming Webhook(Office 365コネクタ)は廃止されたため、後継のPower Automate ワークフローを使います。テンプレート「Send webhook to a channel(チャネルに Webhook を送信)」で作成すると、発行されるURLがMessageCard形式を直接受理します。
- そのURLを送信先に貼り、送信形式に
teamsを選びます。 TesseraはMessageCard(@type: MessageCard)で1行メッセージを送信します。 - ※ 「Post card in a chat or channel」アクションのテンプレートはAdaptive Card必須で、この形式では 投稿できません。上記のWebhook受信テンプレートを選んでください。
Google Chat
- 対象スペースで「アプリと統合 → Webhook」から受信Webhookを作成し、URLをコピーします。
- そのURLを送信先に貼り、送信形式に
googlechatを選びます。{ "text": "..." }形式で1行メッセージを送信します。
デプロイフック(Vercel / Netlify / Cloudflare Pages)
静的サイト(SSG)はビルド時にコンテンツを取り込むため、公開しても再ビルドするまで本番へ 反映されません。デプロイフックを使うと、公開のたびに自動で再ビルドできます。
- ホスティング側で Deploy Hook / Build Hook を作成し、URLをコピーします(Vercel: Settings → Git → Deploy Hooks、Netlify: Build & deploy → Build hooks、Cloudflare Pages: Settings → Builds & deployments)。
- 送信形式に
deployを選び、そのURLを登録します。発火イベントはentry.publishedのみを推奨(下書き保存のたびにビルドが走るのを避けるため)。 - デプロイフックはボディを見ないため、Tesseraは最小の
{}のみを送り、コンテンツ本文は送りません(署名も付きません)。 - ※ 送信形式
jsonでも同じURLに繋げますが、その場合は生イベントJSONが送られます。手軽さのためdeployを推奨します。
Chatwork
- Chatworkの「サービス連携 → API Token」でAPIトークンを発行します。
- 通知したいルームのIDを確認し、送信先URLを
https://api.chatwork.com/v2/rooms/{ルームID}/messagesの形式で入力します。 - 送信形式に
chatworkを選び、APIトークンを入力して保存します。トークンは暗号化して保存され、画面には 再表示されません(変更する場合のみ再入力)。 - 指定ルームに1行メッセージが投稿されます(
X-ChatWorkTokenヘッダによるトークン認証・署名は付きません)。
エラーケース
署名が一致しない
最も多い原因は、生ボディではなくパース後に再文字列化した値で署名を計算していることです。JSON.parse する前の生の文字列(バイト列)に対してHMACを計算してください。次に多いのは、署名対象を<timestamp>.<body> の連結にしていない(ボディのみで計算している)ケースです。
配信が失敗のまま増える
受信側が2xx以外を返している、または10秒以内に応答していない可能性があります。「最近の配信」の 「応答/エラー」列でHTTPステータスやエラー内容を確認してください。5回失敗するとfailed になり自動リトライは止まります。原因を直したうえで「再送」してください。
無効化・削除したエンドポイント宛の配信
エンドポイントを無効化・削除した後に配信ワーカーが処理した未送信分は、failed(endpoint is inactive or was deleted)になります。停止したい場合は無効化すれば、以後の新規イベントはエンキューされません。
関連ドキュメント
- GraphQL API — Webhookの発火元になるMutationの詳細。
- MCPサーバー — MCP経由の変更もWebhookの対象です。
- 認証・APIキー — APIキーのスコープ・失効の考え方。