长任务完成回调:签名、重试,以及为什么它不是唯一路径

回调让绝大多数情况下不必轮询,但并不意味着不需要轮询。本文给出契约、重试阶梯、验签方法,以及避免回调变成 SSRF 入口的目标地址规则。

发布 2026-09-13 · 更新 2026-09-29 · KeepRouter Editorial · 8 分钟阅读

推送通知示意图:提交块、指向接收块的箭头、带签名线的确认节点,以及五级递减的重试阶梯
签名投递只通知一次结果;重试阶梯决定了接收端可以坏多久。

先给结论:webhook 不是替代轮询,而是让绝大多数情况下不再需要轮询。提交视频这类长时间任务时带上 webhook_url,任务进入终态后网关会 POST 一条签名事件。轮询依然是事实来源:它不计费,并且会报告 webhook 的投递状态,因此只要仍在 24 小时取回窗口内,接收端故障就不会静默丢失结果。

这个定位很重要,因为很多 webhook 集成是把它当成"必定送达"来设计的。它不是。回调可能被重启丢掉、被一次发布拒绝,或在你服务被限流时到达。"webhook 是提示,轮询才是账本"这条设计原则,决定了你的集成是自己恢复,还是凌晨三点把人叫起来。

长任务为什么需要推送

一段 5 秒 1080p 视频可能占用 GPU 一分钟以上,所以视频接口本质是任务队列:提交、拿到任务 ID、之后再取结果。默认方式是轮询,小规模下完全够用。但如果客户端是无服务器函数(每次轮询都是一次调用)、是可能被关掉的浏览器标签页,或者任务时长超过任何礼貌的轮询间隔,轮询就不再合适。

回调把方向反过来:不再按定时器问"好了吗",而是由服务被通知一次,结果一起送达。

你的场景轮询Webhook
几秒内完成的短任务更简单过度设计
需要完成后唤醒的无服务器 worker每次轮询一次调用每个任务一次投递
可能被关闭的浏览器或移动端关掉标签就丢进度服务端独立收到完成通知
阻塞等待结果流水线步骤步骤内轮询循环步骤结束,事件再唤醒

投递契约

照常提交,额外带上 webhook_url:

curl https://keeprouter.com/v1/video/generations \
  -H "Authorization: Bearer $KEEPROUTER_KEY" -H "Content-Type: application/json" \
  -d '{"model":"veo-3.1-fast","prompt":"a paper plane over a sunlit desk","duration":4,
       "webhook_url":"https://hooks.example.com/keeprouter"}'

提交响应里除了任务 ID,还会返回一次性的签名密钥。请保存好:它只在提交响应里出现一次,轮询永远不会再返回。渲染结束后,网关 POST 的事件体包含与轮询完全一致的字段:

{
  "id": "task_QeWgyFbrwjInjPMERqtdyFvehlMM4qgX:completed",
  "type": "video.task.completed",
  "created_at": "2026-09-13T01:00:00.000Z",
  "data": {
    "id": "task_QeWgyFbrwjInjPMERqtdyFvehlMM4qgX",
    "object": "video",
    "model": "veo-3.1-fast",
    "status": "completed",
    "seconds": 4,
    "url": "https://.../renders/xxx.mp4?Expires=...&Signature=...",
    "error": null,
    "tracked_until": "2026-09-14T01:00:00.000Z"
  }
}

三个请求头携带投递元数据:kr-event-id(重试时不变)、kr-attempt(第几次尝试)、kr-signature(签名)。

返回 2xx 视为投递成功。重试策略刻意做得小而慢,值得记住,因为它决定了你的接收端可以坏多久而不丢事件:

尝试时机触发下一次的条件
第 1 次任务结束的瞬间,由观察到终态的那次请求发起响应非 2xx
第 2 次约 1 分钟后408、425、429 或任意 5xx
第 3 次再约 15 分钟后同上
第 4 次再约 1 小时后同上
第 5 次再约 6 小时后,之后停止同上

其他 4xx 视为永久失败:接收端对一个格式正确的事件回 400,再试一次还是 400。3xx 同样是永久失败,因为不跟随重定向是安全设计而不是疏漏(见下文)。

如何验签

签名是对 <时间戳>.<原始请求体> 做 HMAC-SHA256 再十六进制编码,请求头形如 t=1730000000,v1=<hex>。时间戳参与签名,因此捕获到的投递无法用新头重放。Node 示例:

import crypto from "node:crypto";

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  if (typeof header !== "string") return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (!Number.isFinite(age) || age > toleranceSeconds) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const received = parts.v1;
  if (typeof received !== "string" || !/^[0-9a-f]{64}$/i.test(received)) return false;
  return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(received, "hex"));
}

三个细节决定它在生产里是否站得住:先拒绝缺失或格式错误的签名,再做常量时间比较;必须对原始请求体验签,而不是重新序列化后的对象,因为键顺序与空白也参与了签名;比较必须用常量时间函数,而不是 ===。

双向幂等

重试投递的内容是逐字节相同的:同一个事件 ID、同一个请求体、同一个签名时间戳。这是刻意的,让你可以用唯一索引按 kr-event-id 去重,然后就不必再操心:

INSERT INTO webhook_events (event_id, received_at)
VALUES (?, now()) ON CONFLICT (event_id) DO NOTHING;

如果插入没有产生行,说明这个事件已经处理过。换成按视频链接或时间戳去重,只要两个任务在同一秒完成就会失效。

轮询要一直留着

现在每次轮询都会返回投递状态,以及跟踪窗口结束的时刻:

{
  "id": "task_...",
  "status": "completed",
  "url": "https://.../renders/xxx.mp4?Expires=...",
  "expires_at": "2026-09-14T01:00:00.000Z",
  "webhook": { "status": "delivered", "attempts": 1, "last_error": null, "delivered_at": "2026-09-13T01:00:05.000Z" }
}

这正是 webhook 可以放心依赖的原因。若 webhook.status 是 pending 且带 last_error,问题在你的接收端,你自己就能看到,不必开工单;若已是 failed,事件不会再重试,轮询是取回视频的唯一途径。expires_at 给出这件事的截止时间:网关跟踪任务 24 小时,与上游签名下载窗口一致,之后视频链接同样失效。

为什么目标地址规则这么严

发起请求的是网关本身,从 Cloudflare 内部发出,且它持有自己的数据库与存储绑定。因此"它愿意 POST 的地址"是安全边界,不是便利功能。只接受 https、443 端口、公网 DNS 主机名;内网域名、回环地址、私有网段与云元数据地址会在任务提交前就被拒绝;重定向一律不跟随,因为一个指向元数据地址的 302 会绕过上述全部规则。

被拒绝的目标地址不产生任何费用,也不会创建任务:请求以 invalid_webhook_url 失败并说明原因。建议在写提交调用之前就定好目标地址,而不是之后再改。

什么情况下不要用 webhook

一秒内完成的任务。 回调意味着一个公网端点、一个需要轮换的密钥、一条要监控的重试队列。对同步返回的请求来说,这笔交易不划算。

无法验签的接收端。 不验签的 webhook 端点比轮询更糟:它是一个收到请求就改动你状态的公开 URL,任何知道该 URL 的人都能驱动它。验不了就轮询。

没有稳定端点的团队。 webhook 需要一个可靠的落点。会休眠的笔记本不是,重试阶梯会很有礼貌地耗尽,而始终没有人在听。

用无副作用任务测试重复通知

向测试处理器发送两次相同签名的测试事件,第二次应确认收到,却不重复执行业务动作。再于回调后处理一次状态查询,确认不会产生第二次完成记录。Stripe Webhook 指南说明重复事件这一通用集成问题;KeepRouter 事件字段和签名仍以自己的 API 参考为准,不要把 Stripe 的 Header 名复制进这里。

上线前清单

  1. 验签:对原始请求体、常量时间比较、拒绝过旧的时间戳。
  2. 按 kr-event-id 去重,在你自己存储里加唯一约束。
  3. 快速返回 2xx,再异步处理。先干活再响应的接收端会在某天超过 5 秒并被重试。
  4. 盯 webhook.status:在你本来就会发的轮询里看,或对 failed 以及尝试次数超过两次的 pending 告警。
  5. 保留轮询实现,因为事件没送达时,它是取回视频的唯一路径。

异步视频流程 讲清了本文依赖的提交与轮询机制,视频生成参考 记录了每个字段,模型页 给出渲染完成后计费的每秒单价。若你的任务不是视频,同一套契约适用于任何接受 webhook_url 的路由,错误参考 里有 invalid_webhook_url 的说明。

常见问题

用了 webhook 还需要轮询吗?

需要,把轮询留作兜底。接收端故障期间事件可能丢失,而重试阶梯在约七个半小时、五次尝试后就会停止。轮询响应会报告投递状态,你可以据此发现失败并自行取回视频。

如何确认投递确实来自网关?

用提交时只返回一次的签名密钥,对「时间戳 + 点号 + 原始请求体」重算 HMAC-SHA256,再与 kr-signature 里的 v1 做常量时间比较。同时拒绝过旧的时间戳,避免捕获到的投递被重放。

webhook 投递会计费吗?

不会,投递不计费,轮询也不计费。只有任务本身计费,按公布的每秒单价乘以你请求的视频时长。

参考的一手资料

本文最近复核 2026-09-29

  1. [1] Stripe webhooks documentation (retries, idempotency and signature verification)
  2. [2] GitHub webhooks documentation (delivery ids, redelivery and validation)
  3. [3] OWASP Server-Side Request Forgery Prevention Cheat Sheet (destination validation)

继续阅读

← 全部文章 · 模型与价格 · 获取 API Key