為每個請求選擇 Auto、TokenLab Verified 或 Official,並預先顯示價格。 查看最新動態

非同步 AI 任務 Webhooks:驗證簽章,然後讀取任務

CryptoCrypto
·2026年9月28日·約 10 分鐘閱讀·更新 2026年9月28日·37 次瀏覽
#Webhook#非同步任務#API 整合#安全性
非同步 AI 任務 Webhooks:驗證簽章,然後讀取任務

Webhook 是一個已簽章的提示,代表任務已達到終止狀態。它並非紀錄本身。因此規則很簡單:驗證原始位元組 (raw bytes)、透過事件 ID 進行去重、快速回覆 2xx,然後讀取 GET /v1/tasks/{id} 以取得結果與計費狀態。

工作區任務 Webhook 於 2026-09-27 發布。您將獲得用於 Webhook 生命週期管理、測試傳送、密鑰輪替與傳送歷史記錄的 Management API。儀表板與 MCP 管理功能也同步開放。

首先修正一個資訊。我們先前的非同步影像生成指南提到 TokenLab 沒有任務回調;這在 2026-09-27 之前是正確的,該指南已與本篇一同更新。

Webhook 還是輪詢 (Polling)?兩者並用

它們解決的是不同的問題,且兩者無法互相取代。

情況 建議方案
您希望在任務結束的當下立即反應 Webhook
您需要權威性的結果或成本資訊 GET /v1/tasks/{id}
您的接收端曾離線一段時間 使用儲存的任務 ID 進行輪詢
您希望在傳送遺失時有備援機制 以較慢的頻率進行輪詢

Webhook 並不會取代狀態查詢,也不會增加輪詢限制。請兩者並用。即使啟用了 Webhook,透過讀取您儲存的任務 ID 來執行緩慢的對帳迴圈,也是一種低成本的保險。

如果您使用輪詢,請使用 poll_url,在任務待處理時進行退避 (backoff),並在達到終止狀態時停止。遇到 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 拒絕
簽章密鑰 (Signing secret) whsec_… 驗證接收端收到的傳送內容 絕非 Bearer token

關於 Management Token 有兩點說明。首先,它也授權其他工作區管理操作,因此它不僅僅是 Webhook 專用憑證。請選擇與提交任務的 API key 相同的工作區。其次,您可以在「儀表板 → API → Management Tokens」中建立它。請參閱另一個 Management API 範例。

請僅在後端保留 mt-… 與 whsec_…。切勿將它們傳送到瀏覽器或行動裝置用戶端。

建立端點並立即儲存密鑰

建立呼叫會回傳 201,其中包含 Webhook id 與一次性的 secret(以 whsec_… 開頭)。列出、取得與更新操作將不會再次顯示該密鑰。請在看到它的當下立即儲存。

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-09-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-09-28 測試:有效、舊密鑰、錯誤密鑰、過期時間戳、竄改主體以及使用預設 json.dumps 間距重新序列化的主體。六種情況全部通過。

除了簽章之外,請在每個請求中執行以下三件事:

  • 拒絕時間戳距離現在超過 300 秒的請求。這代表 5 分鐘,限制了重播攻擊的時效。
  • 確認主體 id 等於 X-Webhook-ID。
  • 將事件 ID 與您的工作項目在一次原子寫入中儲存,並由唯一約束保護。然後快速回傳 2xx,並從您自己的佇列中執行繁重工作。

傳送可能會重複,且不保證順序。時間戳視窗限制了重播的時效。事件 ID 去重可防止重複處理。

重試、自動暫停與恢復手冊

每個傳送週期最多進行三次嘗試。

嘗試 等待時間 嘗試逾時
1 無 10 秒
2 1 秒 10 秒
3 4 秒 10 秒

來源:TokenLab Webhook 指南,觀察於 2026-09-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 伺服器會公開相同的生命週期。請使用 @tokenlabai/mcp-server 並搭配 full 設定檔。工具包括 list_webhooks、create_webhook、get_webhook、update_webhook、delete_webhook、rotate_webhook_secret、test_webhook 與 list_webhook_deliveries。

伺服器會從 TOKENLAB_MANAGEMENT_TOKEN 讀取 Management Token。觀察於 2026-09-28 的最新發布版本為 0.6.24。MCP 編輯的端點與您在儀表板中看到的一致,因此無需對帳不同的狀態。

常見問題

影像任務會傳送 Webhook 嗎?

是的。工作區中的每個非同步任務(包括影像任務)都會將其終止事件傳送到訂閱該事件類型的端點。負載上的 taskType 欄位會告訴您它是哪種任務,例如 video 或 image。同步結果不包含在內。

如果我的端點離線會怎樣?

每個週期最多重試三次。連續十次失敗週期會自動暫停端點。暫時性傳送失敗可能會在稍後以相同的傳送 ID 重試。一旦端點暫停,暫停期間的事件將不會在稍後傳送,也無法手動重播。請修復接收端、恢復端點、傳送測試,然後透過呼叫 GET /v1/tasks/{id} 並使用您儲存的任務 ID,來對帳您在離線期間建立的任務。

我可以重播舊事件嗎?

不行。傳送歷史記錄僅包含元資料而非負載,且沒有手動重播功能。時間戳視窗也會拒絕任何超過 300 秒的請求。透過任務 API 進行對帳是支援的補救方式。

task.failed 且 retryable: true 的事件可以自動重新提交嗎?

不行。retryable 描述的是生成失敗的情況,並非重新提交的指令。新的提交就是一個新的計費任務,因此請自行決定是否重試並考量成本。

Seedance 相容性 API 使用這些 Webhook 嗎?

不使用。其每個請求的 callback_url 是獨立的合約,具有自己的負載。它不使用工作區事件或這些 HMAC 標頭,因此請勿將同一個驗證器指向兩者。

請從 Webhook 指南中的完整合約開始,然後建立 API key,並在提交任務的工作區中啟用您的第一個端點。

來源

最近發布的模型

用本文涉及的模型開始構建

比較價格、測試路由,把文章研究直接變成可執行的 API 呼叫。