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

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

_发布 2026-09-13 · 更新 2026-09-29 · [KeepRouter Editorial](https://keeprouter.com/editorial-policy#editorial-team) · 8 分钟阅读_

![推送通知示意图：提交块、指向接收块的箭头、带签名线的确认节点，以及五级递减的重试阶梯](https://keeprouter.com/editorial/blog/long-running-job-webhooks.png)

_签名投递只通知一次结果；重试阶梯决定了接收端可以坏多久。_

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

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

## 长任务为什么需要推送

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

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

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

## 投递契约

照常提交，额外带上 `webhook_url`：

```bash
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 的事件体包含与轮询完全一致的字段：

```json
{
  "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 示例：

```js
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` 去重，然后就不必再操心：

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

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

## 轮询要一直留着

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

```json
{
  "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 指南](https://docs.stripe.com/webhooks)说明重复事件这一通用集成问题；KeepRouter 事件字段和签名仍以自己的 [API 参考](/api/docs)为准，不要把 Stripe 的 Header 名复制进这里。

## 上线前清单

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

[异步视频流程](/zh/blog/async-video-generation-api) 讲清了本文依赖的提交与轮询机制，[视频生成参考](/api/docs) 记录了每个字段，[模型页](/models/veo-3.1-fast) 给出渲染完成后计费的每秒单价。若你的任务不是视频，同一套契约适用于任何接受 `webhook_url` 的路由，[错误参考](/docs/errors) 里有 `invalid_webhook_url` 的说明。

## 常见问题

### 用了 webhook 还需要轮询吗？

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

### 如何确认投递确实来自网关？

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

### webhook 投递会计费吗？

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

## 参考的一手资料

_本文最近复核 2026-09-29_

1. [Stripe webhooks documentation (retries, idempotency and signature verification)](https://docs.stripe.com/webhooks)
2. [GitHub webhooks documentation (delivery ids, redelivery and validation)](https://docs.github.com/en/webhooks)
3. [OWASP Server-Side Request Forgery Prevention Cheat Sheet (destination validation)](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)

## 继续阅读

- [异步视频生成 API：任务 ID、轮询与按秒计费](https://keeprouter.com/zh/blog/async-video-generation-api.md)
- [图生视频与视频编辑 API：media 输入契约](https://keeprouter.com/zh/blog/image-to-video-api-guide.md)
- [多模态模型 API](https://keeprouter.com/zh/features/multimodal-models.md)
- [errors](https://keeprouter.com/docs/errors.md)
- [veo 3.1 fast](https://keeprouter.com/models/veo-3.1-fast.md)

## 检查应用需要的具体行为

把文中例子用于应用时，按产品记录的请求格式与控制范围实现。

[查看产品说明](https://keeprouter.com/api/docs)

[创建 Key，测试免费模型](https://keeprouter.com/login?returnTo=%2Fconsole%2Fkeys%3Fmodel%3Dfree)

免费测试使用 free 模型；其他付费型号需要足够预付额度。

[全部文章](https://keeprouter.com/zh/blog.md) · [模型与价格](https://keeprouter.com/models.md)
