Skip to content

Webhook:任务完成回调 ​

任务状态变化时,Hub 会主动 POST 到你在 Key 上配置的回调地址。 这是推荐的结果获取方式:任务是分钟级的,轮询 100 个 jobId 既慢又浪费。

1. 事件 ​

事件触发时机isFinal
job.queued提交成功、任务入队false
job.started节点领取开始执行false
job.retrying失败后自动重新排队(还会再跑)false
job.completed执行成功true
job.failed最终失败(不再重试)true
job.cancelled被取消true
batch.completed整批任务全部到终态true

⚠️ 只看 isFinal=true 判断终态。同一个 job 可能先收到 job.failed(第 1 次尝试失败)再收到 job.retrying,最后 job.completed(第 2 次成功)。中间失败不是终态。

2. 请求 ​

http
POST https://team.cd-zq.com/api/zqagent/webhook
Content-Type: application/json
X-ZQ-Event: job.completed
X-ZQ-Signature: v1=3f9a...c21e
X-ZQ-Timestamp: 1762674000
X-ZQ-Delivery: dlv_7f2c9a
X-ZQ-Attempt: 1

{ ...载荷... }
响应头/头说明
X-ZQ-Event事件名
X-ZQ-Signaturev1= + HMAC-SHA256 十六进制
X-ZQ-TimestampUnix 秒
X-ZQ-Delivery本次投递 id(重试时保持不变)
X-ZQ-Attempt第几次投递(从 1 开始)

你的服务返回 2xx 即视为成功,响应体不解析。其它状态码或 10 秒超时都算失败。

3. 载荷 ​

json
{
  "event": "job.completed",
  "deliveryId": "dlv_7f2c9a",
  "jobId": "job_9f2c",
  "externalId": "TASK-1024",
  "batchId": "sprint-42",
  "attempt": 2,
  "maxAttempts": 4,
  "isFinal": true,
  "status": "done",
  "kind": "ai",
  "nodeId": "node_be",
  "projectId": "proj_order",
  "sessionId": "sess_x1",
  "queuedAt": "2026-11-09T10:05:00Z",
  "startedAt": "2026-11-09T10:05:12Z",
  "finishedAt": "2026-11-09T10:12:03Z",
  "durationMs": 411000,
  "nextRetryAt": null,
  "result": {
    "summary": "已生成 reports/todo.md,共 23 项",
    "producedFiles": ["reports/todo.md"],
    "usage": { "inputTokens": 21000, "outputTokens": 3400 },
    "iterations": 12,
    "toolCalls": 18
  },
  "failure": null,
  "occurredAt": "2026-11-09T10:12:03Z"
}

字段 ​

字段说明
jobId / externalId服务端 id / 你自己的任务 id
isFinal是否终态(见上节)
attempt / maxAttempts第几次尝试 / 总共允许几次
result.summary最终回复(AI)或输出尾部(脚本),最多 4000 字符
result.producedFiles本轮改动的文件(工作区相对路径)
result.usagetoken 用量
failure.type失败分类(见 jobs.md)
nextRetryAtjob.retrying 时为下次开始时间

batch.completed 载荷 ​

json
{
  "event": "batch.completed",
  "deliveryId": "dlv_8a1b",
  "jobId": null,
  "batchId": "sprint-42",
  "isFinal": true,
  "batch": {
    "total": 100,
    "succeeded": 97,
    "failed": 3,
    "cancelled": 0,
    "failedJobs": [
      { "jobId": "job_9f2c", "externalId": "TASK-17", "failureType": "llm_error" }
    ]
  },
  "occurredAt": "2026-11-09T11:30:00Z"
}

这是"汇总"最省事的形态:监听一个事件即可,不用自己遍历 100 个 jobId。

4. 验签 ​

4.1 算法 ​

signature = hex(HMAC_SHA256(webhookSecret, timestamp + "." + rawBody))
  • webhookSecret:创建 Key 时一并下发(whsec_ 前缀),只显示一次
  • timestamp:X-ZQ-Timestamp 的原始字符串
  • rawBody:原始请求体字节,不要先 JSON.parse 再 stringify(键顺序/空格会变)

4.2 Node.js ​

js
import crypto from "node:crypto";

function verify(rawBody, headers, secret) {
  const ts = headers["x-zq-timestamp"];
  const got = headers["x-zq-signature"]; // "v1=..."
  const expected =
    "v1=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");

  // 定长比较,避免时序侧信道
  const a = Buffer.from(got);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;

  // 防重放:5 分钟之外的请求一律拒绝
  return Math.abs(Date.now() / 1000 - Number(ts)) < 300;
}

4.3 Python ​

python
import hmac, hashlib, time

def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
    ts = headers["X-ZQ-Timestamp"]
    got = headers["X-ZQ-Signature"]
    expected = "v1=" + hmac.new(
        secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(got, expected):
        return False
    return abs(time.time() - int(ts)) < 300

4.4 快速自测 ​

bash
SECRET="whsec_xxx"
TS=$(date +%s)
BODY='{"event":"job.completed","jobId":"job_test"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
echo "X-ZQ-Signature: v1=$SIG"

5. 重试策略 ​

次间隔
1立即
210 秒
330 秒
42 分钟
510 分钟
61 小时

之后标记 failed。投递记录落盘:Hub 进程重启后会继续投未完成的。

⚠️ 注意与任务重试区分:任务重试是 1+3 次(30s/2min/10min), 因为那涉及真实执行成本;webhook 只是重发通知,所以更宽松。

6. 幂等与乱序 ​

网络重试可能导致同一事件重复到达,也可能乱序(10 秒的重试早于 2 分钟的重试到达)。

建议:

  1. 用 X-ZQ-Delivery 去重(同一次投递重发时不变)
  2. 或按 (jobId, attempt) 取"最大 attempt 的最终状态"
  3. 处理顺序:先用 isFinal 过滤,再用 occurredAt 比较新旧
js
const key = `${payload.jobId}:${payload.attempt}`;
if (seen.has(key)) return;   // 已处理过
seen.add(key);

7. 回调地址要求 ​

要求说明
协议必须 HTTPS(载荷含任务结果,不能明文传输)
主机拒绝内网/回环/链路本地(127.*、10.*、192.168.*、172.16-31.*、::1)
白名单若 Hub 配置了允许站点,必须命中(默认配置为 https://team.cd-zq.com)
跳转不跟随 3xx(避免绕过白名单)

配置位置:创建 Key 时填 callbackUrl;或在单个任务上覆盖。

7.1 投递记录与手动重发 ​

回调没到时不靠猜。控制台「设置 → 集成」能看到最近的投递记录,接口也可以直接查:

http
GET /api/hub/agent-api/deliveries?limit=50
json
{
  "data": {
    "items": [
      {
        "id": "dlv_a1b2",
        "jobId": "job_9f2c",
        "batchId": "sprint-42",
        "event": "job.completed",
        "attempt": 3,
        "status": "pending",
        "lastError": "HTTP 502",
        "createdAt": "2026-11-09T10:12:03Z",
        "deliveredAt": null
      }
    ]
  },
  "requestId": "req_..."
}
  • status:pending(等待/重试中)、delivered、failed(超过最大重试次数,保留待人工处理)。
  • 只能看到你自己的 Key 产生的投递,看不到别人的。

确认对方服务恢复后可以手动重发(不消耗自动重试配额):

http
POST /api/hub/agent-api/deliveries/dlv_a1b2/redeliver

重发用的是当时那份载荷(不是重新生成),所以接收方按 deliveryId 去重仍然有效。

8. 排障 ​

收不到回调 ​

先查投递记录(见 7.1):pending + lastError 基本就能定位是 DNS、证书还是对方 5xx。

  1. 确认 Key 或任务上有 callbackUrl
  2. 确认地址返回 2xx(用 curl -v 看)
  3. 检查 Hub 日志:[hub:webhook] <deliveryId> 投递失败(HTTP 500)
  4. 手动重发(P2 提供管理接口;当前可重新 POST /v1/jobs/{id}/retry 触发新一轮)

验签总是失败 ​

  • 用了 JSON.stringify(JSON.parse(body)) 而不是原始 body
  • webhookSecret 用了 API Key 而不是 whsec_ 开头的密钥
  • 轮换过 Key:签名密钥也换了,需要更新配置

重复收到同一事件 ​

这是重试的正常表现(Hub 没收到 2xx)。按第 6 节去重即可, 同时检查你的服务是否在 10 秒内没返回 2xx(例如在 handler 里做了重活)。