每次请求可选择 Auto、TokenLab Verified 或 Official,并查看对应价格。 查看更新

异步 AI 任务 Webhooks:验证签名,然后读取任务

CryptoCrypto
·2026年9月28日·约 10 分钟阅读·更新 2026年9月28日·36 次浏览
#Webhooks#异步任务#API集成#安全性
异步 AI 任务 Webhooks:验证签名,然后读取任务

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"}'

同一个端点可以通过三种方式管理,且这三种方式编辑的是同一个对象:

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-ID
  • X-Webhook-Timestamp,Unix 时间戳(秒)
  • X-Webhook-Signature,格式为 sha256=

签名是使用完整的 whsec_… 密钥,对精确的时间戳字符串、一个句点以及原始请求体字节进行 HMAC-SHA256 计算得出的。顺序很重要,请求体本身也很重要。

以下两个错误最容易导致签名检查失败:

  1. 验证已解析的 JSON。如果您解析了请求体并对其重新序列化,字节会发生变化,导致 HMAC 不匹配。请读取原始请求体,并在验证通过前将其保持为字节格式。
  2. 轮换期间仅使用一个密钥进行验证。轮换后,传输中的投递可能仍带有之前的签名。请在短时间内接受多个密钥。

下面的 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),这也是去重必不可少的原因。

连续十次失败的周期会自动暂停端点。

当您的接收端宕机时,请按顺序执行以下操作:

  1. 修复接收端。确认它能读取原始字节并快速返回 2xx。
  2. 使用 PATCH {"is_active": true} 恢复端点。这会重置失败计数。
  3. 使用 POST …/test 发送测试。测试 API 返回 200 仅表示尝试已被记录。请检查投递历史并确认 outcome == "delivered"。
  4. 对账缺失部分。获取端点暂停期间存储的任务 ID,并为每个 ID 调用 GET /v1/tasks/{id}。
  5. 只有这样,才能再次信任 Webhook 流。

投递历史记录为您提供 outcome、http_status、attempts 和 delivered_at。它仅存储元数据,不存储负载。旧事件无法手动重放,因此第 4 步是必不可少的。您存储的任务 ID 是恢复路径。

在不丢失事件的情况下轮换密钥

轮换不可逆,因此请在开始前规划好窗口期。

  1. 调用 POST /v1/management/webhooks/{webhookId}/rotate-secret。响应仅返回一次新密钥。
  2. 将新密钥添加到接收端的验证列表中。同时保留旧密钥。
  3. 在删除任何内容之前部署接收端更改。列表必须同时包含两个密钥。
  4. 发送测试并确认历史记录中显示 outcome == "delivered"。
  5. 经过短暂窗口期后,移除旧密钥并重新部署。

传输中的投递可能仍带有之前的签名。如果您一步到位更换密钥,将会丢失这些事件。仅持有一个密钥的验证器可能会拒绝在轮换前刚刚签名的投递。

从 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,并在提交任务的工作区中开启您的第一个端点。

来源

最近发布的模型

试试本文提到的模型

聊天、出图或做视频,共用同一份 TokenLab 余额。