长任务完成回调:签名、重试,以及为什么它不是唯一路径
回调让绝大多数情况下不必轮询,但并不意味着不需要轮询。本文给出契约、重试阶梯、验签方法,以及避免回调变成 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 名复制进这里。
上线前清单
- 验签:对原始请求体、常量时间比较、拒绝过旧的时间戳。
- 按
kr-event-id去重,在你自己存储里加唯一约束。 - 快速返回 2xx,再异步处理。先干活再响应的接收端会在某天超过 5 秒并被重试。
- 盯
webhook.status:在你本来就会发的轮询里看,或对failed以及尝试次数超过两次的pending告警。 - 保留轮询实现,因为事件没送达时,它是取回视频的唯一路径。
异步视频流程 讲清了本文依赖的提交与轮询机制,视频生成参考 记录了每个字段,模型页 给出渲染完成后计费的每秒单价。若你的任务不是视频,同一套契约适用于任何接受 webhook_url 的路由,错误参考 里有 invalid_webhook_url 的说明。
常见问题
用了 webhook 还需要轮询吗?
需要,把轮询留作兜底。接收端故障期间事件可能丢失,而重试阶梯在约七个半小时、五次尝试后就会停止。轮询响应会报告投递状态,你可以据此发现失败并自行取回视频。
如何确认投递确实来自网关?
用提交时只返回一次的签名密钥,对「时间戳 + 点号 + 原始请求体」重算 HMAC-SHA256,再与 kr-signature 里的 v1 做常量时间比较。同时拒绝过旧的时间戳,避免捕获到的投递被重放。
webhook 投递会计费吗?
不会,投递不计费,轮询也不计费。只有任务本身计费,按公布的每秒单价乘以你请求的视频时长。
参考的一手资料
本文最近复核 2026-09-29
- [1] Stripe webhooks documentation (retries, idempotency and signature verification)
- [2] GitHub webhooks documentation (delivery ids, redelivery and validation)
- [3] OWASP Server-Side Request Forgery Prevention Cheat Sheet (destination validation)