Webhook 将识别事件实时投递到您的服务器。每当有访客被识别,TRACIO 就会向您配置的
webhook URL 发送一个 HTTP POST 请求。请求体本身就是事件载荷。
它们也是唯一交付延迟判定的通道 —— 即页面已经加载之后,访客的行为才证明其为 自动化的那一类判定。
请在仪表盘的设置 → Webhooks 中进行配置。Webhook 需要 Pro 或更高套餐。
| 事件 | 触发时机 | 套餐 |
|---|---|---|
identification | 每次访问 —— primary、late 和 correction 阶段 | 全部 |
account_takeover | 账户下的行为不再与所有者的画像相符 | Business 及以上 |
attack_detected | 您的站点上出现机器人激增 | Business 及以上 |
reputation_changed | 设备背后那个人的信誉发生了变化 | Business 及以上 |
事件名一律使用下划线,绝不使用点号 —— 并不存在 visitor.created 或
session.created。reputation_changed 依赖人物层,因此只有在启用了跨设备身份归并的
工作区中才会触发。
一个 webhook 订阅特定的类型;单独的取值 * 表示“所有类型,包括日后新增的类型”。
在创建或编辑订阅时,未知类型会以 400 被拒绝,因此一个拼写错误不会让您留下一个
悄无声息、永不触发的 webhook。
identification 事件的各个阶段一次访问最多产生三次投递,它们共享同一个 requestId:
primary —— 页面加载时的初始判定。late —— 大约九秒后,在耗时较长的检查落地后的补充增强。correction —— 基于行为(指针、键盘、滚动)的修正。请用 requestId 关联它们,用 phase 区分它们。较晚的阶段优先: 如果 primary
说是 human,而 correction 说是 bot,那么后者才是正确答案。
不要依赖到达顺序。 每个阶段都独立投递,并各自遵循自己的重试计划 —— 如果 primary
进入了重试而 late 首次尝试就成功,您收到它们的顺序就会颠倒。优先级请从 phase
字段判断,而不是从接收时间判断。
这三个就是 identification 事件的全部阶段。另有一个取值也会到达您这里:
account_takeover 携带 phase: "beacon",因为账户接管告警只会从行为信标
(beacon)发起。
请注意由此产生的不一致,因为它会影响幂等性。生产环境中一次 identification 投递的
eventId 恰好就是 <requestId>:<phase>,但有两种投递打破了这个公式。
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 区块。
如果您在同一个处理函数中解析所有事件,请在访问访问级字段之前先检查 event。
| 版本 | 适用对象 | 如何切换 |
|---|---|---|
1 | 在 v2 出现之前创建的 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", "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, "connectionType": "DCH", "proxyDetected": true // HTTP 流量与原始网络路径经由不同网络出口 }, "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 是 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) |
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(布尔值)与 connectionType |
network.proxyDetected | boolean | 该次访问的 HTTP 流量与其原始网络路径经由不同网络出口——浏览器前面有代理或 VPN。同一服务商的两个地址(运营商 NAT、同一 VPN 的第二个出口)不算 |
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, // 在原始网络路径上观测到的公网地址,也就是代理或 VPN 背后的地址;未观测到 // 这样的地址时不存在 "realIp": { "address": "203.0.113.7", "country": "NL", "isp": "KPN" } }, // 在 Linux 机器上实测到的桌面环境("Mint 22+"、"Ubuntu"、"GNOME"、 // "KDE");User-Agent 无法表达发行版。无法判定时不存在——大多数 Linux 访问 // 以及每一次非 Linux 访问都是如此。 "osEnvironment": "Mint 22+", // 该次访问对自身的声称,与独立检查实测结果的对照。只有确实检测到伪装时才存 // 在;`real` 字段为空表示"检查保持沉默",绝不是"已确认"。在 `gpu` 轴上, // `claimed.gpu` 以与顶层 `gpu` 字段相同的可读型号名称承载所声称的显卡。 "spoofing": { "detected": true, "claimed": { "os": "Windows 10", "browser": "Chrome 139.0" }, "real": { "os": "macOS" }, "spoofedAxes": ["os", "screen"], // os | gpu | screen | network | browser "anonymousBrowser": { "detected": true, "names": ["Linken Sphere"] } }, // 设备事实——访客浏览器上报的关于这台机器的信息,已在我方净化。screen:分辨 // 率、色深与 device pixel ratio。locale:浏览器自身偏好的语言与时区——不同 // 于由 IP 地址推导出的 geo.timezone;两者不一致是位置被伪造的常见迹象。 // clientHints:User-Agent Client Hints——CPU 架构与位数、设备型号 // (Android 上为 `model` 中的型号代码,例如 "SM-A556B",以及 `deviceName` // 中来自 Google Play 设备列表的营销名称,例如 "Samsung Galaxy A55 5G") // 以及确切的平台版本;只有基于 Chromium 的浏览器会上报。访问未携带此类数 // 据时每个块都不存在,因此请把每个块都当作可选的。 "screen": { "width": 2560, "height": 1600, "colorDepth": 30, "pixelRatio": 2 }, "locale": { "languages": ["en-US", "de"], "timezone": "Europe/Berlin" }, "clientHints": { "architecture": "arm", "bitness": "64", "platformVersion": "15.5.0" }, // 只有显卡自报为虚拟显卡时才存在;hypervisor 是封闭字典(vmware、 // virtualbox、parallels、qemu、hyperv、bochs、intel-gvt、vgpu)。缺少该块 // 意味着没有此类证据。 "environment": { "virtualMachine": true, "hypervisor": "vmware" }, "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 是四个场景中最严格的那一个。
| 建议 | 支付 | 注册 | 登录 | 联盟推广 |
|---|---|---|---|---|
allow | 照常处理 | 照常创建 | 放行 | 计入该次转化 |
challenge | 3-D Secure / 二次确认 | 验证码、邮箱或手机确认 | 升级 2FA、重新认证 | 标记为存疑,直到出现真实活动 |
review | 处理,但排入人工复核 | 创建但施加限制 | 放行,同时发出告警 | 暂扣分成,待复核后再发 |
deny | 不要处理该笔交易 | 拒绝创建账户 | 不要放行 | 不要计入该次转化 |
version 是规则集的版本 —— 逻辑改进时会递增。处置建议是可加的:新的场景会以新键的
形式出现,不会破坏契约。较晚的阶段胜出,但部分建议除外:基于不完整输入计算出的
投递会被标记为 "partial": true,且部分建议不会覆盖此前针对同一 requestId
收到的完整建议。在普通投递中,partial 字段完全不存在。
确切的阈值是有意不写入文档的。一旦建议可以被反推出评分,它就不再是防护。
account_takeover 事件仅限 Business 和 Enterprise。主体是与您套餐对应的完整识别信封,外加一个
accountAlert 区块,每次访问最多投递一次:
{ "version": 2, "event": "account_takeover", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:ato", "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "phase": "beacon", // 告警从 beacon 发起;只有 eventId 说的是 "ato" "accountAlert": { "type": "behavior-drift", "accountId": "user-42", // your linkedId for the account "driftScore": 0.83 } // …the remaining identification fields}在 v1 中,该区块携带 type、linkedId 和 drift;在 v2 中有两个字段被重命名 ——
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>" 计算的 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.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,连续五次 这样的响应就会停用该 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) |
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>
构造,因此同一次访问的三个阶段会各自独立去重,而不会被合并成一条。
请返回 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?.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