面向 AI 助手:本页的 Markdown 版本位于 https://ark.volcengine.com/region:cn-beijing/docs/receive-webhook-events.md,全部文档索引见 https://ark.volcengine.com/llms.txt。

使用 Webhook 接收事件通知

如果你需要把 Agent 运行结果接入审计、告警或内部编排系统,不必为每个资源持续轮询状态。配置 Managed Agents Webhook 后,方舟会在 Agent、Environment、Session、Memory Store 和 Vault 等资源发生变化时主动回调你的服务端。本指南从创建接入点开始,串起接收服务、验签去重、测试事件、故障恢复、密钥轮换和资源清理。

选择接收方式#

Webhook、SSE 和轮询解决的问题不同。需要监听多个资源的异步变化时,使用 Webhook。需要实时展示单个 Session 的消息、工具调用和增量输出时,使用 SSE。需要主动核对资源当前状态或补查 Session 历史事件时,调用对应的查询接口。

维度 Webhook SSE 轮询
适用范围 跨资源接收生命周期和状态变化 实时接收指定 Session 或线程的细粒度事件 主动查询资源当前状态或历史记录
连接方式 平台向公网回调地址发送 HTTP POST 客户端维持长连接 客户端按需发送查询请求
成功判定 接收端返回 HTTP 2xx 客户端持续读取事件流 查询接口返回成功响应
可靠性处理 验签、去重、重试和停用恢复 断线后重连,并按接口能力补查 控制查询频率,避免高频轮询
推荐场景 审计、告警、异步任务编排 实时对话、运行进度展示 对账、恢复和低频状态检查

Webhook 支持订阅下表中的业务事件。事件正文结构详情请参见 Webhook 事件结构参考。

资源域 事件类型
Session 与线程 session.status_run_started、session.status_idled、session.status_terminated、span.outcome_evaluation_end、session.error、session.thread_status_running、session.thread_status_idle、session.thread_status_terminated、session.updated、session.deleted
Agent agent.created、agent.updated、agent.deleted
Environment environment.created、environment.updated、environment.deleted
Memory Store memory_store.created、memory_store.deleted
Vault 与凭证 vault.created、vault.deleted、vault_credential.created、vault_credential.deleted、vault_credential.refresh_failed

判定 Session 运行结果#

session.status_idled 只表示 Session 已进入空闲状态,不表示当前轮次正常结束。Webhook 事件不携带 Session Event ID 或轮次 ID,不能只查询最近一条 session.status_idle 后自动推进业务。收到该通知后,按以下顺序判定结果:

  1. 从 Webhook 事件的 data.id 读取 Session ID,并按 Webhook 外层 id 去重。
  2. 找到业务侧记录的本轮边界。推荐在发送本轮 user.message、user.tool_result 或 user.tool_confirmation 后,保存发送事件接口返回的事件 ID;如果接收端已经持续消费 Session 事件,也可以保存上一条已处理 Session Event 的 processed_at 作为水位。
  3. 使用 Session ID 调用 查询会话事件列表,查询本轮边界之后的历史事件。可以用 created_at[gt] 缩小时间窗口,并按 processed_at 正序扫描;只有确认候选 session.status_idle 位于本轮边界之后,才将它作为本次通知对应的状态事件。
  4. 读取对应 session.status_idle 事件的 stop_reason.type:
    • end_turn:当前轮次正常结束,可以处理业务结果。
    • requires_action:当前轮次正在等待工具确认或工具结果,按 stop_reason.event_ids 继续处理。
    • retries_exhausted:当前轮次重试次数已用尽,不要按成功结果推进后续流程。

Webhook 可能早于 Session 历史事件完成同步,也可能延迟投递。若查询结果中只有本轮边界之前的 session.status_idle,或无法确认候选事件与本轮业务任务的关系,等待后继续查询;不能仅凭最近一条 end_turn 推进业务。无法建立本轮边界时,session.status_idled 只适合作为状态核对信号,不适合驱动自动发布、扣款或外部通知等必须精确对应轮次的流程。

Webhook 外层 id 以 whe- 开头,只标识 Webhook 事件信封。Session 历史事件使用独立的事件 ID,不能将 whe- ID 传给 Session Events 查询接口。Session 事件字段详情请参见 会话事件结构参考。

准备接收端#

开始前,完成以下准备:

  • 创建方舟 API Key,并配置为 ARK_API_KEY。
  • 准备一个公网可访问的 HTTP 或 HTTPS 回调地址。平台不跟随 3xx 重定向。
  • 安装 Python 3.10 或更高版本。
  • 确定需要订阅的事件类型。创建接入点时至少填写 1 种,可填写全部支持的事件类型。
  • 为生产环境准备密钥管理服务和持久化去重存储。

本指南使用 /webhooks/managed-agents 作为回调路径。Python 示例只依赖标准库,通过 HTTPServer 监听 0.0.0.0:8000。

创建并测试接入点#

第 1 步:创建接入点#

配置 ARK_API_KEY,并将示例中的 url 替换为公网回调地址,然后创建 Webhook 接入点。接入点创建成功后默认处于 ACTIVE 状态。

Bash
curl -sS --fail-with-body "https://ark.cn-beijing.volces.com/api/v3/webhook_endpoints" \
  -H "Authorization: Bearer $ARK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/managed-agents",
    "description": "接收 Session 空闲和终止通知",
    "events": [
      "session.status_idled",
      "session.status_terminated"
    ]
  }'

创建成功后,响应中的 id 以 wep- 开头,signing_secret 以 whsec_ 开头。signing_secret 仅在本次创建响应中返回一次。

立即将两个值保存到服务端配置:

Bash
export WEBHOOK_ENDPOINT_ID="<WEBHOOK_ENDPOINT_ID>"
export WEBHOOK_SIGNING_SECRET="<SIGNING_SECRET>"

第 2 步:启动接收端#

Python 示例先读取原始请求体,完成时间窗口和签名校验后再解析 JSON。验签成功后,示例按事件 ID 在内存中去重,并返回 HTTP 204。

Python
import base64
import binascii
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer


WEBHOOK_PATH = "/webhooks/managed-agents"
MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60
PROCESSED_EVENT_IDS: set[str] = set()


def decode_signing_secret(value: str) -> bytes:
    if not value.startswith("whsec_"):
        raise ValueError("WEBHOOK_SIGNING_SECRET must start with whsec_")

    encoded_secret = value.removeprefix("whsec_")
    encoded_secret += "=" * (-len(encoded_secret) % 4)
    try:
        secret = base64.b64decode(encoded_secret, validate=True)
    except (binascii.Error, ValueError) as exc:
        raise ValueError("WEBHOOK_SIGNING_SECRET contains invalid Base64 data") from exc
    if len(secret) != 32:
        raise ValueError("WEBHOOK_SIGNING_SECRET must decode to 32 bytes")
    return secret


def verify_signature(
    secret: bytes,
    webhook_id: str,
    timestamp: str,
    raw_body: bytes,
    signature_header: str,
) -> bool:
    signed_content = (
        webhook_id.encode("utf-8")
        + b"."
        + timestamp.encode("utf-8")
        + b"."
        + raw_body
    )
    digest = hmac.new(secret, signed_content, hashlib.sha256).digest()
    expected_signature = base64.b64encode(digest).decode("ascii")

    version, separator, signature = signature_header.partition(",")
    return bool(
        separator
        and version == "v1"
        and hmac.compare_digest(signature, expected_signature)
    )


class WebhookHandler(BaseHTTPRequestHandler):
    signing_secret: bytes

    def send_status(self, status_code: int, message: str | None = None) -> None:
        response_body = b""
        if message is not None:
            response_body = json.dumps({"error": message}).encode("utf-8")

        self.send_response(status_code)
        if response_body:
            self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(response_body)))
        self.end_headers()
        if response_body:
            self.wfile.write(response_body)

    def do_POST(self) -> None:
        if self.path != WEBHOOK_PATH:
            self.send_status(404, "Webhook path not found")
            return

        content_length = self.headers.get("Content-Length")
        try:
            body_length = int(content_length or "")
        except ValueError:
            self.send_status(400, "Invalid Content-Length")
            return
        if body_length < 0:
            self.send_status(400, "Invalid Content-Length")
            return

        raw_body = self.rfile.read(body_length)
        webhook_id = self.headers.get("webhook-id", "")
        timestamp = self.headers.get("webhook-timestamp", "")
        signature = self.headers.get("webhook-signature", "")
        if not webhook_id or not timestamp or not signature:
            self.send_status(400, "Missing webhook signature headers")
            return

        try:
            timestamp_seconds = int(timestamp)
        except ValueError:
            self.send_status(400, "Invalid webhook timestamp")
            return

        if abs(time.time() - timestamp_seconds) > MAX_TIMESTAMP_SKEW_SECONDS:
            self.send_status(401, "Webhook timestamp is outside the allowed window")
            return

        if not verify_signature(
            self.signing_secret,
            webhook_id,
            timestamp,
            raw_body,
            signature,
        ):
            self.send_status(401, "Invalid webhook signature")
            return

        try:
            event = json.loads(raw_body)
        except (json.JSONDecodeError, UnicodeDecodeError):
            self.send_status(400, "Invalid JSON body")
            return

        if not isinstance(event, dict):
            self.send_status(400, "Webhook body must be a JSON object")
            return

        event_id = event.get("id")
        if (
            not isinstance(event_id, str)
            or not event_id.startswith("whe-")
            or event_id != webhook_id
        ):
            self.send_status(400, "Webhook event ID does not match the request header")
            return

        if event_id in PROCESSED_EVENT_IDS:
            self.send_status(204)
            return

        data = event.get("data")
        event_type = data.get("type") if isinstance(data, dict) else None
        print(f"Received event {event_id}: {event_type}", flush=True)
        PROCESSED_EVENT_IDS.add(event_id)
        self.send_status(204)


def main() -> None:
    secret_value = os.environ.get("WEBHOOK_SIGNING_SECRET")
    if not secret_value:
        raise SystemExit("Set WEBHOOK_SIGNING_SECRET before starting the server")

    try:
        WebhookHandler.signing_secret = decode_signing_secret(secret_value)
    except ValueError as exc:
        raise SystemExit(str(exc)) from exc

    server = HTTPServer(("0.0.0.0", 8000), WebhookHandler)
    print(f"Listening on http://0.0.0.0:8000{WEBHOOK_PATH}", flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()


if __name__ == "__main__":
    main()

配置创建响应中的签名密钥,然后启动服务:

Bash
export WEBHOOK_SIGNING_SECRET="<SIGNING_SECRET>"
python code/managed-agents/managed-agents-receive-webhook-python.py

启动成功后,终端显示以下地址:

text
Listening on http://0.0.0.0:8000/webhooks/managed-agents

将该服务部署到创建接入点时填写的公网回调地址。回调路径必须保持一致。

第 3 步:发送测试事件#

发送一个 webhook.test 事件,验证公网路由、请求头、验签和响应状态。

Bash
curl -sS --fail-with-body -X POST \
  "https://ark.cn-beijing.volces.com/api/v3/webhook_endpoints/$WEBHOOK_ENDPOINT_ID/test" \
  -H "Authorization: Bearer $ARK_API_KEY"

测试接口返回 HTTP 200 表示平台已经生成并受理测试事件,不表示接收端已经收到事件。测试事件通过异步任务投递一次,不受订阅列表和接入点启停状态影响,也不会触发重试。普通接收失败不改变接入点状态。

每次调用都会生成新的测试事件。请求超时后直接重试可能导致接收端收到多个不同事件 ID 的测试通知。

接收端收到事件后输出:

text
Received event whe-20261012080500-f6g7h: webhook.test

确认日志中的 whe- ID 与测试接口响应中的 id 相同。Python 示例处理成功后返回 HTTP 204。

验签并处理事件#

每次回调包含以下签名请求头:

请求头 说明
Content-Type 固定为 application/json,正文使用 UTF-8 编码
User-Agent 固定为 ArkManagedAgents-Webhook/1.0
webhook-id Webhook 事件 ID,以 whe- 开头,与正文外层 id 相同
webhook-timestamp 生成签名时使用的 Unix 秒时间戳
webhook-signature v1,<BASE64_SIGNATURE> 格式的签名

签名内容按以下顺序拼接:

text
signed_content = webhook-id + "." + webhook-timestamp + "." + raw_body
signature = Base64(HMAC-SHA256(Base64Decode(signing_secret 去掉 whsec_ 前缀), signed_content))

其中,webhook-id 和 webhook-timestamp 使用 UTF-8 编码。raw_body 必须是网络中收到的原始字节。

按以下顺序处理请求:

  1. 读取 webhook-id、webhook-timestamp、webhook-signature 和原始请求体。
  2. 将 webhook-timestamp 解析为 Unix 秒,并拒绝与服务端当前时间相差超过 5 分钟的请求。
  3. 去掉 signing_secret 的 whsec_ 前缀,再对剩余内容进行 Base64 解码。解码结果必须为 32 字节。
  4. 使用 webhook-id.timestamp.raw_body 计算 HMAC-SHA256 和 Base64 签名。
  5. 校验签名版本为 v1,并使用 hmac.compare_digest 等恒定时间比较函数比较 Base64 签名。
  6. 验签通过后解析 JSON,并确认正文外层 id 与 webhook-id 相同。
  7. 按外层 id 去重。重复收到同一个 whe- ID 时,不要重复执行有副作用的业务操作。
  8. 可靠保存事件或业务处理结果后,返回 HTTP 2xx。

Python 示例使用内存集合去重,进程重启后记录会丢失。生产环境应在数据库或缓存中为事件 ID 建立唯一约束,并将去重记录的保留时间覆盖业务追溯周期。

投递和重试#

业务事件不保证严格顺序或恰好一次投递。同一事件重试时,webhook-id、created_at 和原始正文保持不变。平台会为每次尝试重新生成 webhook-timestamp 和 webhook-signature。

规则 行为
成功响应 接收端返回任意 HTTP 2xx 后,本次投递成功;平台忽略响应正文
单次超时 DNS、连接、TLS、发送和读取的总时间最多为 5 秒
最大次数 同一业务事件最多投递 3 次
重试间隔 第一次失败后等待 5 秒,第二次失败后等待 30 秒
截止时间 事件产生超过 24 小时后,平台不再发起投递
可重试失败 HTTP 408、425、429、5xx,以及 DNS、连接或读写错误
不重试失败 其他 4xx、3xx、URL 安全校验失败和 HTTPS 证书校验失败

只在事件已经可靠入库或业务结果已经持久化后返回 2xx。需要异步处理时,先把事件写入内部队列,再返回 204。返回 3xx 不会让平台跟随重定向。

查询事件记录#

使用事件列表接口核对指定接入点最近 7 天内关联的事件。列表按事件发生时间倒序排列,支持按 event_id 或 event_type 精确筛选:

Bash
#!/usr/bin/env bash
curl -sS --fail-with-body \
  "https://ark.cn-beijing.volces.com/api/v3/webhook_endpoints/$WEBHOOK_ENDPOINT_ID/events?limit=20&event_type=session.status_idled" \
  -H "Authorization: Bearer $ARK_API_KEY"

响应中的 data 是冻结事件信封数组。还有更多结果时,next_page 返回下一页标识;将该值原样传入 page 查询下一页。最后一页返回 next_page: null。

需要读取单个事件时,配置列表或接收日志中的事件 ID:

Bash
export WEBHOOK_EVENT_ID="<WEBHOOK_EVENT_ID>"

然后查询事件详情:

Bash
#!/usr/bin/env bash
curl -sS --fail-with-body \
  "https://ark.cn-beijing.volces.com/api/v3/webhook_endpoints/$WEBHOOK_ENDPOINT_ID/events/$WEBHOOK_EVENT_ID" \
  -H "Authorization: Bearer $ARK_API_KEY"

事件详情与原回调请求体一致。接口不会重新读取业务资源,不返回投递是否成功、HTTP 尝试次数或每次失败原因,也不支持重放事件。接入点停用后仍可查询;删除接入点后,在 7 天墓碑保留期内仍可查询尚未过期的关联事件。

运维接入点#

暂停和更新#

需要迁移回调地址或进行维护时,先停用接入点:

Bash
curl -sS --fail-with-body -X POST \
  "https://ark.cn-beijing.volces.com/api/v3/webhook_endpoints/$WEBHOOK_ENDPOINT_ID/disable" \
  -H "Authorization: Bearer $ARK_API_KEY"

停用会取消尚未开始和等待重试的投递。已经发出的请求仍可能到达,停用期间产生的事件不会在恢复后补发。

更新回调地址、描述或订阅列表。只有停用状态下可以修改回调地址:

Bash
curl -sS --fail-with-body -X PUT \
  "https://ark.cn-beijing.volces.com/api/v3/webhook_endpoints/$WEBHOOK_ENDPOINT_ID" \
  -H "Authorization: Bearer $ARK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "接收 Session 状态变更通知",
    "events": [
      "session.status_idled",
      "session.status_terminated"
    ]
  }'

更新订阅列表会整体替换原列表。移除事件类型会取消尚未开始和等待重试的对应投递;新增事件类型不会补发历史事件。更新后先发送测试事件,再启用接入点:

Bash
curl -sS --fail-with-body -X POST \
  "https://ark.cn-beijing.volces.com/api/v3/webhook_endpoints/$WEBHOOK_ENDPOINT_ID/enable" \
  -H "Authorization: Bearer $ARK_API_KEY"

启用成功后,平台清空停用原因和连续失败数,但不会补发停用期间的业务事件。启用接口不会先向回调地址发送探测请求,因此应在启用前发送测试事件并确认接收成功。

恢复自动停用#

一个业务事件经过全部尝试后仍未成功,只计为一次最终投递失败。连续 20 个业务事件最终投递失败时,平台自动停用接入点,并将 disabled_reason 设为 consecutive_failures。

以下问题也会导致接入点自动停用:

  • 回调地址返回 3xx,disabled_reason 为 redirect。
  • 回调地址未通过公网地址安全校验,disabled_reason 为 unsafe_url。
  • HTTPS 证书或主机名校验失败,disabled_reason 为 invalid_certificate。

恢复时,先查询接入点详情并检查 disabled_reason。修复回调服务后,按以下顺序操作:

  1. 更新停用状态下的回调地址或订阅列表。
  2. 发送测试事件,并确认接收端返回 2xx。
  3. 调用启用接口恢复业务事件投递。
  4. 通过资源查询接口核对停用期间的状态变化。

重置签名密钥#

怀疑密钥泄露或需要定期轮换时,重置签名密钥。平台不提供新旧密钥并行生效窗口;重置成功后立即使用新密钥签名后续请求。根据业务是否允许短暂停止通知,选择以下方式。

在维护窗口轮换#

本指南提供的 Python 接收端一次只读取一个 WEBHOOK_SIGNING_SECRET,适合在维护窗口中轮换:

  1. 停用 Webhook 接入点。停用会取消尚未开始和等待重试的投递,停用期间产生的事件不会在恢复后补发。
  2. 重置签名密钥:
Bash
curl -sS --fail-with-body -X POST \
  "https://ark.cn-beijing.volces.com/api/v3/webhook_endpoints/$WEBHOOK_ENDPOINT_ID/reset_secret" \
  -H "Authorization: Bearer $ARK_API_KEY"
  1. 保存响应中的新密钥,更新接收端的 WEBHOOK_SIGNING_SECRET,然后重启接收服务。
  2. 发送测试事件并确认验签成功。测试事件在接入点停用期间也可以投递。
  3. 启用接入点,恢复业务事件投递。
  4. 通过 Webhook 事件查询和对应资源查询接口,核对维护窗口内的状态变化。

新密钥仅在本次成功响应中返回。关闭响应前,将新密钥写入密钥管理服务。

持续接收时轮换#

需要持续接收业务通知时,不要直接使用“重置密钥后更新环境变量并重启”的顺序。先部署支持受控轮换的接收端,再执行以下步骤:

  1. 让接收端进入短时轮换模式:继续使用旧密钥验签;仅在本次受控窗口内,对无法验签的请求返回 HTTP 503,使业务事件进入平台重试流程。
  2. 调用重置密钥接口并保存新密钥。
  3. 在接收端原子加载新密钥,同时保留旧密钥。过渡期间使用新旧两个密钥依次验签,并继续按事件 ID 去重。
  4. 发送测试事件,确认新密钥验签成功。业务事件最多投递 3 次,应在重试次数用尽前完成新密钥加载。
  5. 保留旧密钥至少覆盖接收端允许的签名时间窗口;本指南示例为 5 分钟。随后移除旧密钥,并退出轮换模式。

清理资源#

确认不再需要事件通知后,使用精确的 WEBHOOK_ENDPOINT_ID 删除接入点:

Bash
curl -sS --fail-with-body -X DELETE \
  "https://ark.cn-beijing.volces.com/api/v3/webhook_endpoints/$WEBHOOK_ENDPOINT_ID" \
  -H "Authorization: Bearer $ARK_API_KEY"

删除成功返回 HTTP 204。删除会取消尚未开始和等待重试的投递,并立即清除服务端保存的签名密钥。已经发出的请求仍可能到达。删除后 7 天内重复调用删除接口仍返回 204。

删除接入点后,移除接收服务中的密钥和本地环境变量:

Bash
unset WEBHOOK_SIGNING_SECRET
unset WEBHOOK_ENDPOINT_ID
unset WEBHOOK_EVENT_ID