跳转到主要内容
Webhook 让你通过 POST 而不是轮询接收任务生命周期更新。在任务上 注册回调 URL,任务每次状态切换都会命中它。
状态 —— P2-A 已完整落地:回调 (1) 用 HMAC-SHA256 可签名X-BeeOS-Signature), (2) 失败按文档化的指数退避重试(1m → 5m → 30m → 2h → 12h, 之后死信), (3) 通过 GET .../deliveriesPOST .../deliveries/{id}/redeliver 可审计 + 可重放
单次尝试 10s 超时仍按尝试生效,但失败尝试现在能熬过进程崩溃, 由后台重试 worker 接管。

1. 生命周期


2. 注册 webhook

请求

POST /api/v1/agents/agent_abc123/tasks/ch-uuid/webhooks
Authorization: Bearer oag_...
Content-Type: application/json

{
  "url": "https://example.com/beeos/hook",
  "token": "optional-bearer-shared-secret",
  "secret": "wh_sec_a3b1...4f"
}
字段必填说明
url绝对的 http://https://。除 scheme 校验外不验证路径。
token共享密钥。设置后 BeeOS 在每次回调上发 Authorization: Bearer <token>。空则匿名回调。
secretP2-A HMAC-SHA256 签名 key(16-256 字符)。设置后每次回调带 X-BeeOS-Signature(格式 t=<unix>,v1=<hex> —— 见 §6 HMAC 签名)。只写 —— Get / List 永不返回。

响应(201)

{
  "success": true,
  "data": {
    "webhook_id": "wh-uuid",
    "task_id": "ch-uuid",
    "url": "https://example.com/beeos/hook",
    "has_secret": true,
    "created_at": "2026-05-14T18:00:00Z"
  }
}
tokensecret任何 list / get 响应里都永不返回 —— 注册后仅服务端使用。has_secret 布尔值让 UI / SDK 不暴露 secret 本身的情况下显示 “已启用签名”。轮换 secret 用一个新值 Set 即可 (传 "" 不影响现有 secret —— 要清空请 DELETE + 重新注册)。

列出 / 删除

GET    /api/v1/agents/{agentId}/tasks/{taskId}/webhooks
DELETE /api/v1/agents/{agentId}/tasks/{taskId}/webhooks/{webhookId}
一个任务可以持有任意数量的 webhook(目前没有显式上限)。删除立即生效 —— 不取消进行中的投递,但删除后不再有新尝试。

3. 当前限制

约束投递契约的几点:
限制行为解决
单次尝试 10s 超时接收方超过 10s 请求被取消。开了重试后会按退避日程重新尝试。handler 保持快速(< 1s)。把 payload 排入后台队列,立即 200 ACK。
不跟随重定向HTTP 3xx 响应被当作最终结果,跟随。retry 调度器把它和其他非 2xx 一样处理(会重试)。注册实际目的地 URL,不要注册重定向器。
并发上限 5每次 firePushWebhooks 调用最多 5 路 webhook 并行扇出。订阅者多的任务会看到扇出排队。单任务不要注册 > 5 个 webhook;在你的边界合并。
payload schema 未版本化payload 格式取决于注册时绑定的 renderer(见 §4)。renderer 在注册时固定。在接收方代码里盖自己的版本;BeeOS 若改 renderer 按 ADR-0017 是加法(额外字段)。
最多 6 次尝试1m / 5m / 30m / 2h / 12h 日程跑完后,行进入 dead_letter修复接收方后通过 §7 deliveries / redeliver 手动重放。

4. Payload 格式

BeeOS 支持三种 renderer,由注册时的 protocol_filter 列选择。 通过此 OpenAPI 端点注册的 webhook 始终用 openapi renderer。

openapi renderer —— TaskEvent 信封

匹配 GET /tasks/{id}/events 的 SSE 流 payload,可共享 decoder。 每次状态切换(中间态 + 终态)都发。
POST {your-url}
Content-Type: application/json
X-BeeOS-Webhook-Format: openapi
Authorization: Bearer <your-token>       ← 仅当你 register 时设了 `token`
{
  "type": "task_status",
  "task_id": "ch-uuid",
  "context_id": "ch-uuid",
  "status": "succeeded",
  "final": true,
  "timestamp": "2026-05-14T18:01:23.456Z"
}
可能的 status 值:queuedrunninginput_requiredauth_requiredsucceededfailedcanceledtimeoutrejected(见 调用智能体 §状态参考)。 final: true 仅在终止态。

a2ageneric renderer

供 A2A JSON-RPC 客户端和无 protocol filter 注册的通用 / 旧版订阅者 使用。OpenAPI Gateway 不可选 —— 仅文档参考。详见 webhook_renderer.go

5. 接收方 checklist

针对当前投递语义,生产级接收方需要:
  1. 跑在 HTTPS 上。 无例外 —— tokensecret 在传输中都敏感。
  2. 先验签X-BeeOS-Signature)—— 注册了 secret 时。用 §6 的 recipe。不匹配或时间戳过旧返回 401
  3. 再验可选的 Authorization bearer token —— 注册了 token 时。 不匹配返回 401
  4. 1s 内 ACK(10s 上限)。payload 内部排队;不在 inline 跑业务逻辑。
  5. 保持幂等 —— retry 调度器可能引入重复(at-least-once 投递)。 按 (task_id, status, timestamp) 作为 key,在接收方去重。
  6. 对必须不丢的终止态 webhook(如计费相关),周期性轮询 GET /tasks/{id}。Webhook 是”快速通知”;API 是”真相”。
  7. 丢弃未知 type —— renderer 的扩展按 ADR-0017 是加法(额外 字段 / 新类型);不要对新事件类型崩溃。
app.post("/beeos/hook", express.json(), async (req, res) => {
  const expected = `Bearer ${process.env.BEEOS_WEBHOOK_TOKEN}`;
  if (req.header("authorization") !== expected) return res.sendStatus(401);
  await queue.add(req.body);          // fast ACK, real work later
  res.sendStatus(200);
});

6. HMAC 签名(P2-A)

注册时设 secret 开启 HMAC-SHA256 body 签名(见 §2)。投递器随后 在每次回调上发两个额外 header:
  • X-BeeOS-Event: task.state
  • X-BeeOS-Task-Id: ch-uuid
  • X-BeeOS-Signature: t=<unix>,v1=<hex>,其中 hex = hmac_sha256(secret, "<unix>." || body)
其他 header(X-BeeOS-Webhook-Format、可选的 Authorization: Bearer <token>)不变。轮换期可同时叠加签名 + bearer token。

验证 recipe —— Python

import hmac, hashlib, time, json
from flask import abort, request

WEBHOOK_SECRET = open("/etc/secrets/beeos-webhook").read().strip().encode()
CLOCK_SKEW_SEC = 300                                # ±5 min

def verify_signature() -> None:
    sig_header = request.headers.get("X-BeeOS-Signature", "")
    parts = dict(p.split("=", 1) for p in sig_header.split(",") if "=" in p)
    try:
        ts = int(parts.get("t", ""))
    except ValueError:
        abort(400, "malformed signature header")
    if abs(time.time() - ts) > CLOCK_SKEW_SEC:
        abort(401, "stale or future-dated signature")
    body = request.get_data()
    expected = hmac.new(
        WEBHOOK_SECRET,
        f"{ts}.".encode() + body,
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(401, "bad signature")

验证 recipe —— Node.js / Express

import crypto from "node:crypto";

const WEBHOOK_SECRET = process.env.BEEOS_WEBHOOK_SECRET!;
const CLOCK_SKEW_SEC = 300;

app.post("/beeos/hook",
  express.raw({ type: "application/json" }),       // raw bytes for HMAC
  (req, res, next) => {
    const sig = String(req.header("x-beeos-signature") ?? "");
    const parts = Object.fromEntries(sig.split(",").map((p) => p.split("=")));
    const ts = Number(parts.t);
    if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > CLOCK_SKEW_SEC) {
      return res.sendStatus(401);
    }
    const body = req.body as Buffer;
    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(`${ts}.`)
      .update(body)
      .digest("hex");
    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""))) {
      return res.sendStatus(401);
    }
    // Now safe to JSON.parse(body.toString()).
    next();
  },
  // ...your handler...
);
关键:对原始请求字节做 hash,不是对重新序列化的 JSON 对象。 重新序列化会改变空白 / key 顺序,HMAC 不会匹配。

为什么有 ±5 分钟时差检查?

没有时间戳检查时,捕获一次有效回调的攻击者可以永久重放。拒绝过旧或 未来日期的时间戳缩小重放窗口。±5 分钟是 BeeOS 推荐值;封闭网络 接收方可以更严。

7. 投递审计日志 + 手动重放(P2-A part 3)

每次回调尝试现在都被记录为一行持久行,可通过两个 REST 端点列出和 重放。这些行能熬过进程崩溃(lease 过期后 retry worker 接管), 所以临时部署 / 滚动重启不再丢失飞行中的回调。

重试日程

失败一次后,按固定退避重排:
第 N 次 → N+1 次重排延迟
1 → 21 分钟
2 → 35 分钟
3 → 430 分钟
4 → 52 小时
5 → 612 小时
6 → ✗移入 dead_letter
行在尝试之间保持 pending(schedule 触发时 worker 把它从 failed 翻回 pending)。 POST .../redeliver 手动重放无论之前自动重试 多少次,都会把行 clone 为一行新的 pending

生命周期状态

状态含义终止态?
pending排队待首次尝试或等待计划重试。
succeeded接收方返回 2xx。
failed上次尝试失败 / 非 2xx;下一次已计划。否(中间态)
dead_letter日程耗尽。需要手动 redeliver

GET .../deliveries

GET /api/v1/agents/{agentId}/tasks/{taskId}/webhooks/{webhookId}/deliveries?limit=50
Authorization: Bearer oag_...
返回最近的投递行,新到旧。limit 钳制到 [1, 200],默认 50。
{
  "success": true,
  "data": {
    "deliveries": [
      {
        "delivery_id": "del-uuid",
        "webhook_id": "wh-uuid",
        "task_id": "ch-uuid",
        "renderer": "openapi",
        "status": "succeeded",
        "attempt_num": 1,
        "last_response_status": 200,
        "last_error": "",
        "next_attempt_at": "2026-05-19T10:00:00Z",
        "last_attempted_at": "2026-05-19T10:00:00Z",
        "created_at": "2026-05-19T10:00:00Z",
        "completed_at": "2026-05-19T10:00:00Z"
      },
      {
        "delivery_id": "del-uuid-2",
        "webhook_id": "wh-uuid",
        "task_id": "ch-uuid",
        "renderer": "openapi",
        "status": "dead_letter",
        "attempt_num": 6,
        "last_response_status": 502,
        "last_error": "http_status: 502",
        "next_attempt_at": "2026-05-19T22:00:00Z",
        "last_attempted_at": "2026-05-19T22:00:00Z",
        "created_at": "2026-05-19T08:00:00Z",
        "completed_at": "2026-05-19T22:00:00Z"
      }
    ]
  }
}
原始 payload 字节、bearer token、HMAC secret 永不返回 —— 只有诊断字段。要验证接收方应该看到什么,请查阅你自己的源任务 状态切换日志;renderer 字段告诉你应用了哪个 payload schema。

POST .../redeliver

POST /api/v1/agents/{agentId}/tasks/{taskId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver
Authorization: Bearer oag_...
把一行 faileddead_letter clone 为一行新的 pending。clone 重发完全一致的 payload 字节(和 HMAC 签名)—— 接收方可以把 手动重放的回调和自动重试同等对待。 返回 202 + 新 pending 行:
{
  "success": true,
  "data": {
    "delivery_id": "del-uuid-new",
    "status": "pending",
    "attempt_num": 0,
    "...": "..."
  }
}
错误:
状态Code何时
404not_found投递不存在或不属于该 webhook。
409conflict源行是 pendingsucceeded —— 只有 failed / dead_letter 行能重放。
403forbidden调用方不拥有该任务。
每次 redeliver 调用都入队一行新的 pending —— 反复调用产生反复 投递。把此端点当作手动救场,不是调用方侧”网络出错就重试”的工具。

另请参阅