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-urlencodedbody=...)で送信します。deploy はボディを見ない宛先向けのため、コンテンツを含まない 最小の {} のみを送ります。

ヘッダーと署名検証

各配信リクエストには次のヘッダーが付きます。

ヘッダー内容
x-tessera-eventイベント名(例 entry.created
x-tessera-delivery配信ID。リトライや手動再送でも同じ値になるため、受信側の冪等性キーに使えます
x-tessera-timestamp署名時刻(Unix秒)。署名対象に含まれ、鮮度確認(リプレイ対策)に使います
x-tessera-signaturesha256=<hex><x-tessera-timestamp>.<生ボディ> を署名シークレットでHMAC-SHA256した値
user-agentTessera-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が秘密)と chatworkX-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)になります。停止したい場合は無効化すれば、以後の新規イベントはエンキューされません。