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"}'
相同的端點可以透過三種方式管理,且三者皆編輯相同的物件:
- 儀表板 → 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-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)的重試,這也是為什麼去重並非選項的原因。
連續十次失敗週期會自動暫停該端點。
當您的接收端離線時,請按順序執行以下操作:
- 修復接收端。確認它能讀取原始位元組並快速回傳
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 伺服器會公開相同的生命週期。請使用 @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,並在提交任務的工作區中啟用您的第一個端點。
來源
- 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



