Webhook 将识别事件实时投递到您的服务器。每当有访客被识别,TRACIO 就会向您配置的
webhook URL 发送一个 HTTP POST 请求。请求体本身就是事件载荷。
它们也是唯一交付延迟判定的通道 —— 即页面已经加载之后,访客的行为才证明其为 自动化的那一类判定。
请在仪表盘的设置 → Webhooks 中进行配置。Webhook 需要 Pro 或更高套餐。
| 事件 | 触发时机 | 套餐 |
|---|---|---|
identification | 每次访问 —— primary、late 和 correction 阶段 | 全部 |
account_takeover | 账户下的行为不再与所有者的画像相符 | Business 及以上 |
account_assessment | 账户的账户接管评估状态:已判定且无异常、已判定且存在偏离,或未判定 | Business 及以上 |
attack_detected | 您的站点上出现机器人激增 | Business 及以上 |
reputation_changed | 关联人物的信誉移入了另一个区间 | Business 及以上 |
transaction_state_changed | 您发送到 POST /v2/transactions 的交易结果发生了变化 | Business 及以上 |
login_risk | POST /v2/logins/assess 的登录评估以 challenge 或 review 结束 | 全部 |
trusted_device_enrolled | 某台设备被设为某个账户的受信任设备,或其信任被续期 | 全部 |
trusted_device_revoked | 某台设备的信任被吊销 —— 通过 API 或在仪表盘中 | 全部 |
affiliate_threshold_crossed | 对某个联盟推广者的建议变差(none → review、none → hold、review → hold) | 全部 |
事件名使用下划线,从不使用点号 —— 并不存在 visitor.created 或
session.created。
一个 webhook 订阅特定的类型;单独的取值 * 表示“所有类型,包括日后新增的类型”。
有五种类型是例外:* 不包含 account_assessment、login_risk、
trusted_device_enrolled、trusted_device_revoked 和 affiliate_threshold_crossed
—— 需要逐一显式指定。 account_assessment 报告的是覆盖情况而不是发现,因此大约每次
携带 linked_id 的访问都会到达一次 —— 数量级与其他事件不同。登录风险类和联盟推广类
类型只对调用了相应 API 的集成才有意义。在这些事件出现之前设置的通配符,并不代表同意
接收它们。
在创建或编辑订阅时,未知类型会以 400 被拒绝,因此一个拼写错误不会让您留下一个
悄无声息、永不触发的 webhook。
identification 事件的各个阶段一次访问最多产生三次投递,它们共享同一个 request_id:
primary —— 页面加载时的初始判定。late —— 大约九秒后,在耗时较长的检查落地后的补充增强。correction —— 基于行为(指针、键盘、滚动)的修正。请用 request_id 关联它们,用 phase 区分它们。较晚的阶段优先: 如果 primary
说是 human,而 correction 说是 bot,那么后者才是正确答案。
不要依赖到达顺序。 每个阶段都独立投递,并各自遵循自己的重试计划 —— 如果 primary
进入了重试而 late 首次尝试就成功,您收到它们的顺序就会颠倒。优先级请从 phase
字段判断,而不是从接收时间判断。
这三个就是 identification 事件仅有的阶段。另有一个取值也会到达您这里:
account_takeover 和 account_assessment 携带 phase: "beacon",因为它们是从该会话的
行为信标(beacon)发起的。
请注意由此产生的不一致,因为它会影响幂等性。生产环境中一次 identification 投递的
event_id 恰好就是 <request_id>:<phase>,但其他投递打破了这个公式。
account_takeover 是 <request_id>:ato —— 这里的后缀是字面量 ato,而不是 phase
字段的取值 —— 而 account_assessment 是 <request_id>:ato-state-<state>。从仪表盘发出的
测试投递是 <request_id>:test,而其主体中的 phase 仍然写着 primary —— 模式 1 的主体
则根本没有 phase 字段,因此该后缀只出现在请求头这一处。请直接把 event_id 当作幂等键
使用,绝不要用 request_id 和 phase 重新拼装它。请只匹配您会处理的取值,其余的任何
取值忽略即可,而不是拒绝该次投递。
有些事件没有访问:attack_detected(工作区级别)、reputation_changed(人物级别)、
transaction_state_changed(交易级别)、login_risk、trusted_device_enrolled 和
trusted_device_revoked(账户级别)以及 affiliate_threshold_crossed(联盟推广者级别)。
它们没有 request_id、没有 visitor_id,也没有 browser、geo、bot 或 decision
区块 —— 这些键根本不存在,X-Tracio-Request-Id 头也同样不存在。与之相对,
account_takeover 和 account_assessment 由某一次具体访问产生,携带与您套餐对应的完整
识别主体。如果您在同一个处理函数中解析所有事件,请在访问访问级字段之前先检查 event。
新建的 webhook 使用载荷版本 3。使用版本 1 或 2 的现有 webhook 保留其所选的版本;这两种 旧模式已冻结,并将于 2027-04-12 移除。要迁移,请在仪表盘中的 webhook 卡片上选择 版本 3。
| 版本 | 状态 |
|---|---|
1 | 已冻结;于 2027-04-12 移除 |
2 | 已冻结;于 2027-04-12 移除 |
3 | 新建 webhook 的默认值;snake_case 键 |
版本 3 携带与版本 2 相同的字段和取值,只是键改为 snake_case,并在顶层带有 version: 3。
签名算法和请求头保持不变(X-Tracio-Payload-Version: 3)。您自己的 fields 字典中的键
属于您的数据,不会被重命名。
| v2 | v3 |
|---|---|
eventId | event_id |
requestId | request_id |
visitorId | visitor_id |
linkedId | linked_id |
userAgent | user_agent |
network.connectionType | network.connection_type |
network.proxyDetected | network.proxy_detected |
network.realIp | network.real_ip |
bot.antidetectScore | bot.antidetect_score |
identification.matchType | identification.match_type |
identification.matchConfidence | identification.match_confidence |
identification.incognitoVisits | identification.incognito_visits |
identification.driftScore | identification.drift_score |
decision.riskScore | decision.risk_score |
decision.suspectScore | decision.suspect_score |
deviceInfo | device_info |
deviceInfo.deviceId | device_info.device_id |
deviceInfo.crossBrowser | device_info.cross_browser |
deviceInfo.linkedBrowsers | device_info.linked_browsers |
screen.colorDepth | screen.color_depth |
screen.pixelRatio | screen.pixel_ratio |
clientHints | client_hints |
clientHints.platformVersion | client_hints.platform_version |
clientHints.deviceName | client_hints.device_name |
osEnvironment | os_environment |
environment.virtualMachine | environment.virtual_machine |
spoofing.spoofedAxes | spoofing.spoofed_axes |
spoofing.anonymousBrowser | spoofing.anonymous_browser |
session.durationSeconds | session.duration_seconds |
velocity.uniqueIps | velocity.unique_ips |
velocity.uniqueLocations | velocity.unique_locations |
accountAlert | account_alert |
accountAlert.accountId | account_alert.account_id |
accountAlert.driftScore | account_alert.drift_score |
accountAssessment | account_assessment |
accountAssessment.accountId | account_assessment.account_id |
accountAssessment.profile.windowsEffective | account_assessment.profile.windows_effective |
accountAssessment.profile.oldestWindowAgeSeconds | account_assessment.profile.oldest_window_age_seconds |
attack.windowMinutes | attack.window_minutes |
attack.recentBots | attack.recent_bots |
attack.recentTotal | attack.recent_total |
personId | person_id |
reputation.overallRisk | reputation.overall_risk |
linkedAccountsCount | linked_accounts_count |
linkedVisitorsCount | linked_visitors_count |
reputationChange | reputation_change |
reputationChange.personId | reputation_change.person_id |
reputationChange.previousBand | reputation_change.previous_band |
reputationChange.visitorIds | reputation_change.visitor_ids |
reputationChange.linkedAccountsCount | reputation_change.linked_accounts_count |
{ "version": 3, "event": "identification", "event_id": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:primary", // "<request_id>:<phase>" — the idempotency key "request_id": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", // visit identifier, shared by all phases "phase": "primary", "visitor_id": "X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y", "linked_id": "user-42", // your ?lid=, if you passed one "tag": "checkout", "fields": { "order_ref": "A-1029", "plan": "pro" }, // custom fields that arrived with the visit "timestamp": "2026-07-30T12:00:00Z", "url": "https://shop.example.com/checkout", "ip": "203.0.113.44", "user_agent": "Mozilla/5.0 …", "browser": { "name": "Chrome", "version": "138" }, "os": { "name": "macOS", "version": "15.5" }, "device": "desktop", "gpu": "Intel Iris Plus Graphics 655", // 显卡型号,已归一化;未知时不存在 "geo": { "country": "DE", "city": "Berlin", "lat": 52.52, "lon": 13.405, "timezone": "Europe/Berlin" }, "network": { "vpn": false, "proxy": true, "tor": false, "datacenter": true, "connection_type": "DCH", "proxy_detected": true // HTTP 流量与原始网络路径经由不同网络出口 }, "bot": { "result": "human", "score": 3.2 }, // human | bot | uncertain "identification": { "confidence": 0.97, "incognito": false }, "decision": { "action": "real", "risk_score": 12.2 }, // real | fake | suspicious "extensions": ["metamask", "privacy_badger"] // detected browser extensions; absent when none}零值和空值会被省略。 取值为零的字符串与数值字段(例如人类访问时的 bot.type,或
bot.antidetect_score: 0)不会出现在 JSON 中 —— 不要在您的模式中把它们设为必填,并以
防御性的方式读取嵌套区块。
bot.score 和 decision.risk_score 是 0..100 量表上的小数,保留一位小数 ——
正是仪表盘和 Data API 为同一次访问所报告的数字。(在已冻结的 v1 模式中它们使用不同的
单位:分别是 0..1 的比例和 0..255。)
bot.type 要么是已识别机器人的名称,要么是经过验证的 AI 代理(ai_agent:<vendor>),
要么是类别标签。词表参见 机器人类型 —— 内部检查名称在
任何套餐上都不会暴露。ai_agent:<vendor> 表示该请求证明了自己确实来自该厂商的基础设施
—— 使用厂商公布的密钥生成的有效 Web Bot Auth 签名,或者位于厂商公布的 IP 段内的地址 ——
而不仅仅是在 user agent 中这样声称。自称 AI 代理、却与厂商公布的每一项证明都相矛盾的
请求,会被报告为 impersonation。
| 字段 | 类型 | 说明 |
|---|---|---|
version | number | 载荷模式版本(3) |
event | string | 事件类型 |
event_id | string | 投递标识符 —— 幂等键;对每个事件唯一,并由接收该事件的您的所有 webhook 共享,在重试之间保持不变 |
request_id | string | 访问标识符(UUID),由该次访问的所有阶段共享 |
phase | string | primary、late、correction;account_takeover 和 account_assessment 携带 beacon |
visitor_id | string | 稳定的访客标识符 |
linked_id | string | 客户端提供的关联标识符 |
tag | string | 客户端提供的自定义标签 |
fields | object | 采集时附加到该次访问的自定义字段。只包含随访问一同到达的内容 —— 之后通过 Data API 附加的字段,在任何阶段或重试中都不会出现在 webhook 里。键和值均为字符串。访问未携带字段时不存在 |
timestamp | string | 事件时间(RFC 3339) |
url | string | 捕获该事件的页面 URL |
ip | string | 客户端 IP 地址 |
user_agent | string | 原始客户端 user-agent 字符串 |
browser.name / .version | string | 检测到的浏览器 |
os.name / .version | string | 检测到的操作系统 |
device | string | 设备类别(例如 desktop、mobile) |
gpu | string | 浏览器报告的显卡型号(WebGL),归一化为可读名称(Intel Iris Xe Graphics、Apple M1 Pro);软件光栅化器为 Software renderer;未知时不存在 |
geo | object | IP 地理定位:country、city、lat、lon、timezone |
network | object | vpn、proxy、tor、datacenter(布尔值)与 connection_type |
network.proxy_detected | boolean | 该次访问的 HTTP 流量与其原始网络路径经由不同网络出口——浏览器前面有代理或 VPN。同一服务商的两个地址(运营商 NAT、同一 VPN 的第二个出口)不算 |
bot.result | string | human、bot 或 uncertain |
bot.type | string | 检测到机器人时的机器人名称、经过验证的 AI 代理或类别标签 |
bot.score | number | 机器人评分(0–100) |
identification.confidence | number | 识别置信度(0.0–1.0) |
identification.incognito | boolean | 隐私/无痕浏览上下文 |
decision.action | string | real、fake 或 suspicious |
decision.risk_score | number | 综合风险评分(0–100) |
extensions | array | 该次访问中检测到的浏览器扩展,以稳定的机器 slug 表示(名称、类别和商店链接见仪表盘和 Data API)。未检测到时不存在 |
Pro 及以上 —— 访客随时间表现出的行为:
{ "identification": { "match_type": "exact", // exact | fuzzy | new — how the visitor was recognized "match_confidence": 0.93, "visits": 42, "incognito_visits": 3 }, // Present when visitor counters are available at event time (usually primary). // A missing block means "no data", not "zeros". "velocity": { "events5m": 7, "unique_ips": 2, "unique_locations": 1 }, "bot": { "antidetect_score": 0 }, // antidetect indicators, 0..100 "session": { "duration_seconds": 95 } // the correction phase and the account_takeover alert}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 }, // The smoothed behavior score the account-takeover verdict was made on. It is // measured by beacons that arrive after the identification event, so it is // empty in identification deliveries — see the account_takeover event. "identification": { "drift_score": 0.41 }, "device_info": { "device_id": "…", // the physical device across browsers on it "cross_browser": true, "confidence": 0.88, "linked_browsers": 3 }, "network": { "isp": "Deutsche Telekom", "asn": 3320, // 在原始网络路径上观测到的公网地址,也就是代理或 VPN 背后的地址;未观测到 // 这样的地址时不存在 "real_ip": { "address": "203.0.113.7", "country": "NL", "isp": "KPN" } }, "geo": { "isp": "Deutsche Telekom" }, // 在 Linux 机器上实测到的桌面环境("Mint 22+"、"Ubuntu"、"GNOME"、 // "KDE");User-Agent 无法表达发行版。无法判定时不存在——大多数 Linux 访问 // 以及每一次非 Linux 访问都是如此。 "os_environment": "Mint 22+", // 该次访问对自身的声称,与独立检查实测结果的对照。只有确实检测到伪装时才存 // 在;`real` 字段为空表示"检查保持沉默",绝不是"已确认"。在 `gpu` 轴上, // `claimed.gpu` 以与顶层 `gpu` 字段相同的可读型号名称承载所声称的显卡。 "spoofing": { "detected": true, "claimed": { "os": "Windows 10", "browser": "Chrome 139.0" }, "real": { "os": "macOS" }, "spoofed_axes": ["os", "screen"], // os | gpu | screen | network | browser "anonymous_browser": { "detected": true, "names": ["Linken Sphere"] } }, // 设备事实——访客浏览器上报的关于这台机器的信息,已在我方净化。screen:分辨 // 率、色深与 device pixel ratio。locale:浏览器自身偏好的语言与时区——不同 // 于由 IP 地址推导出的 geo.timezone;两者不一致是位置被伪造的常见迹象。 // client_hints:User-Agent Client Hints——CPU 架构与位数、设备型号 // (Android 上为 `model` 中的型号代码,例如 "SM-A556B",以及 `device_name` // 中来自 Google Play 设备列表的营销名称,例如 "Samsung Galaxy A55 5G") // 以及确切的平台版本;只有基于 Chromium 的浏览器会上报。访问未携带此类数 // 据时每个块都不存在,因此请把每个块都当作可选的。 "screen": { "width": 2560, "height": 1600, "color_depth": 30, "pixel_ratio": 2 }, "locale": { "languages": ["en-US", "de"], "timezone": "Europe/Berlin" }, "client_hints": { "architecture": "arm", "bitness": "64", "platform_version": "15.5.0" }, // 只有显卡自报为虚拟显卡时才存在;hypervisor 是封闭字典(vmware、 // virtualbox、parallels、qemu、hyperv、bochs、intel-gvt、vgpu)。缺少该块 // 意味着没有此类证据。 "environment": { "virtual_machine": true, "hypervisor": "vmware" }, "decision": { "suspect_score": 4.1 }, "guidance": { "version": 3, "overall": "review" } // see below}原因码词表以及 severity 的含义,参见
机器人检测。
多账户字段(Business 及以上)。 为您的工作区启用人物模式后,主体还会携带
person_id、一个 reputation 区块(score、categories、overall_risk)、
linked_accounts_count 和 linked_visitors_count —— 即该访客是否与其他访客和账户相关联。
人物层目前以观察模式运行,在它正式上线之前,主体中不会出现这些字段。不要在您的模式中把
它们设为必填。
guidance 针对每个集成场景给出现成的“该怎么做”建议,您无需自己从原始评分推导策略。它
出现在每一个 v3 Business+ 事件中 —— allow 也会被明确写出:
{ "guidance": { "version": 3, "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 是全部四个场景中最严格的那一个。
| 建议 | 支付 | 注册 | 登录 | 联盟推广 |
|---|---|---|---|---|
allow | 照常处理 | 照常创建 | 放行 | 计入该次转化 |
challenge | 3-D Secure / 二次确认 | 验证码、邮箱或手机确认 | 升级 2FA、重新认证 | 标记为存疑,直到出现真实活动 |
review | 处理,但排入人工复核 | 创建但施加限制 | 放行,同时发出告警 | 暂扣分成,待复核后再发 |
deny | 不要处理该笔交易 | 拒绝创建账户 | 不要放行 | 不要计入该次转化 |
basis 列出决定了该建议的维度 —— 提议比最终采纳的建议更温和的维度不会被列入。请按
代码读取它,而不要假定它是固定的集合:
维度(basis) | 含义 |
|---|---|
list | 访客命中了您工作区中处于 enforce 模式的某个列表。allow 列表会覆盖所有维度:每个场景都变为 allow,basis 恰好是 ["list"] |
rule | 该次访问命中了您工作区中处于 enforce 模式的某条规则;规则只改变其所属场景的建议(allow 规则只放宽那一个场景),若列表已生效则跳过 |
bot | 机器人检测器判定这是自动化流量 —— 每个场景都拒绝 |
risk | 该次访问的决策和综合风险评分 |
behavior | 行为生物特征以足够的置信度判定为机器人 —— 增加摩擦而不是拒绝 |
account_takeover | 该账户下的行为不再与其所有者的画像相符 —— 登录转为 challenge,支付转为 review |
network | Tor,或从数据中心出口的 VPN |
affiliate_pattern | 自产推荐的形态 —— 联盟推广转化以 challenge 暂扣 |
fraud_reputation、multiaccounting | 访客背后那个人的信誉和账户聚类;人物层上线后才会出现 |
version 是规则集的版本 —— 逻辑改进时会递增,因此请把它记入日志。版本 2 引入了 list
维度,这是第一个能够放宽建议的维度;版本 3 把 account_takeover 的登录阈值移到了账户接管
判定评分的量表上,因此同一次访问在版本 2 给出 challenge 的地方可能得到 deny,反之亦然。
处置建议是可加的:新的场景会以新键的形式出现,不会破坏契约。较晚的阶段胜出,但部分
建议除外:基于不完整输入计算出的投递会被标记为 "partial": true,且部分建议不会
覆盖此前针对同一 request_id 收到的完整建议。在普通投递中,partial 字段完全不存在。
确切的阈值是有意不写入文档的。一旦建议可以被反推出评分,它就不再是防护。有意的例外只有
一个:account_assessment.verdict.threshold,即下文所述账户接管判定的决策边界。
account_takeover 事件仅限 Business 和 Enterprise;在更低的套餐上不会创建投递,即使有 webhook 订阅了该事件。
主体是与您套餐对应的完整识别信封,外加一个 account_alert 区块和下一节所述的
account_assessment 区块,每次访问最多投递一次:
{ "version": 3, "event": "account_takeover", "event_id": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:ato", "request_id": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "phase": "beacon", // 告警从 beacon 发起;只有 event_id 说的是 "ato" "account_alert": { "type": "behavior-drift/2", // kind of alert and the version of its construction "account_id": "user-42", // your linked_id for the account "drift_score": 0.71 // the value the verdict was made on }, "account_assessment": { "type": "behavior-drift/2", "account_id": "user-42", "state": "assessed_alert", "verdict": { "severity": "high", "score": 0.71, "threshold": 0.3565, "scale": "manhattan-normalized", "basis": "ring", "windows": 9 }, "profile": { "windows": 6, "windows_effective": 5, "oldest_window_age_seconds": 1837 } } // …the remaining identification fields}为什么 type 带有版本号。 评分背后的构造以前就变过:判定的量表和基准。
behavior-drift/2 是第一个带版本号的构造。如果您基于这个数字校准过自己的规则,请把该取值
的变化视为需要重新检查规则的信号。
account_alert.drift_score 与 identification.drift_score 是同一个数字(自
2026-10-03 起):即作出判定所依据的平滑行为评分,以账户所有者过去受信任窗口组成的环为参照
测得。在该日期之前,identification.drift_score 承载的是另一种量表上的原始距离 —— 不要跨越
该日期混用取值。当某个窗口未被判定时,该字段不存在,而不是为零。如果您需要这个数字连同与之
比较的阈值,请使用 account_assessment.verdict。
在 v1 中,该区块是 accountAlert,携带 type、linkedId 和 drift,并且没有评估区块。
在 v2 中告警有两个字段被重命名 —— linkedId → accountId,drift → driftScore ——
并新增了 accountAssessment;v3 使用上面的 snake_case 名称。切换版本时请同步更新您的处理
函数,否则账户接管逻辑会悄无声息地再也看不到数据。
account_assessment 事件仅限 Business 和 Enterprise。需要显式订阅:* 不包含它。
如果没有这个事件,没有告警同时意味着两件不同的事:要么我们将该会话与账户所有者的画像作了 比对且结果相符,要么我们根本无法评估它 —— 所有者的画像尚未就绪、窗口过于稀疏,或者流量 并非来自真人。两者看起来都是沉默,而沉默会被理解为安全。通常并非如此:在 2026 年 9 月一个 完整的生产日里,我们对 73% 的窗口和 80% 的账户放弃了判定。建立在“没有告警就是没事”之上的 流程,所依赖的是一个我们大多数时候并不具备的状态。
{ "version": 3, "event": "account_assessment", "event_id": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:ato-state-not_assessed", "request_id": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "phase": "beacon", "account_assessment": { "type": "behavior-drift/2", "account_id": "user-42", "state": "not_assessed", // always present, one of three values "reason": "profile_not_ready", // only when state is not_assessed "profile": { "windows": 3, // windows in the owner's reference ring "windows_effective": 2, // of those, how many yielded a distance "oldest_window_age_seconds": 1837 } } // …the remaining identification fields}state 从不缺失,也从不为空:
state | 含义 | 该怎么做 |
|---|---|---|
assessed_clear | 已判定;行为与所有者相符 | 无需处理 |
assessed_alert | 已判定;行为存在偏离 | 升级验证 —— account_takeover 告警携带相同的判定 |
not_assessed | 未作出判定 | 不要视为正常;退回到您自己的信号 |
reason(仅与 not_assessed 一同出现):
reason | 含义 |
|---|---|
profile_not_ready | 所有者的参照目前无法为该账户作出判定。请把它当作一种状态而不是一个阶段:它可能永远不会改变,因此不要等待 |
profile_learning | 参照仍在积累中 |
window_too_sparse | 确实有人在操作,但该窗口中的交互太少 |
not_computable | 该窗口与参照没有共同的有信息量的维度 |
bot_traffic | 数据足够,但其中没有可供判定的真人 |
自 2026-10-04 18:00 UTC 起,对于参照已就绪的所有者,不再发出 window_too_sparse 和
not_computable —— 这样的窗口会被报告为 profile_not_ready。这两个原因仍保留在列表中,
因为更早的投递携带它们:如果您的处理函数按它们分支,请以同样的方式路由
profile_not_ready,并且不要跨越该日期比较这些原因的占比。
state 和 reason 不受套餐限制。verdict 与 drift_score 一样,属于 Business+。
verdict 区块只与 assessed_clear 和 assessed_alert 一同出现。在 not_assessed 时
该区块不存在,而不是用零填充 —— score 为 0 加上 threshold 为 0,会被读成“行为完全相符”。
| 字段 | 含义 |
|---|---|
severity | low 或 high —— 有意只分两档:中间档需要在同一量表上设第二个阈值 |
score | 作出判定所依据的值 |
threshold | 实际与之比较的值 |
scale | 评分的构造方式:manhattan-normalized 是归一化行为空间中的 d/(1+d),其中 0 表示完全一致 |
basis | ring —— 到所有者过去受信任窗口中最近一个的距离;centroid —— 到其平均画像的距离 |
windows | 有多少个窗口被聚合进该评分 |
score 和 threshold 总是处于同一个有名称的量表上,因此这一对数值可以复现决策:当且仅当
score > threshold 时触发告警。
到达频率。 每次访问每种状态最多投递一次 —— 实际上是每次携带 linked_id 的访问一次,
而不是每个行为窗口一次。状态可能在一次访问内部发生变化 —— 例如参照完成学习,
not_assessed 变为 assessed_clear —— 这时您会按顺序收到两者。这种转变是可能的,而不是
承诺:profile_not_ready 可能在账户的整个生命周期内持续存在。
它不承诺什么。 受到最好保护的是会话较长、参照画像完整的活跃用户;受到最差保护的是 不常使用的用户,而对他们来说账户被接管的危害更大。这是构造本身的属性,而不是一个可以调节 的阈值。该事件报告的是相对于参照的行为偏离 —— 它并不声称能广泛地捕获账户接管。
attack_detected 事件机器人激增告警以通用的 v3 信封送达,并带有一个 attack 区块。它没有识别字段 —— 这是一个
工作区级别的事件:
{ "version": 3, "event": "attack_detected", "event_id": "c0a8e1f2-…", // alert id; matches X-Tracio-Event-Id "timestamp": "2026-07-30T12:00:00Z", "attack": { "kind": "bot_spike", "severity": "critical", // warning | critical "window_minutes": 15, "recent_bots": 4210, "recent_total": 5100, "expected": 180.5 // the baseline expected over a window this size }}使用载荷版本 1 的 webhook 会收到历史上的扁平告警模式 —— 它不会改变。
login_risk、trusted_device_enrolled 和 trusted_device_revoked 事件这三个事件来自您自己对登录风险 API(POST /v2/logins/assess)及其受信任设备的调用。它们
只在按名称显式订阅时投递 —— * 不包含它们 —— 并且只投递给使用载荷版本 2 或 3 的
webhook;版本 1 没有对应的主体。
challenge 或 review 时发送 login_risk —— 在 allow、deny 或
not_assessed 时从不发送。其 login_risk 区块就是存储下来的评估,与同步响应给出的内容
完全一致。事件 id:login-risk:<id>,其中 <id> 是我们对该存储记录的标识符 —— 您自己的
assessment_id 请从主体中获取。trusted_device_enrolled 和
trusted_device_revoked。它们的 trusted_device 区块 —— user_id、device_ref、
enrolled_at、expires_at、revoked_at、revoked_by(api 或 dashboard)—— 是事件
发生时刻的快照;对于 trusted_device_enrolled,revoked_at 和 revoked_by 为 null。
事件 id:trusted-device:<n>。它们以异步方式发送,在调用后最多约五秒送达,并且不携带 X-Tracio-Request-Id 头 —— 请用
login_risk.request_id 查找对应的访问。Webhook 是用于审计和告警的通知;决策本身是同步响应。
{ "version": 3, "event": "login_risk", "event_id": "login-risk:4b1f9e2c-6d3a-4f80-9b7e-2a5c8d1e0f37", "timestamp": "2026-10-17T10:00:00Z", "login_risk": { "assessment_id": "snap-1", "user_id": "alice", "state": "assessed", "recommendation": "challenge", "reasons": ["new_device"] // …the rest of the assessment }}{ "version": 3, "event": "trusted_device_revoked", "event_id": "trusted-device:7", "timestamp": "2026-10-17T11:00:00Z", "trusted_device": { "user_id": "alice", "device_ref": "7978151c54e94838", "enrolled_at": "2026-10-17T10:00:00Z", "expires_at": "2026-11-16T10:00:00Z", "revoked_at": "2026-10-17T11:00:00Z", "revoked_by": "dashboard" }}affiliate_threshold_crossed 事件只在按名称显式订阅时投递 —— * 不包含它 —— 并且只投递给使用载荷版本 2 或 3 的
webhook。TRACIO 每 15 分钟为每个数据足够的联盟推广者重新计算质量评分,并在其建议变差时
发送该事件:none → review、none → hold 或 review → hold。建议改善时不发送任何内容,
并允许下一次变差再次被报告。该事件属于工作区:它会发送到每个环境中已订阅的 webhook,并且
不携带 X-Tracio-Request-Id 头。事件 id:
affiliate-threshold:<affiliate_id>:<recommendation>:<unix time>。
{ "version": 3, "event": "affiliate_threshold_crossed", "event_id": "affiliate-threshold:aff_17:review:1792490400", "timestamp": "2026-10-20T10:00:00Z", "affiliate_threshold": { "affiliate_id": "aff_17", "recommendation": "review", // none | review | hold "previous_recommendation": "none", "quality_score": 72, // 0..100, higher is better "hold_days": 14, // the current hold period of the workspace "crossed_at": "2026-10-20T10:00:00Z", "metrics": { "reg_to_ftd": 0.31, "active14_share": 0.42, "dup_device_share": 0.3, "dup_person_share": 0.1, "ring_share": 0.05, "bonus_hunter_share": 0.2, "dc_vpn_bot_share": 0.1, "chargeback_share": 0.03, "affiliate_fraud_share": 0, "no_visit_share": 0.05 } }}metrics 是该联盟推广者在过去 90 天内的各项占比,与仪表盘报告中显示的相同:达到首次存款的
注册占比(reg_to_ftd)、注册 14 天后仍然活跃的玩家占比(active14_share),以及具有重复
设备或重复人物、来自欺诈团伙、来自奖金猎人、来自数据中心、VPN 或机器人、被标记为拒付或
联盟推广欺诈、以及未找到访问的转化占比。TRACIO 只提供建议 —— 暂扣本身以及任何支付决定仍由
您的联盟推广平台作出。在载荷版本 2 中,同一主体使用 camelCase 键:区块为
affiliateThreshold,带有 affiliateId、previousRecommendation、qualityScore 等。
transaction_state_changed 事件Business 及以上。当您发送到 POST /v2/transactions 的交易结果发生变化时发送:规则判定为
REVIEW 或 DECLINE(APPROVE 则不发送),分析师在仪表盘中将交易送去复核,或者
标签被设置、替换或移除。用它在申领处于复核期间暂扣奖金,并在结果确定后扣下或发放奖金,
而无需轮询。* 包含该事件;它只投递给使用载荷版本 2 或 3 的 webhook。绑定到某个环境的
webhook 只会收到该环境的此类事件。
{ "version": 3, "event": "transaction_state_changed", "event_id": "txn-state:7d1c2b3a-0000-4000-8000-000000000042:118", "timestamp": "2026-10-15T10:04:00Z", "transaction": { "id": "7d1c2b3a-0000-4000-8000-000000000042", "transaction_id": "bonus-claim-1842", "state": "REVIEW", // always the decision of the rules "fraud_score": 50, "effective_state": "DECLINE", // the outcome to act on "recommendations": ["withhold_bonus"], "label": "fraud_detection_bonus_abuse", "label_source": "api", // api | dashboard "label_at": "2026-10-15T10:04:00Z", "previous_effective_state": "REVIEW" // …applied_rules and evidence, as in the transaction response }}| 键 | 含义 |
|---|---|
effective_state | 据以行动的结果:设置了分析师或标签的状态时取该状态,否则为 state |
recommendations | 如何处理奖金,由 effective_state 推导得出 |
label、label_source、label_at | 当前标签、其设置位置(api 或 dashboard)及设置时间;交易没有标签时三者全部为 null |
previous_effective_state | 本次变化之前的 effective_state;对于在自动决策时发送的事件为 null |
该区块展示的是事件发送时的交易:如果两次变化接连很快发生,两个事件携带的是最新状态,
每个事件带有各自的 previous_effective_state 和 timestamp。不保证顺序 —— 请按
timestamp 排序,并通过 API 读取交易的当前状态。事件 id:自动决策为 txn-state:<id>:created,
之后的每一次变化为 txn-state:<id>:<n>。该事件不会说明是谁设置了标签 —— 只说明它来自哪里。
reputation_changed 事件Business 及以上。当您的组织见过的某个人的跨客户信誉移入另一个区间(例如 clean →
suspicious)时发送。它依赖人物层,而人物层目前以观察模式运行 —— 因此只有在启用了跨设备
身份归并的工作区中才会触发该事件。这是人物级别的事件:它没有 request_id,也没有访问区块。
{ "version": 3, "event": "reputation_changed", "event_id": "person-reputation:pn_4f2a…:clean->suspicious:2026-07-30T12:10:00.123456Z", "timestamp": "2026-07-30T12:10:00Z", "reputation_change": { "person_id": "pn_4f2a…", "score": 46, "band": "suspicious", // clean | suspicious | fake "previous_band": "clean", "categories": ["chargeback"], "visitor_ids": ["X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y"], // the person's visitors in YOUR data "linked_accounts_count": 4 // omitted when zero }}区块键是 reputation_change,而不是 reputation:后者已被用于具体某次访问上下文中的信誉,
两者是不同的东西。模式 1 投递的是历史上的扁平形式 —— 相同的字段位于顶层。
每一次投递都包含 X-Tracio-Signature 头:
X-Tracio-Signature: t=1710432000,v1=5257a869e7ecebed…t 是请求被签名时的 Unix 时间戳(秒)。v1 是以您的 webhook 密钥为键、对 "<t>.<rawRequestBody>" 计算的 HMAC-SHA256,
以十六进制编码。时间戳是被签名内容的一部分,由此提供了重放保护。
有两件事必须做对,否则在生产环境中校验会失败:
v1= 取值匹配即可接受。 在密钥轮换期间,该头会携带两个签名,
而只保留其中一个的解析器会在整个轮换窗口内拒绝合法的投递。// 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.visitor_id}`) console.log(`Bot: ${event.bot?.result}`) // "human" | "bot" | "uncertain"
res.status(200).send("OK")})模式 2 和 3 的投递会额外携带 X-Tracio-Signature-Ed25519
(t=<unix>,kid=<id>,v1=<base64>)。HMAC 密钥为双方所共知,因此 HMAC 只能证明发送方
知道该密钥,却不能证明请求确实源自 TRACIO;非对称签名可以。公钥发布在
https://api.tracio.ai/.well-known/webhook-keys,以 kid 作为索引:请读取 public_key
字段。旧别名 publicKey 携带相同的值,并将于 2027-04-12 移除。
从仪表盘发送的测试投递只使用 HMAC 签名 —— 平台私钥保存在投递节点上,有意不向仪表盘
开放。强制要求 Ed25519 的校验器必须放行测试投递(它们的 event_id 带有 :test
后缀),否则会出现生产一切正常、但从仪表盘测试却失败的情况。同样的谨慎也适用于格式
校验:测试投递的 request_id 形如 test_<hex>,visitor_id 则是字面量
test_visitor,因此按生产环境的形态去校验它们的处理函数,会拒绝一次本来完全正常的
投递。
轮换之后,两个密钥会在 24 小时内同时有效,并且该头会同时携带两个签名,因此您可以在 不丢失投递的情况下更新配置。立即吊销操作会提前结束这一窗口。请在 24 小时内更新 您这边的密钥:窗口关闭后旧密钥不再匹配,而如果您的端点对无效签名返回 4xx,连续五次 这样的响应就会停用该 webhook。
| 头 | 说明 |
|---|---|
Content-Type | application/json |
X-Tracio-Signature | t=<unix>,v1=<hmac_sha256_hex> —— 轮换窗口内有两个 v1= |
X-Tracio-Signature-Ed25519 | 平台签名,t=<unix>,kid=<id>,v1=<base64>(仅 v2 和 v3) |
X-Tracio-Event-Id | 投递标识符 —— 幂等键 |
X-Tracio-Request-Id | 访问标识符,与 request_id 一致(仅 v2 和 v3)。只在访问类事件中发送 —— identification、account_takeover 和 account_assessment |
X-Tracio-Event-Type | 事件类型(仅 v2 和 v3) |
X-Tracio-Delivery-Attempt | 尝试次数,从 1 开始(仅 v2 和 v3) |
X-Tracio-Payload-Version | 3 或 2(仅 v2 和 v3) |
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")})请注意,event_id 对每个事件唯一,而不是对每个 webhook 唯一:如果工作区中有多个
webhook 订阅了同一事件,它们会各自收到带有相同标识符的投递。对于 identification,它按
<request_id>:<phase> 构造,因此同一次访问的三个阶段会各自独立去重,而不会被合并成一条。
没有访问的事件有各自的形式 —— 参见上文每个事件的说明。
请返回 2xx —— 这是投递被接受的唯一标志。
| 响应 | 会发生什么 |
|---|---|
2xx | 投递完成 |
429 Too Many Requests | 不计为失败,也不消耗一次尝试;更长的 Retry-After 会被遵守 |
408、425、5xx、连接中断 | 以逐渐增大的间隔重试 |
410 Gone | 端点被视为已移除 —— webhook 立即停用 |
其他 4xx | 会重试,但连续五次即停用该 webhook —— 400/401/404 无法靠重试解决 |
重试计划:5s → 30s → 2min → 10min → 30min → 2h → 6h(共 8 次尝试)。前几次重试都在一 分钟以内,因此服务短暂重启不会让您丢失一条通知。每次等待都会在一半到全值之间随机化, 避免故障恢复后重试集中在同一时刻涌来。
自动停用同时需要达到阈值(连续 20 次失败,或 5 次配置错误)并且至少连续 15 分钟 处于失败状态 —— 即便有大量投递排队,短暂重启也不会让集成被关停。中断超过 15 分钟会 重新开始计数。仪表盘会显示原因,包含响应码和错误文本,并提供一个可重置计数器的 重新启用按钮。
| 套餐 | 每个工作区的 webhook 数量 |
|---|---|
| Free | 不提供 |
| Pro | 5 |
| Business | 20 |
| Enterprise | 100 |
端点必须是使用公网 IP 的 https —— 私有地址和回环地址会被拒绝,重定向后的地址同样如此
—— 并且重定向不得超过两层。
只会跟随 307 和 308 重定向。 301、302 和 303 会指示客户端改用 GET 并丢弃
请求体,因此投递不会跟随它们,该次尝试计为失败。如果您的负载均衡器会规范化 URL
(添加 www 或末尾斜杠),请把 webhook 直接指向最终的 URL。
Webhook 在仪表盘中管理。仪表盘背后驱动的是一套工作区范围的管理 API,由应用主机
提供(例如 https://app.tracio.ai/api/v1),下面列出的正是它所调用的端点。所有
webhook 端点都位于 /workspaces/{wsId} 之下。
这不是面向服务端到服务端的接口。 该管理 API 只接受您的仪表盘会话 JWT,并 针对您在工作区中的角色(RBAC)进行校验;
tracio_sk_…形式的密钥在这里会被拒绝。 由于该会话存在于浏览器中并随之失效,请把下面这些调用理解为对仪表盘行为的说明, 而不是一套供您自动化的集成。若要从您自己的后端以编程方式访问,请使用只读的 Data 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?.risk_score > 50) { await alertFraudTeam(event) }
if (event.bot?.result === "bot") { await blockVisitor(event.visitor_id) }}使用 webhook 上的测试操作(或 POST .../webhooks/{webhookId}/test)向您的端点发送
一份带签名的示例载荷,确认它可达并且签名校验正确。
在本地开发时,请用 ngrok 之类的隧道暴露您的服务器:
ngrok http 3000# Use the generated URL as your webhook endpoint