メインコンテンツまでスキップ

Webhooks — イベント、 署名、 リプレイ防止

Webhook は api_version で webhook 単位に契約を出し分ける。 既存 (v1) は v1 形式 body + X-Webhook-*、 新規は v2 契約で配信される。

契約バージョン (api_version) による出し分け

api_versionHTTP body署名ヘッダーUser-Agent
2025-10-23 (v1, 既存)v1 形式 {event, timestamp, roomId, data, deliveryId}X-Webhook-Signature (sunset まで) + Tascha-Webhook-* (移行用に並送)TasCha-Webhook/1.0
2026-06-09 (v2)v2 形式 (下記)Tascha-Webhook-* のみTasCha-Webhook/2.0
  • 既存の webhook は v1 契約のまま配信され、 receiver の挙動は変わらない。 新規登録は v2 契約。
  • v1 行にも Tascha-Webhook-Signature (raw body 方式) が並送されるため、 receiver は body 形式を変えずに署名検証だけ先に v2 へ移行 できる。 その後 api_version を進めて body を v2 化する 2 段階移行が可能。
  • v1 → v2 の昇格は webhook の再作成で行う (自動昇格・部分更新は非対応)。

設計思想

  • Payload は ID-only にする。 詳細データは API で取得する (Notion 同様)
  • HMAC-SHA256 を raw body bytes に対して計算 (JSON.stringify ではない)
  • signature には timestamp を含め、 5 分の replay window
  • secret は rotation: currentnext の 2 本
  • delivery 失敗時の retry / redelivery API を提供

Payload 形式 (Webhook v2)

{
"id": "evt_NTcwY2NjZGM2N2QxZjFiZA",
"type": "record.created",
"apiVersion": "2026-06-09",
"createdAt": "2026-06-09T12:00:00.000Z",
"organizationId": "org_...",
"roomId": "room_...",
"data": { "object": { "id": "rec_...", "type": "record" } }
}

idevt_ prefix + 16 byte 暗号乱数を URL-safe base64 (= 22 文字) でエンコードしたもの。 sortable ではない ため受信側で時系列ソートに使わない (createdAt を使う)。 過去ログとの突合は文字列等価で十分。

data.object は event 種別ごとに polymorphic に変わる:

eventdata.object.typedata.object.id
record.* / approval.*record議事録 ID (rec_...)
message.createdmessageメッセージ ID
task.*taskタスク ID
file.uploadedfileファイル ID
room.created / room.member.addedroomroom ID

詳細データが必要なら GET /v1/rooms/{roomId}/records/{recordId} 等で取りに行く (Public API)。

Headers

Tascha-Webhook-Id: evt_...
Tascha-Webhook-Delivery: <uuid>
Tascha-Webhook-Timestamp: 1780987200
Tascha-Webhook-Signature: v1=<hex> # rotation 中は "v1=<hex>,v1=<hex>"
Tascha-Webhook-Type: record.created
Tascha-Webhook-Api-Version: 2026-06-09
User-Agent: TasCha-Webhook/2.0

# v1 契約行のみ (sunset 2026-12-31 まで): 旧 v1 ヘッダーも並行送信される
X-Webhook-Signature: <hex> # HMAC-SHA256 over 実送信 raw body (v1 形式)
X-Webhook-Event: record.created
X-Webhook-Delivery-Id: <uuid>
Sunset: Thu, 31 Dec 2026 00:00:00 GMT # RFC 8594 (deprecation 予告)
Deprecation: true

Tascha-Webhook-Delivery は配信冪等キーで、 同一ジョブの自動リトライ間では同一値、 redeliver では新しい値になる。 Public API の配信履歴では deliveryId フィールドとして返り、 ヘッダーと突合できる (履歴行そのものの ID は id)。

カスタムヘッダーの優先順位

webhook 登録時に指定したカスタムヘッダーは Content-Type / User-Agent の既定値を 上書きできる (v1 からの互換挙動。 application/vnd.api+json 送信や受信側の UA allowlist 連携を想定)。 一方、 署名・配信メタの契約ヘッダー (Tascha-Webhook-*、 legacy X-Webhook-*Sunset / Deprecation) はカスタムヘッダーでは 上書き・偽装できない。

legacy ヘッダーの送出条件 (sender 側)

X-Webhook-*無条件には送らない:

  1. v1 契約 (api_version = 2025-10-23) の行のみ。 v2 契約行には一切送らない
  2. WEBHOOK_LEGACY_HEADER_SUNSET_AT (= 2026-12-31) を過ぎたら一切送らない
  3. v1 enum に含まれない event (例: message.created, task.created, file.uploaded 等) は v2 ヘッダーのみで送る — v1 receiver は知らない event のため v1 signature を持っても意味がないため

X-Webhook-Signature実送信 raw body (v1 契約行では v1 形式 JSON) への HMAC で、 旧実装の HMAC(JSON.stringify(payload)) と bit-exact に一致する。 既存 receiver の検証コードはそのまま通る。

receiver は新 SDK で Tascha-Webhook-Signature を使うよう移行し、 sunset までに X-Webhook-* の参照を削除すること。

Rotation 中の Tascha-Webhook-Signature

Secret rotation の overlap window 中は 2 つの v1 値 をカンマ区切りで送る:

Tascha-Webhook-Signature: v1=<currentHex>,v1=<nextHex>

受信側は どちらかが 自分が保持する secret で一致すれば valid とみなすことで、 missed delivery なく rolling できる。

注: 旧 X-Webhook-Signature は単一値しか表現できないため、 rotation 中も current secret の署名のみ。 rotation を使う receiver は Tascha-Webhook-Signature の検証へ移行していること。

署名検証 (最小実装)

import { createHmac, timingSafeEqual } from "node:crypto";

const REPLAY_WINDOW_SECONDS = 300;

/**
* 1 つ以上の secret と 1 つ以上の v1 署名値を突き合わせて検証する。
*
* - sender は rotation 中 `Tascha-Webhook-Signature: v1=<hex>,v1=<hex>` の形で
* current / next 両方の HMAC を載せる。 receiver はこのどちらかが自分の
* stored secret で一致すれば valid とみなして良い。
* - receiver 自身が secret rotation を回している場合 (旧/新 両方を持っている期間)、
* `secret` を配列で渡せばどの組み合わせでも検証できる。
*/
export function verifyTaschaWebhook(opts: {
rawBody: Buffer; // ⚠️ JSON parse 前の raw bytes
timestamp: string; // Tascha-Webhook-Timestamp
signatureHeader: string; // "v1=<hex>" or "v1=<hex>,v1=<hex>"
secret: string | string[]; // 現行 + (rotation 中なら) next も渡せる
}): boolean {
const now = Math.floor(Date.now() / 1000);
const ts = Number(opts.timestamp);
if (!Number.isFinite(ts) || Math.abs(now - ts) > REPLAY_WINDOW_SECONDS) {
return false;
}
const provided: Buffer[] = [];
for (const part of opts.signatureHeader.split(",")) {
const match = /^v1=([0-9a-f]+)$/i.exec(part.trim());
if (match) provided.push(Buffer.from(match[1]!, "hex"));
}
if (provided.length === 0) return false;
const secrets = Array.isArray(opts.secret) ? opts.secret : [opts.secret];
for (const secret of secrets) {
const expected = createHmac("sha256", secret)
.update(`${opts.timestamp}.`)
.update(opts.rawBody)
.digest();
for (const sig of provided) {
if (sig.length === expected.length && timingSafeEqual(sig, expected)) {
return true;
}
}
}
return false;
}

重要: JSON.parse(...)しない 状態で署名を作るのが大前提。 Express / NestJS なら raw body middleware を有効にする。

webhook 管理エンドポイント

外部開発者向け SDK は Public API (/v1/...) だけで完結する。

Path 例Auth用途
GET /v1/event-typesAPI key / OAuth bearer (scope: events:read)event catalog の参照
GET /v1/rooms/.../webhooks/.../deliveriesscope: webhooks:read配信履歴の参照 + 失敗の追跡
POST /v1/rooms/.../webhooks/.../deliveries/.../redeliverscope: webhooks:write単体 redelivery
  • webhook の登録・更新・削除・secret rotation は TAS-CHA の設定画面 (/settings/developer) から行う。
  • 履歴の閲覧・特定 delivery の再送・event 一覧の取得は Public API 側で完結する。
  • 平文 secret を返す操作や receiver response の preview は Public API では扱わない (二次漏洩リスクの低減)。
  • secret rotation は自動で promote される。

Retry / redelivery

応答扱い
2xx成功
3xx失敗 (リダイレクトを追わない)
408 / 429 / 5xx / network timeoutexponential backoff retry
400 / 401 / 403 / 404 / 410 / 422non-retriable

リトライは最大 4 回、 exponential backoff (2s base = 2s → 4s → 8s)。 恒久的失敗は即時打ち切り。

Redelivery API

POST /v1/rooms/{roomId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver
Authorization: Bearer tcha_at_... # scope: webhooks:write

元の eventId を維持したまま 新しい deliveryId で 1 回再送する。 受信側は Tascha-Webhook-Id を冪等キーに使うことで、 「人手で何度再送されても 1 度しか処理しない」運用が成り立つ。 パスパラメータの deliveryId には配信履歴の行 ID (PublicWebhookDelivery.id) を渡す。

レスポンスは { delivery: PublicWebhookDelivery }。 配信観測フィールド (statusCode / durationMs / finalizedAt 等) は通常配信と同じスキーマで返る。 HTTP 配信自体の失敗は 200 + delivery.success=false で表現し、 前提条件エラーは status で区別する:

条件statuscode
元配信が存在しない404not_found
webhook が inactive409webhook_inactive
stored payload が解釈不能422validation_error

responseBodyPreview (受信側 server が返した response 先頭 2KB) は Public API では返却しない。 内容に受信側の internal stack trace / 接続文字列 / PII が混入しうるため、 同 room の別 API キー保持者への二次漏洩を避ける。

Event Catalog API

GET /v1/event-types # scope: events:read

Webhook で subscribe 可能な event 一覧 (type / description / stable)。 SDK の選択肢生成や管理 UI に使う。 stable=false は preview で破壊的変更が入りうる。

イベント一覧

stable=true実際に発火する event。 stable=false (preview) の event はカタログ (GET /v1/event-types) に載るが まだ発火しない

イベント説明状態
record.created / .updated / .deleted議事録ライフサイクルstable (発火中)
approval.requested / .approved / .rejected / .withdrawn承認フローstable (発火中)
room.member.addedメンバー追加stable (発火中)
message.createdメッセージ送信stable (発火中)
task.created / .updatedタスク作成 / 更新stable (発火中)
record.approval_requested / record.approvedv2 aliasstable (発火中)
file.uploadedファイル添付stable (発火中)
room.createdRoom 作成preview (room 単位購読では配信不可)

やってはいけないこと

  • payload の id を冪等キーにしない (Tascha-Webhook-Delivery を使う)
  • 署名検証前に payload を信頼しない / DB に書かない
  • secret をログに出さない / Slack に貼らない
  • public IP に直接 webhook を受け、 ファイアウォール / 認証なしで放置しない (SSRF 等)
  • 内部 LAN 内 URL を webhook 先に登録する (SSRF 対策で TAS-CHA が拒否する)。 同一 VPC 等の正当な内部受信先が必要な場合は、 運用者が許可リストへ明示登録することで個別に許可できる

Secret rotation

secret rotation は TAS-CHA の設定画面 (/settings/developer) から開始する。

  • 新しい secret (nextSecret) は 開始時に 1 度だけ表示される。 保管は利用者側で行う。
  • rotation 期間中は sender が current + next の両署名を付与する。 受信側はどちらかで検証成功すれば valid。
  • 受信側の rollout が完了したら rotation を完了し、 nextcurrent に昇格して旧 secret を無効化する。
  • rotation 期間の既定は 7 日、 最大 90 日。 current + next が無期限に並存する状態は作れない。

既存仕様との互換

旧 v1 Webhook モデルは 同じ row に共存 する形で残る:

  • 既存行は apiVersion = 2025-10-23 (v1) に backfill 済み。 v1 形式 body + 検証可能な X-Webhook-Signature を従来どおり受け取り続け、 デプロイによる破壊的変更はない
  • 新規登録は apiVersion = 2026-06-09 (v2)。 v2 形式 body + Tascha-Webhook-* のみ。
  • v1 行への旧 X-Webhook-* 送出は WEBHOOK_LEGACY_HEADER_SUNSET_AT (2026-12-31) まで。 v1 受信側はそれまでに Tascha-Webhook-* 検証へ移行し、 その後 apiVersion を v2 へ進めて body 移行を完了させる。

イベントの発火状況

event catalog の 14 イベントが stable。 発火元:

イベント発火タイミング
record.created / record.updated / record.deleted議事録の作成/更新/削除 (内部 UI・Public API 両経路)
approval.requested / approval.approved / approval.rejected / approval.withdrawn承認フローの各遷移
record.approval_requested / record.approved上記の v2 別名 (data.object は record)
room.member.addedメンバー追加
message.createdメッセージ投稿 (Public API / OpenClaw 経由も含む)
task.createdタスク作成 (AI 一括生成・Public API・OpenClaw 含む)
task.updatedタスク更新 (ガントの期間変更含む)
file.uploaded添付ファイルの確定 (message / task)

room.created だけは preview: webhook は room 単位購読のため、 作成直後の room には subscriber が存在せず構造上配信できない (組織単位 webhook 導入時に対応)。

payload は ID-only (data.object = {id, type})。 詳細は Public API で fetch する。