Webhooks — イベント、 署名、 リプレイ防止
Webhook は
api_versionで webhook 単位に契約を出し分ける。 既存 (v1) は v1 形式 body +X-Webhook-*、 新規は v2 契約で配信される。
契約バージョン (api_version) による出し分け
api_version | HTTP 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:
currentとnextの 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" } }
}
id は evt_ prefix + 16 byte 暗号乱数を URL-safe base64 (= 22 文字) でエンコードしたもの。 sortable ではない ため受信側で時系列ソートに使わない (createdAt を使う)。 過去ログとの突合は文字列等価で十分。
data.object は event 種別ごとに polymorphic に変わる:
| event | data.object.type | data.object.id |
|---|---|---|
record.* / approval.* | record | 議事録 ID (rec_...) |
message.created | message | メッセージ ID |
task.* | task | タスク ID |
file.uploaded | file | ファイル ID |
room.created / room.member.added | room | room 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-* は 無条件には送らない:
- v1 契約 (
api_version = 2025-10-23) の行のみ。 v2 契約行には一切送らない WEBHOOK_LEGACY_HEADER_SUNSET_AT(= 2026-12-31) を過ぎたら一切送らない- 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-types | API key / OAuth bearer (scope: events:read) | event catalog の参照 |
GET /v1/rooms/.../webhooks/.../deliveries | scope: webhooks:read | 配信履歴の参照 + 失敗の追跡 |
POST /v1/rooms/.../webhooks/.../deliveries/.../redeliver | scope: 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 timeout | exponential backoff retry |
| 400 / 401 / 403 / 404 / 410 / 422 | non-retriable |
総試行回数は 4 回 (初回 + 最大 3 リトライ)。 exponential backoff は 2s base で 2s → 4s → 8s。 恒久的失敗 (non-retriable) は即時打ち切り。
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 で区別する:
| 条件 | status | code |
|---|---|---|
| 元配信が存在しない | 404 | not_found |
| webhook が inactive | 409 | webhook_inactive |
| stored payload が解釈不能 | 422 | validation_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.approved | v2 alias | stable (発火中) |
file.uploaded | ファイル添付 | stable (発火中) |
room.created | Room 作成 | 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 を完了し、
nextをcurrentに昇格して旧 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 する。