Webhook 回调契约

更新时间:
复制 MD 格式

描述平台向回调地址投递事件时的入站 HTTP 请求契约:请求方法与请求头、验签算法、事件信封字段,以及幂等、重试与投递保证。事件正文仅携带资源标识,需按 data.id 查询资源最新状态。

概述

创建 Webhook 并订阅具名事件后,平台在事件发生时向配置的回调地址投递通知。本页说明平台发出的入站请求契约:接收端只需按此契约验证签名、解析事件信封,并按 data.id 查询资源最新状态。

事件正文不携带资源完整内容,仅含事件类型、资源标识和必要上下文。接收方根据 data.id 调用对应资源的 GET 接口查询最新状态。

支持的事件

events 支持以下 32 个具名事件,不支持 * 或其他通配订阅。

分类事件
Session 管控面session.created、session.updated、session.archived、session.deleted
Session 运行状态session.status_run_started、session.status_idled、session.status_terminated
Session Threadsession.thread_created、session.thread_run_started、session.thread_idled、session.thread_terminated
Agentagent.created、agent.updated、agent.archived
Deploymentdeployment.created、deployment.updated、deployment.archived、deployment.paused、deployment.unpaused
Deployment Rundeployment_run.started、deployment_run.failed、deployment_run.succeeded
Environmentenvironment.created、environment.updated、environment.archived、environment.deleted
Vaultvault.created、vault.archived、vault.deleted
Vault Credentialvault_credential.created、vault_credential.archived、vault_credential.deleted

回调请求

平台向通过安全校验的 HTTP 或 HTTPS 地址发起 POST 请求,不跟随重定向。直接填写的公网 IP 可以投递;私网、回环、链路本地和保留地址禁止连接。

每次投递都会重新生成 webhook-timestamp,并使用当前 Signing Secret 计算签名。重试时事件正文、外层 id 和 created_at 保持不变。

请求头示例:

POST /managedagent/webhooks HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Bailian-ManagedAgent-Webhook/1.0
webhook-id: whe_01JXX8JY9BBM4BK4C2P7K3M3ZR
webhook-timestamp: 1785810621
webhook-signature: v1,BASE64_HMAC_SHA256
请求头说明
Content-Type固定为 application/json
User-Agent固定为 Bailian-ManagedAgent-Webhook/1.0
webhook-id外层 event.id,格式 whe_<ULID>;用于幂等去重,也是验签原文的组成部分
webhook-timestamp本次投递生成的 Unix 秒级时间戳;每次投递重新生成
webhook-signature签名,格式 v1, 加 Base64 编码的 HMAC-SHA256 结果

签名验证

签名原文和算法:

signed_content = webhook-id + "." + webhook-timestamp + "." + raw_body
secret_bytes = Base64Decode(RemovePrefix(signing_secret, "whsec_"))
signature = Base64(HMAC-SHA256(secret_bytes, UTF8(signed_content)))
  • webhook-id 的值是外层 event.id,不是 Webhook 配置的 webhook_id。
  • signing_secret 为 whsec_ 加标准 Base64 文本。验签时去掉 whsec_ 前缀,再用标准 Base64 解码剩余内容,不能将完整 whsec_... 字符串直接作为 HMAC 密钥。
  • 先读取未经修改的原始请求体完成验签,再进行 JSON 反序列化。
  • 校验时间戳与当前时间相差不超过 5 分钟,并使用恒定时间比较验证签名。

警告必须对未经解析的原始请求体(raw body)验签,再做 JSON 反序列化。任何在验签前对正文的重编码或解析都会导致签名不匹配。

说明Signing Secret 仅在 创建 和重置成功响应中返回,后续查询不再返回。请妥善保存。

事件信封字段

普通事件正文:

{
  "type": "event",
  "id": "whe_01JXX8JY9BBM4BK4C2P7K3M3ZR",
  "created_at": "2026-08-06T10:30:21.123Z",
  "data": {
    "id": "sesn_xxx",
    "type": "session.status_idled",
    "workspace_id": "ws_xxx"
  }
}

Thread 事件额外携带 session_thread_id,data.id 为 Session 标识,data.session_thread_id 为具体 Thread 标识。session.thread_created、session.thread_run_started、session.thread_idled 和 session.thread_terminated 都使用该结构:

{
  "type": "event",
  "id": "whe_01JXX8JY9BBM4BK4C2P7K3M3ZR",
  "created_at": "2026-08-06T10:30:21.123Z",
  "data": {
    "id": "sesn_xxx",
    "type": "session.thread_idled",
    "workspace_id": "ws_xxx",
    "session_thread_id": "sthread_xxx"
  }
}
字段类型说明
typestring固定为 event
idstring事件 ID,格式 whe_<ULID>;同一事件的所有投递尝试使用相同外层 id,与 webhook-id 请求头一致
created_atstring事件创建时间,ISO 8601
dataobject事件载荷
data.idstring资源标识;Thread 事件中为 Session 标识
data.typestring事件类型
data.workspace_idstring工作空间 ID
data.session_thread_idstringThread 事件中为具体 Thread 标识
data.vault_idstringVault Credential 事件中携带

幂等、重试与投递保证

接收端不需要返回响应体,应在 5 秒内返回状态码。平台按接收端响应或错误决定后续行为:

接收端响应或错误平台行为
200~299投递成功,不再重试
300~399不跟随重定向,不重试,立即禁用当前 Webhook
408、425、429当前请求失败,进入重试
400~499 其他状态当前投递最终失败,不重试
500~599当前请求失败,进入重试
域名解析、连接、写入或读取超时当前请求失败,进入重试
HTTPS 证书校验或主机名校验失败不重试,立即禁用当前 Webhook
域名解析到私网或保留地址禁止建立连接,立即禁用当前 Webhook

业务事件首次请求失败后最多再重试 3 次,重试等待时间依次为 10 秒、30 秒、1 分钟,共最多 4 次真实网络请求。第 4 次仍失败时记录最终失败。默认连续 20 个业务事件最终失败后自动禁用 Webhook。webhook.test 仅同步请求一次,不重试,不影响连续失败计数。

其他投递语义:

  • 可能重复:同一事件可能多次投递,始终使用相同的 event.id;按该标识幂等去重。
  • 不保证顺序:事件可能乱序到达;需要排序时使用 created_at,最终状态以资源查询结果为准。
  • 查询期限:投递事件保留 7 天,超过后无法查询。

验签示例

import base64
import hashlib
import hmac
import os
import time

secret = os.environ["AGENT_WEBHOOK_SECRET"]
secret_bytes = base64.b64decode(secret[len("whsec_"):])

def verify_webhook(raw_body: bytes, webhook_id: str, timestamp: str, signature_header: str) -> bool:
    try:
        if abs(time.time() - int(timestamp)) > 300:
            return False

        version, signature = signature_header.split(",", 1)
        if version != "v1":
            return False

        signed_payload = f"{webhook_id}.{timestamp}.".encode() + raw_body
        expected = base64.b64encode(
            hmac.new(secret_bytes, signed_payload, hashlib.sha256).digest()
        ).decode()
        return hmac.compare_digest(signature, expected)
    except (TypeError, ValueError):
        return False
import crypto from "node:crypto"
import express from "express"

const secret = process.env.AGENT_WEBHOOK_SECRET // whsec_...
const secretBytes = Buffer.from(secret.slice("whsec_".length), "base64")
const app = express()

// 必须使用原始请求体,不能先经过 express.json() 解析。
app.post("/agent/events", express.raw({ type: "*/*" }), (req, res) => {
  const webhookId = req.get("webhook-id") ?? ""
  const timestamp = req.get("webhook-timestamp") ?? ""
  const [version, signature = ""] = (req.get("webhook-signature") ?? "").split(",", 2)
  const timestampSeconds = Number(timestamp)

  if (!timestampSeconds || version !== "v1" || Math.abs(Date.now() / 1000 - timestampSeconds) > 300) {
    return res.status(401).send("invalid webhook signature")
  }

  const expected = crypto
    .createHmac("sha256", secretBytes)
    .update(`${webhookId}.${timestamp}.`)
    .update(req.body)
    .digest()
  const actual = Buffer.from(signature, "base64")

  if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
    return res.status(401).send("invalid webhook signature")
  }

  const event = JSON.parse(req.body.toString("utf8"))
  res.status(200).end()
  enqueue(event)
})