Webhook 是一个已达到终止状态的任务的签名提示,它并非记录本身。因此,规则很简单:验证原始字节,通过事件 ID 进行去重,快速响应 2xx,然后通过 GET /v1/tasks/{id} 读取结果和计费状态。
工作区任务 Webhook 于 2026 年 9 月 27 日发布。您将获得用于 Webhook 生命周期管理、测试投递、密钥轮换和投递历史记录的 Management API。仪表板和 MCP 管理功能也已同步上线。
首先更正一点。我们之前的异步图像生成指南中提到 TokenLab 没有任务回调;这在 2026 年 9 月 27 日之前是事实,该指南已与本文档同步更新。
Webhook 还是轮询?两者都要用
它们解决的是不同的问题,且互不替代。
| 场景 | 推荐方案 |
|---|---|
| 您希望在任务结束时立即做出反应 | Webhook |
| 您需要权威的结果或成本数据 | GET /v1/tasks/{id} |
| 您的接收端离线了一段时间 | 使用存储的任务 ID 进行轮询 |
| 您希望在投递丢失时有兜底方案 | 以较慢的频率进行轮询 |
Webhook 并不能取代状态查询,也不会增加轮询限制。请两者并用。即使启用了 Webhook,通过读取您存储的任务 ID 进行缓慢的对账循环也是一种廉价的保险。
如果您进行轮询,请使用 poll_url,在任务处于 pending 状态时进行退避,并在达到终止状态时停止。在遇到 401、403、404 或 error.retryable == false 时停止。对于 503 async_task_owner_unavailable,请使用退避策略进行重试。缺失或过期的任务会返回 404 async_task_not_found。有关轮询契约,请参阅异步任务与轮询指南。
三种凭证,三种用途
混淆这些凭证是导致接收端故障的最快途径。
| 凭证 | 前缀 | 用途 | 备注 |
|---|---|---|---|
| Management Token | mt-… |
在 /v1/management/webhooks* 上创建、列出、更新、删除、测试和轮换 Webhook |
以 Authorization: Bearer mt-… 形式发送。工作区作用域 |
| API key | sk-… |
提交模型请求并通过 GET /v1/tasks/{id} 读取任务状态 |
会被 Management API 拒绝 |
| 签名密钥 | whsec_… |
验证接收端收到的投递 | 绝非 Bearer token |
关于 Management Token 有两点需要注意。首先,它还授权其他工作区管理操作,因此它不仅仅是 Webhook 专用凭证。请选择与提交任务的 API key 相同的工作区。其次,您可以在“仪表板 → API → Management Tokens”中创建它。请参阅另一个 Management API 示例。
请仅在后端保留 mt-… 和 whsec_…。切勿将其发送到浏览器或移动客户端。
创建端点并立即存储密钥
创建调用会返回 201,其中包含 Webhook id 和一个以 whsec_… 开头的一次性 secret。列表、获取和更新操作都不会再次显示该密钥。请在看到它的第一时间将其存储起来。
export TOKENLAB_MANAGEMENT_TOKEN="mt-your-management-token"
curl https://api.tokenlab.sh/v1/management/webhooks \
-H "Authorization: Bearer $TOKENLAB_MANAGEMENT_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"url":"https://your-app.example/webhooks/tokenlab","events":["task.completed","task.failed","task.timeout"],"description":"Production task results"}'
同一个端点可以通过三种方式管理,且这三种方式编辑的是同一个对象:
- 仪表板 → API → Webhooks
- Management API
- MCP
URL 规则非常严格。端点必须是公共 HTTPS。URL 中不能包含凭证、查询字符串或片段。系统不会跟随重定向,因此 301 会被计为投递失败。
每个工作区最多可以拥有 10 个端点。创建第 11 个会返回 409 webhook_limit_reached。
| 方法 | 路径 | 用途 |
|---|---|---|
GET |
/v1/management/webhooks |
列出端点 |
POST |
/v1/management/webhooks |
创建端点 |
GET |
/v1/management/webhooks/{webhookId} |
读取单个端点 |
PATCH |
/v1/management/webhooks/{webhookId} |
更新、暂停或恢复 |
DELETE |
/v1/management/webhooks/{webhookId} |
删除 |
POST |
/v1/management/webhooks/{webhookId}/rotate-secret |
轮换签名密钥 |
POST |
/v1/management/webhooks/{webhookId}/test |
发送 webhook.test |
GET |
/v1/management/webhooks/{webhookId}/deliveries?page=1&limit=50 |
投递历史,限制最多 100 条 |
使用 PATCH {"is_active": false} 暂停。使用 PATCH {"is_active": true} 恢复。恢复操作会重置连续失败计数,这在故障恢复后非常重要。
实际收到的内容
每次投递都是一个带有 JSON 信封的 POST 请求。Management API 字段采用 snake_case,但回调字段采用 camelCase。请勿假设一种命名规范适用于所有情况。
| 字段 | 含义 |
|---|---|
id |
事件 ID。用于去重 |
type |
事件类型 |
created |
Unix 时间戳(秒) |
data |
事件负载,形状取决于事件类型 |
| 事件 | 触发条件 |
|---|---|
task.completed |
任务成功完成 |
task.failed |
任务失败结束 |
task.timeout |
任务达到时间限制 |
webhook.test |
仅由测试操作发送 |
task.completed 包含 taskType(例如 video 或 image)、taskId、可选的 model、durationMs、resultUrls 和 settledCost。
task.failed 包含 taskType、taskId、error、errorCode、retryable 和 refundOutcome。
task.timeout 包含 taskType、taskId、refundOutcome 以及等待时间字段。请读取任务记录以获取这些值;字段集取决于任务类型。
订阅涵盖工作区中异步任务未来的终止事件。同步结果和历史任务不会重放。您将收到所选事件类型的所有工作区任务,因此请将 data.taskId 与您创建任务时存储的 ID 进行匹配。
根据任务的不同,某些字段可能会缺失。这就是为什么使用原始工作区的 sk-… 密钥调用 GET /v1/tasks/{id} 始终是获取结果和计费状态的唯一真理来源。事件通知您某事已完成,而任务记录告诉您它产生了什么以及成本是多少。
关于失败事件中的 retryable 还有一点需要说明。它描述的是生成失败的情况,而不是自动重新提交的指令。新的提交就是新的计费任务。
验证原始字节,然后处理一次
每个 POST 请求都带有三个头信息:
X-Webhook-IDX-Webhook-Timestamp,Unix 时间戳(秒)X-Webhook-Signature,格式为sha256=
签名是使用完整的 whsec_… 密钥,对精确的时间戳字符串、一个句点以及原始请求体字节进行 HMAC-SHA256 计算得出的。顺序很重要,请求体本身也很重要。
以下两个错误最容易导致签名检查失败:
- 验证已解析的 JSON。如果您解析了请求体并对其重新序列化,字节会发生变化,导致 HMAC 不匹配。请读取原始请求体,并在验证通过前将其保持为字节格式。
- 轮换期间仅使用一个密钥进行验证。轮换后,传输中的投递可能仍带有之前的签名。请在短时间内接受多个密钥。
下面的 Node 接收端不依赖任何外部库,使用了 node:http。它读取原始请求体,根据密钥列表进行验证,检查 300 秒的时间窗口,将请求体中的 id 与 X-Webhook-ID 进行比较,通过事件 ID 去重,加入队列,并返回 204。示例中的去重使用了内存 Set;在生产环境中请使用数据库的唯一约束。
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
// 轮换期间,列出新的和之前的 whsec_ 密钥。
const SECRETS = (process.env.TOKENLAB_WEBHOOK_SECRETS ?? '').split(',').filter(Boolean);
const TOLERANCE_SECONDS = 300;
const seen = new Set(); // 生产环境请使用数据库唯一约束,而非内存。
function verify(rawBody, headers) {
const timestamp = headers['x-webhook-timestamp'];
const signature = headers['x-webhook-signature'];
if (typeof timestamp !== 'string' || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
if (typeof signature !== 'string' || !/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
const received = Buffer.from(signature.slice(7), 'hex');
return SECRETS.some((secret) => {
const expected = createHmac('sha256', secret).update(timestamp + '.').update(rawBody).digest();
return timingSafeEqual(expected, received);
});
}
const server = createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/webhooks/tokenlab') {
res.writeHead(404).end();
return;
}
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks); // 在 JSON.parse 之前验证精确字节
if (!verify(rawBody, req.headers)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(rawBody.toString('utf8'));
if (event.id !== req.headers['x-webhook-id']) {
res.writeHead(400).end();
return;
}
if (!seen.has(event.id)) {
seen.add(event.id);
enqueue(event); // 移交处理;在请求之外执行耗时操作
}
res.writeHead(204).end();
});
});
function enqueue(event) {
console.log('queued', event.type, event.data?.taskId);
}
server.listen(Number(process.env.PORT ?? 3000));
该接收端于 2026 年 9 月 28 日在本地针对生产环境发送方的签名请求进行了测试:有效投递、重复投递、轮换期间的旧密钥、错误密钥、陈旧时间戳、头信息与请求体 ID 不匹配、篡改请求体以及重新序列化的 JSON。八种情况全部通过。重复项仅被加入队列一次。
Python 端是一个简单的验证函数。它使用 hmac.compare_digest 比较签名,并期望从 Flask 的 request.get_data() 或 FastAPI 的 await request.body() 获取原始请求体字节。
import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300
SIGNATURE_RE = re.compile(r"^sha256=[a-f0-9]{64}$")
def verify_webhook(raw_body: bytes, headers, secrets: list[str]) -> bool:
"""使用一个或多个 whsec_ 密钥验证 TokenLab Webhook。
raw_body 必须是精确的请求字节(Flask: request.get_data(),
FastAPI/Starlette: await request.body()),在任何 JSON 解析之前读取。
"""
timestamp = headers.get("x-webhook-timestamp", "")
signature = headers.get("x-webhook-signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
if not SIGNATURE_RE.match(signature):
return False
received = signature.removeprefix("sha256=")
for secret in secrets:
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, received):
return True
return False
于 2026 年 9 月 28 日测试:有效、旧密钥、错误密钥、陈旧时间戳、篡改请求体以及使用默认 json.dumps 空格重新序列化的请求体。六种情况全部通过。
除了签名外,每次请求还应执行以下三件事:
- 拒绝时间戳距离当前时间超过 300 秒的请求。这是 5 分钟,限制了重放攻击的窗口。
- 确认请求体中的
id等于X-Webhook-ID。 - 将事件 ID 与您的工作项一起进行原子写入,并由唯一约束支持。然后快速返回
2xx,并在您自己的队列中执行繁重的工作。
投递可能会重复,且不保证顺序。时间戳窗口限制了重放的有效期。事件 ID 去重防止了重复处理。
重试、自动暂停与恢复手册
每个投递周期最多进行三次尝试。
| 尝试 | 等待时间 | 尝试超时 |
|---|---|---|
| 1 | 无 | 10 秒 |
| 2 | 1 秒 | 10 秒 |
| 3 | 4 秒 | 10 秒 |
来源:TokenLab Webhook 指南,观察于 2026 年 9 月 28 日。
每次尝试都会获得新的时间戳和签名。这意味着您的签名检查必须使用来自同一请求的时间戳,而不是缓存的值。
可重试的响应:网络故障、429 和 5xx。周期内不重试的情况:其他 4xx、重定向和无效的网络目标。瞬时故障可能会触发后续对同一事件的重试(使用相同的投递 ID),这也是去重必不可少的原因。
连续十次失败的周期会自动暂停端点。
当您的接收端宕机时,请按顺序执行以下操作:
- 修复接收端。确认它能读取原始字节并快速返回
2xx。 - 使用
PATCH {"is_active": true}恢复端点。这会重置失败计数。 - 使用
POST …/test发送测试。测试 API 返回200仅表示尝试已被记录。请检查投递历史并确认outcome == "delivered"。 - 对账缺失部分。获取端点暂停期间存储的任务 ID,并为每个 ID 调用
GET /v1/tasks/{id}。 - 只有这样,才能再次信任 Webhook 流。
投递历史记录为您提供 outcome、http_status、attempts 和 delivered_at。它仅存储元数据,不存储负载。旧事件无法手动重放,因此第 4 步是必不可少的。您存储的任务 ID 是恢复路径。
在不丢失事件的情况下轮换密钥
轮换不可逆,因此请在开始前规划好窗口期。
- 调用
POST /v1/management/webhooks/{webhookId}/rotate-secret。响应仅返回一次新密钥。 - 将新密钥添加到接收端的验证列表中。同时保留旧密钥。
- 在删除任何内容之前部署接收端更改。列表必须同时包含两个密钥。
- 发送测试并确认历史记录中显示
outcome == "delivered"。 - 经过短暂窗口期后,移除旧密钥并重新部署。
传输中的投递可能仍带有之前的签名。如果您一步到位更换密钥,将会丢失这些事件。仅持有一个密钥的验证器可能会拒绝在轮换前刚刚签名的投递。
从 MCP 管理 Webhook
如果您通过 Agent 驱动 TokenLab,MCP 服务器提供了相同的生命周期管理。请使用带有 full 配置文件的 @tokenlabai/mcp-server。工具包括 list_webhooks、create_webhook、get_webhook、update_webhook、delete_webhook、rotate_webhook_secret、test_webhook 和 list_webhook_deliveries。
服务器从 TOKENLAB_MANAGEMENT_TOKEN 读取 Management Token。2026 年 9 月 28 日观察到的最新发布包版本为 0.6.24。MCP 编辑的是您在仪表板中看到的相同端点,因此没有单独的状态需要对账。
常见问题解答
图像任务会发送 Webhook 吗?
会。工作区中的每个异步任务(包括图像任务)都会将其终止事件发送到订阅了该事件类型的端点。负载上的 taskType 字段会告诉您它是哪种任务,例如 video 或 image。同步结果不包含在内。
如果我的端点宕机了会怎样?
每个周期最多重试三次。连续十次失败的周期会自动暂停端点。瞬时投递故障可能会在稍后使用相同的投递 ID 重试。一旦端点暂停,暂停期间的事件将不会在稍后投递,也无法手动重放。请修复接收端,恢复端点,发送测试,然后通过使用存储的任务 ID 调用 GET /v1/tasks/{id} 来对账期间创建的任务。
我可以重放旧事件吗?
不能。投递历史仅包含元数据,不包含负载,且没有手动重放功能。时间戳窗口也会拒绝任何超过 300 秒的请求。通过任务 API 进行对账是支持的补救方式。
task.failed 且 retryable: true 的事件可以自动重新提交吗?
不能。retryable 描述的是生成失败的情况,而不是重新提交的指令。新的提交就是新的计费任务,因此请自行决定是否重试并考虑成本。
Seedance 兼容性 API 使用这些 Webhook 吗?
不使用。其每个请求的 callback_url 是一个独立的契约,有自己的负载。它不使用工作区事件或这些 HMAC 头信息,因此请勿将同一个验证器指向两者。
请从 Webhook 指南中的完整契约开始,然后创建一个 API key,并在提交任务的工作区中开启您的第一个端点。
来源
- https://docs.tokenlab.sh/guides/webhooks资料更新于 2026-09-28
- https://docs.tokenlab.sh/guides/async-jobs-polling资料更新于 2026-09-28
- https://www.npmjs.com/package/@tokenlabai/mcp-server资料更新于 2026-09-28



