Webhookは識別イベントをリアルタイムでサーバーに配信します。訪問者が識別される
たびに、TRACIOは構成されたWebhook URLへHTTP POST リクエストを送信します。
リクエストボディがそのままイベントペイロードです。
また、遅延判定 — ページの読み込み後に、訪問者の挙動によって自動化であることが 判明したもの — を届ける唯一のチャネルでもあります。
設定はダッシュボードの設定 → Webhookで行います。WebhookにはProプラン以上が 必要です。
| イベント | タイミング | プラン |
|---|---|---|
identification | すべての訪問で — primary、late、correction の各フェーズ | すべて |
account_takeover | アカウント上の挙動が所有者のプロファイルと一致しなくなったとき | Business以上 |
attack_detected | サイト上でのボットの急増 | Business以上 |
reputation_changed | デバイスの背後にいる人物のレピュテーションが変化したとき | Business以上 |
イベント名はアンダースコアで構成され、ドットは使いません — visitor.created や
session.created のようなものは存在しません。reputation_changed はperson
レイヤーを必要とするため、クロスデバイスのアイデンティティ解決が有効な
ワークスペースでのみ発火します。
Webhookは特定のタイプを購読します。別に用意された値 * は「今後追加されるものも
含む、すべてのタイプ」を意味します。未知のタイプは購読の作成・編集時に 400 で
拒否されるため、打ち間違いによって「何も発火しないWebhook」が黙って残ることは
ありません。
identification イベントのフェーズ1回の訪問で、同じ requestId を共有する最大3件の配信が生成されます:
primary — ページ読み込み時の最初の判定。late — およそ9秒後、時間のかかるチェックが揃った時点でのエンリッチメント。correction — 挙動(ポインター、キーボード、スクロール)に基づく訂正。requestId で相関付けし、phase で区別します。後のフェーズが優先されます:
primary が human と述べ、correction が bot と述べている場合、正しい答えは
後者です。
到着順に依存しないでください。 各フェーズは独立して、それぞれ独自のリトライ
スケジュールで配信されます — primary がリトライに入っている間に late が初回で
成功すれば、逆の順序で受け取ることになります。優先関係は受信時刻ではなく phase
フィールドから判断してください。
identification イベントのフェーズはこの3つだけです。ただし、もう1つの値が届きます:
account_takeover は phase: "beacon" を運びます。アカウント乗っ取りのアラートは、
常に挙動ビーコンからのみ発せられるためです。
ここで生じる食い違いに注意してください。冪等性に影響します。本番の identification
配信では eventId はちょうど <requestId>:<phase> になりますが、2種類の配信は
この式から外れます。account_takeover では <requestId>:ato であり、サフィックスは
リテラルの ato であって phase フィールドの値ではありません。ダッシュボードから
送られるテスト配信では <requestId>:test になりますが、そのスキーマ2ボディの
phase は依然として primary を示します — さらにスキーマ1のボディには phase
フィールド自体が存在しないため、そのサフィックスが現れる場所はヘッダーだけです。
eventId はそのまま冪等性キーとして使い、requestId と phase から組み立て直す
ことは絶対に避けてください。自分が扱う値に一致させ、それ以外は配信を拒否するのでは
なく無視してください。
attack_detected はワークスペースレベルのイベントです: requestId も visitorId
も持たず、browser、geo、bot、decision の各ブロックもありません — これらの
キーは単に存在しません。account_takeover は特定の訪問から生成され、ご利用の
プランに応じた識別ボディ一式に accountAlert ブロックを加えたものを運びます。
すべてのイベントを1つのハンドラーで解析する場合は、訪問に関するフィールドに
触れる前に event を確認してください。
| バージョン | 対象 | 切り替え方法 |
|---|---|---|
1 | v2が存在する前に作成されたWebhook | それらのWebhookでは既定のまま |
2 | 新規のWebhook | ダッシュボードのWebhookカードにあるトグル |
スキーマv1は凍結されています — フィールドは一切変更されないため、既存の統合は 手を入れずに動作し続けます。新しいものはすべてv2に入り、新規のWebhookはv2を 送信します。
{ "version": 2, "event": "identification", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:primary", // "<requestId>:<phase>" — the idempotency key "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", // visit identifier, shared by all phases "phase": "primary", "visitorId": "X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y", "linkedId": "user-42", // your ?lid=, if you passed one "tag": "checkout", "timestamp": "2026-07-30T12:00:00Z", "url": "https://shop.example.com/checkout", "ip": "203.0.113.44", "userAgent": "Mozilla/5.0 …", "browser": { "name": "Chrome", "version": "138" }, "os": { "name": "macOS", "version": "15.5" }, "device": "desktop", "geo": { "country": "DE", "city": "Berlin", "lat": 52.52, "lon": 13.405, "timezone": "Europe/Berlin" }, "network": { "vpn": false, "proxy": true, "tor": false, "datacenter": true, "connectionType": "DCH" }, "bot": { "result": "human", "score": 3.2 }, // human | bot | uncertain "identification": { "confidence": 0.97, "incognito": false }, "decision": { "action": "real", "riskScore": 12.2 } // real | fake | suspicious}ゼロ値と空の値は省略されます。 ゼロ値を持つ文字列・数値フィールド(人間に対する
bot.type など)はJSONに存在しません — スキーマで必須にせず、ネストされたブロックは
防御的に読み取ってください。
bot.score と decision.riskScore は、小数第1位までの**0..100**スケールの小数です
— 同じ訪問についてダッシュボードが報告するのとまったく同じ数値です。(凍結されたv1
スキーマでは単位が異なり、それぞれ 0..1 の割合と 0..255 です。)
bot.type は、認識されたボットの名前かファミリーのラベルのいずれかです。語彙は
ボットタイプを参照してください — 内部のチェック名が
公開されることは、どのプランでもありません。
| フィールド | 型 | 説明 |
|---|---|---|
version | number | ペイロードスキーマのバージョン(2) |
event | string | イベントタイプ |
eventId | string | 配信識別子 — 冪等性キー |
requestId | string | 訪問識別子(UUID)。その訪問のすべてのフェーズで共有されます |
phase | string | primary、late、correction。account_takeover は beacon を運ぶ |
visitorId | string | 安定した訪問者識別子 |
linkedId | string | クライアントが指定したリンク済み識別子 |
tag | string | クライアントが指定したカスタムタグ |
timestamp | string | イベント時刻(RFC 3339) |
url | string | イベントがキャプチャされたページURL |
ip | string | クライアントのIPアドレス |
userAgent | string | 生のクライアントuser-agent文字列 |
browser.name / .version | string | 検知されたブラウザ |
os.name / .version | string | 検知されたオペレーティングシステム |
device | string | デバイスクラス(例: desktop、mobile) |
geo | object | IP位置情報: country、city、lat、lon、timezone |
network | object | vpn、proxy、tor、datacenter(ブール値)と connectionType |
bot.result | string | human、bot、uncertain のいずれか |
bot.type | string | ボット検知時のボット名またはファミリーのラベル |
bot.score | number | ボットスコア(0〜100) |
identification.confidence | number | 識別の信頼度(0.0〜1.0) |
identification.incognito | boolean | プライベート/シークレットブラウジングのコンテキスト |
decision.action | string | real、fake、suspicious のいずれか |
decision.riskScore | number | 集約リスクスコア(0〜100) |
Pro以上 — 訪問者が時間の経過とともにどう振る舞うか:
{ "identification": { "matchType": "exact", // exact | fuzzy | new — how the visitor was recognized "matchConfidence": 0.93, "visits": 42, "incognitoVisits": 3 }, // Present when visitor counters are available at event time (usually primary). // A missing block means "no data", not "zeros". "velocity": { "events5m": 7, "uniqueIps": 2, "uniqueLocations": 1 }, "bot": { "antidetectScore": 0 }, // antidetect indicators, 0..100 "session": { "durationSeconds": 95 } // where the visit duration is already known}Business以上 — なぜその判定になったか:
{ "reasons": [ // at most 8, sorted by importance { "code": "headless_browser", "severity": "high" }, { "code": "privacy_hardening", "severity": "low" } ], // Behavioral biometrics — present only when behavioral scoring ran for the // visit. A missing block means "no data", never "nothing suspicious". "behavior": { "score": 87, "verdict": "human", "confidence": 0.92 }, "identification": { "driftScore": 0.31 }, // divergence from the account profile "deviceInfo": { "deviceId": "…", // the physical device across browsers on it "crossBrowser": true, "confidence": 0.88, "linkedBrowsers": 3 }, "network": { "isp": "Deutsche Telekom", "asn": 3320 }, "decision": { "suspectScore": 55 }, "guidance": { "version": 1, "overall": "review" } // see below}理由コードの語彙と severity の意味については
ボット検知を参照してください。
guidance は、統合ポイントごとの「どうすべきか」という既成の推奨を運びます。生の
スコアからポリシーを導き出す必要はありません:
{ "guidance": { "version": 1, "overall": "review", // the strictest advice across the scenarios "payment": "review", // whether to accept the payment "registration": "challenge", // whether to create the account "login": "challenge", // whether to let them into the account "affiliate": "review", // whether to credit the conversion to the partner "basis": ["risk", "network"] // the axes that determined the advice }}各シナリオは allow から始まり、ラダーを上にしか進みません:
allow → challenge → review → deny。シナリオ内では発火した最も厳しい軸が
優先され、overall は4つのシナリオ全体で最も厳しいものになります。
| 推奨 | 決済 | 登録 | ログイン | アフィリエイト |
|---|---|---|---|---|
allow | 処理する | 作成する | 入室を許可する | コンバージョンを計上する |
challenge | 3-Dセキュア/確認 | CAPTCHA、メールまたは電話での確認 | ステップアップ2FA、再認証 | 活動が確認されるまで疑わしいものとして印を付ける |
review | 処理するが、レビュー待ちに入れる | 制限付きで作成する | 入室を許可し、アラートを上げる | レビューが済むまで支払いを保留する |
deny | 取引を処理しない | アカウントの作成を拒否する | 入室を許可しない | コンバージョンを計上しない |
version はルールセットのバージョンで、ロジックが改善されるたびに上がります。
ガイダンスは追加的です: 新しいシナリオは新しいキーとして届き、契約を壊しません。
後のフェーズが優先されますが、部分的な推奨は例外です: 不完全な入力セットで
計算された配信には "partial": true が付き、部分的な推奨は同じ requestId に
ついて先に受け取った完全な推奨を上書きしません。通常の配信では partial
フィールドは完全に存在しません。
正確なしきい値は意図的に文書化されていません。スコアに逆算できてしまう推奨は、 もはや防御にはなりません。
account_takeover イベントBusinessおよびEnterpriseのみ。ボディは、ご利用のプランに応じた識別エンベロープ一式に
accountAlert ブロックを加えたもので、1回の訪問につき最大1回配信されます:
{ "version": 2, "event": "account_takeover", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:ato", "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "phase": "beacon", // アラートはビーコンから発せられる。"ato" と言うのは eventId だけ "accountAlert": { "type": "behavior-drift", "accountId": "user-42", // your linkedId for the account "driftScore": 0.83 } // …the remaining identification fields}v1ではこのブロックは type、linkedId、drift を運びます。v2では2つのフィールドが
改名されています — linkedId → accountId、drift → driftScore。
payloadVersion を切り替える際にはハンドラーを更新してください。さもないと、
アカウント乗っ取りのロジックは黙ってデータを見なくなります。
attack_detected イベント{ "version": 2, "event": "attack_detected", "eventId": "c0a8e1f2-…", "timestamp": "2026-07-30T12:00:00Z", "attack": { "kind": "bot_spike", "severity": "critical", // info | warning | critical "windowMinutes": 15, "recentBots": 4210, "recentTotal": 5100, "expected": 180.5 // the baseline expected over a window this size }}すべての配信には X-Tracio-Signature ヘッダーが含まれます:
X-Tracio-Signature: t=1710432000,v1=5257a869e7ecebed…t はリクエストが署名されたUnixタイムスタンプ(秒)です。v1 は、Webhookシークレットでキー付けされた "<t>.<rawRequestBody>" の
16進エンコードされたHMAC-SHA256です。タイムスタンプは署名対象のコンテンツの一部であり、これがリプレイ保護になります。
正しく行うべき点が2つあります。外すと本番で検証に失敗します:
v1= の値が一致すれば受け入れてください。 シークレットの
ローテーション中、ヘッダーは2つの署名を運びます。片方しか保持しないパーサーは、
ローテーションの期間を通じて正当な配信を拒否し続けます。// Express.js exampleimport express from "express"import crypto from "crypto"
const app = express()
// Capture the raw body so the signature can be verified byte-for-byte.app.use( express.json({ verify: (req, _res, buf) => { ;(req as any).rawBody = buf }, }),)
function verifySignature(rawBody: Buffer, header: string, secret: string): boolean { if (!header) return false
const parts = header.split(",").map((p) => p.trim()) const ts = parts.find((p) => p.startsWith("t="))?.slice(2) if (!ts) return false
// Replay protection: reject timestamps more than five minutes old. if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
// Sign the raw bytes: the "<t>." prefix plus the raw request body. const signed = Buffer.concat([Buffer.from(`${ts}.`, "utf8"), rawBody]) const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex") const exp = Buffer.from(expected, "hex")
// During a rotation window the header carries several v1= — any may match. return parts.some((p) => { if (!p.startsWith("v1=")) return false const got = Buffer.from(p.slice(3), "hex") // Compare lengths BEFORE timingSafeEqual: it throws on differing lengths, // and one junk header would turn the handler into a 500. return got.length === exp.length && crypto.timingSafeEqual(got, exp) })}
app.post("/webhook/tracio", (req, res) => { const header = req.headers["x-tracio-signature"] as string if (!verifySignature((req as any).rawBody, header, WEBHOOK_SECRET)) { return res.status(401).json({ error: "Invalid signature" }) }
const event = req.body console.log(`Visitor: ${event.visitorId}`) console.log(`Bot: ${event.bot?.result}`) // "human" | "bot" | "uncertain"
res.status(200).send("OK")})スキーマ2の配信には、追加で X-Tracio-Signature-Ed25519
(t=<unix>,kid=<id>,v1=<base64>)が含まれます。HMACシークレットは双方が知って
いるため、HMACは送信者がシークレットを知っていることは証明しますが、リクエストが
TRACIO発であることは証明しません。非対称署名はそれを証明します。公開鍵は
https://api.tracio.ai/.well-known/webhook-keys で kid をキーとして公開されています。
ダッシュボードから送信されるテスト配信はHMACのみで署名されます — プラットフォームの
秘密鍵は配信ノード上にあり、ダッシュボードからは意図的に利用できないように
なっています。Ed25519を必須とする検証処理は、テスト配信を通す必要があります
(テスト配信は eventId に :test サフィックスが付きます)。そうしないと、本番は
正常なのにダッシュボードからのテストだけが失敗します。同じ注意は形式チェックにも
当てはまります: テスト配信の requestId は test_<hex> という形をとり、visitorId
はリテラルの test_visitor です。したがって、これらを本番の形式に照らして検証する
ハンドラーは、それ以外の点では正しく組み立てられた配信を拒否してしまいます。
ローテーション後、両方のシークレットが24時間有効なままとなり、ヘッダーは両方の署名を 運ぶため、配信を失うことなく設定を更新できます。今すぐ失効アクションはこの期間を 短縮します。お客様側のシークレットは24時間以内に更新してください: 期間が閉じると 古いシークレットは一致しなくなり、無効な署名に対してエンドポイントが4xxを返す場合、 そうした応答が5回続くとWebhookが無効化されます。
| ヘッダー | 説明 |
|---|---|
Content-Type | application/json |
X-Tracio-Signature | t=<unix>,v1=<hmac_sha256_hex> — ローテーション期間中は v1= が2つ |
X-Tracio-Signature-Ed25519 | プラットフォーム署名、t=<unix>,kid=<id>,v1=<base64>(v2のみ) |
X-Tracio-Event-Id | 配信識別子 — 冪等性キー |
X-Tracio-Request-Id | 訪問識別子(v2、訪問イベントのみ) |
X-Tracio-Event-Type | イベントタイプ(v2のみ) |
X-Tracio-Delivery-Attempt | 試行番号。1から始まります(v2のみ) |
X-Tracio-Payload-Version | 2(v2のみ) |
X-Tracio-Webhook-Id | この配信を生成したWebhookの識別子 |
配信はリトライされることがあり、リトライは同じ X-Tracio-Event-Id を運びます。
これで重複を排除してください:
app.post("/webhook/tracio", async (req, res) => { const eventId = req.headers["x-tracio-event-id"] as string
const existing = await db.webhooks.findOne({ eventId }) if (existing) return res.status(200).send("Already processed")
await db.webhooks.insert({ eventId, processedAt: new Date() }) await processWebhookEvent(req.body)
res.status(200).send("OK")})eventId はWebhookごとではなくイベントごとに一意である点に注意してください:
ワークスペース内の複数のWebhookが同じイベントを購読している場合、それぞれが同じ
識別子を持つ配信を受け取ります。この値は <requestId>:<phase> として組み立てられる
ため、1回の訪問の3つのフェーズは1つにまとまるのではなく、それぞれ独立して
重複排除されます。
2xx を返してください — 配信が受け入れられたことを示す唯一の合図です。
| 応答 | 動作 |
|---|---|
2xx | 配信完了 |
429 Too Many Requests | 失敗として数えられず、試行も消費しません。より長い Retry-After は尊重されます |
408、425、5xx、接続の切断 | 待機時間を伸ばしながらリトライされます |
410 Gone | エンドポイントは削除されたものとして扱われ、Webhookは直ちに無効化されます |
その他の 4xx | リトライされますが、5回連続でWebhookが無効化されます — 400/401/404 はリトライでは治りません |
リトライのスケジュール: 5s → 30s → 2min → 10min → 30min → 2h → 6h(8回の試行)。 最初のリトライは1分以内に収まるため、サービスの短時間の再起動で通知を失うことは ありません。各待機時間は半分から全量の間でランダム化されるため、障害の復旧後に リトライが一斉に発火することはありません。
自動無効化には、しきい値(連続20回の失敗、または5回の設定エラー)と、 少なくとも15分間連続して失敗が続くことの両方が必要です — 多くの配信がキューに 溜まっていたとしても、短時間の再起動で統合が停止することはありません。15分を超える 間隔が空くとカウントはやり直しになります。ダッシュボードには理由が応答コードと エラーテキストとともに表示され、カウンターをリセットする再有効化ボタンが あります。
| プラン | ワークスペースあたりのWebhook数 |
|---|---|
| Free | 利用できません |
| Pro | 5 |
| Business | 20 |
| Enterprise | 100 |
エンドポイントはパブリックIPを持つ https である必要があります — プライベート
アドレスとループバックアドレスは、リダイレクト先であっても拒否されます — また、
リダイレクトは2段までです。
追跡されるリダイレクトは 307 と 308 のみです。 301、302、303 は
クライアントにGETへの切り替えとボディの破棄を指示するため、配信はこれらを追跡せず、
その試行は失敗として数えられます。ロードバランサーがURLを正規化する場合
(www や末尾のスラッシュの付加)、Webhookは最終的なURLへ直接向けてください。
Webhookはダッシュボードで管理します。ダッシュボードはワークスペーススコープの
管理APIを駆動しており、このAPIはアプリケーションホスト(例:
https://app.tracio.ai/api/v1)で提供されます。以下のエンドポイントは、その
ダッシュボードが呼び出しているものそのものです。Webhookのエンドポイントはすべて
/workspaces/{wsId} の下にあります。
これはサーバー間連携のための面ではありません。 管理APIが受け付けるのは ダッシュボードのセッションJWTだけで、ワークスペース内のロール(RBAC)に 照らして検査されます。
tracio_sk_…のシークレットキーはここでは拒否されます。 そのセッションはブラウザー内に存在し、ブラウザーとともに失効するため、以下の 呼び出しは自動化する連携先ではなく、ダッシュボードが何をしているかの説明として 捉えてください。自前のバックエンドからプログラムでアクセスする場合は、読み取り 専用の Server API を使用してください。
curl -X POST https://app.tracio.ai/api/v1/workspaces/{wsId}/webhooks \ -H "Authorization: Bearer <session-jwt>" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhook/tracio", "events": [] }'署名シークレットはTRACIOが生成し、作成時(およびローテーション時)に一度だけ
signingSecret として返されます。安全に保管してください — 署名の検証に使う鍵です。
{ "ok": true, "data": { "id": "b3d4f8a1-2c67-4e9b-8f05-7a1d3c9e2b48", "workspaceEnvironmentId": "b201f2ba-…", "url": "https://your-server.com/webhook/tracio", "events": [], "signingSecret": "f3a9…<hex>", "status": "active", "successRate": 100, "createdAt": "2026-07-30T12:00:00Z" }}以降の読み取りでは signingSecret はマスクされます(null) — 公開されるのは作成時と
シークレットのローテーション時のみです。
| メソッド | パス | 説明 |
|---|---|---|
GET | /workspaces/{wsId}/webhooks | Webhookの一覧 |
PATCH | /workspaces/{wsId}/webhooks/{webhookId} | url / events / status の更新 |
DELETE | /workspaces/{wsId}/webhooks/{webhookId} | Webhookの削除 |
POST | /workspaces/{wsId}/webhooks/{webhookId}/test | 署名済みのテスト配信を送信 |
POST | /workspaces/{wsId}/webhooks/{webhookId}/secret/rotate | 署名シークレットの再生成 |
GET | /workspaces/{wsId}/webhooks/{webhookId}/deliveries | 直近の配信試行の一覧 |
タイムアウトを避けるため、できるだけ早く 2xx を返し、ペイロードは非同期で処理して
ください:
app.post("/webhook/tracio", async (req, res) => { res.status(200).send("OK") processWebhookEvent(req.body).catch(console.error)})
async function processWebhookEvent(event: WebhookPayload) { await db.events.insert(event)
if (event.decision?.riskScore > 50) { await alertFraudTeam(event) }
if (event.bot?.result === "bot") { await blockVisitor(event.visitorId) }}Webhookのテストアクション(または POST .../webhooks/{webhookId}/test)を使うと、
署名済みのサンプルペイロードをエンドポイントへ送信し、到達可能であること、署名を
正しく検証していることを確認できます。
ローカル開発では、ngrok などのトンネルでサーバーを公開してください:
ngrok http 3000# Use the generated URL as your webhook endpoint