如果你需要把 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 后自动推进业务。收到该通知后,按以下顺序判定结果:
- 从 Webhook 事件的
data.id读取 Session ID,并按 Webhook 外层id去重。 - 找到业务侧记录的本轮边界。推荐在发送本轮
user.message、user.tool_result或user.tool_confirmation后,保存发送事件接口返回的事件 ID;如果接收端已经持续消费 Session 事件,也可以保存上一条已处理 Session Event 的processed_at作为水位。 - 使用 Session ID 调用 查询会话事件列表,查询本轮边界之后的历史事件。可以用
created_at[gt]缩小时间窗口,并按processed_at正序扫描;只有确认候选session.status_idle位于本轮边界之后,才将它作为本次通知对应的状态事件。 - 读取对应
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 状态。
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 仅在本次创建响应中返回一次。
立即将两个值保存到服务端配置:
export WEBHOOK_ENDPOINT_ID="<WEBHOOK_ENDPOINT_ID>"
export WEBHOOK_SIGNING_SECRET="<SIGNING_SECRET>"
第 2 步:启动接收端#
Python 示例先读取原始请求体,完成时间窗口和签名校验后再解析 JSON。验签成功后,示例按事件 ID 在内存中去重,并返回 HTTP 204。
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()
配置创建响应中的签名密钥,然后启动服务:
export WEBHOOK_SIGNING_SECRET="<SIGNING_SECRET>"
python code/managed-agents/managed-agents-receive-webhook-python.py
启动成功后,终端显示以下地址:
Listening on http://0.0.0.0:8000/webhooks/managed-agents
将该服务部署到创建接入点时填写的公网回调地址。回调路径必须保持一致。
第 3 步:发送测试事件#
发送一个 webhook.test 事件,验证公网路由、请求头、验签和响应状态。
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 的测试通知。
接收端收到事件后输出:
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> 格式的签名 |
签名内容按以下顺序拼接:
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 必须是网络中收到的原始字节。
按以下顺序处理请求:
- 读取
webhook-id、webhook-timestamp、webhook-signature和原始请求体。 - 将
webhook-timestamp解析为 Unix 秒,并拒绝与服务端当前时间相差超过 5 分钟的请求。 - 去掉
signing_secret的whsec_前缀,再对剩余内容进行 Base64 解码。解码结果必须为 32 字节。 - 使用
webhook-id.timestamp.raw_body计算 HMAC-SHA256 和 Base64 签名。 - 校验签名版本为
v1,并使用hmac.compare_digest等恒定时间比较函数比较 Base64 签名。 - 验签通过后解析 JSON,并确认正文外层
id与webhook-id相同。 - 按外层
id去重。重复收到同一个whe-ID 时,不要重复执行有副作用的业务操作。 - 可靠保存事件或业务处理结果后,返回 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 精确筛选:
#!/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:
export WEBHOOK_EVENT_ID="<WEBHOOK_EVENT_ID>"
然后查询事件详情:
#!/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 天墓碑保留期内仍可查询尚未过期的关联事件。
运维接入点#
暂停和更新#
需要迁移回调地址或进行维护时,先停用接入点:
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"
停用会取消尚未开始和等待重试的投递。已经发出的请求仍可能到达,停用期间产生的事件不会在恢复后补发。
更新回调地址、描述或订阅列表。只有停用状态下可以修改回调地址:
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"
]
}'
更新订阅列表会整体替换原列表。移除事件类型会取消尚未开始和等待重试的对应投递;新增事件类型不会补发历史事件。更新后先发送测试事件,再启用接入点:
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。修复回调服务后,按以下顺序操作:
- 更新停用状态下的回调地址或订阅列表。
- 发送测试事件,并确认接收端返回 2xx。
- 调用启用接口恢复业务事件投递。
- 通过资源查询接口核对停用期间的状态变化。
重置签名密钥#
怀疑密钥泄露或需要定期轮换时,重置签名密钥。平台不提供新旧密钥并行生效窗口;重置成功后立即使用新密钥签名后续请求。根据业务是否允许短暂停止通知,选择以下方式。
在维护窗口轮换#
本指南提供的 Python 接收端一次只读取一个 WEBHOOK_SIGNING_SECRET,适合在维护窗口中轮换:
- 停用 Webhook 接入点。停用会取消尚未开始和等待重试的投递,停用期间产生的事件不会在恢复后补发。
- 重置签名密钥:
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"
- 保存响应中的新密钥,更新接收端的
WEBHOOK_SIGNING_SECRET,然后重启接收服务。 - 发送测试事件并确认验签成功。测试事件在接入点停用期间也可以投递。
- 启用接入点,恢复业务事件投递。
- 通过 Webhook 事件查询和对应资源查询接口,核对维护窗口内的状态变化。
新密钥仅在本次成功响应中返回。关闭响应前,将新密钥写入密钥管理服务。
持续接收时轮换#
需要持续接收业务通知时,不要直接使用“重置密钥后更新环境变量并重启”的顺序。先部署支持受控轮换的接收端,再执行以下步骤:
- 让接收端进入短时轮换模式:继续使用旧密钥验签;仅在本次受控窗口内,对无法验签的请求返回 HTTP 503,使业务事件进入平台重试流程。
- 调用重置密钥接口并保存新密钥。
- 在接收端原子加载新密钥,同时保留旧密钥。过渡期间使用新旧两个密钥依次验签,并继续按事件 ID 去重。
- 发送测试事件,确认新密钥验签成功。业务事件最多投递 3 次,应在重试次数用尽前完成新密钥加载。
- 保留旧密钥至少覆盖接收端允许的签名时间窗口;本指南示例为 5 分钟。随后移除旧密钥,并退出轮换模式。
清理资源#
确认不再需要事件通知后,使用精确的 WEBHOOK_ENDPOINT_ID 删除接入点:
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。
删除接入点后,移除接收服务中的密钥和本地环境变量:
unset WEBHOOK_SIGNING_SECRET
unset WEBHOOK_ENDPOINT_ID
unset WEBHOOK_EVENT_ID