A webhook is a signed hint that a task reached a terminal state. It is not the record itself. So the rule is short: verify the raw bytes, deduplicate by event ID, answer 2xx fast, then read GET /v1/tasks/{id} for the result and billing status.
Workspace task webhooks shipped on 2026-09-27. You get a Management API for the webhook lifecycle, test delivery, secret rotation and delivery history. Dashboard and MCP management are available too.
One correction up front. Our earlier async image generation guide said TokenLab had no task callback; that was true before 2026-09-27, and that guide has been updated together with this one.
Webhooks or polling? Use both
They solve different problems, and neither replaces the other.
| Situation | Reach for |
|---|---|
| You want to react the moment a task ends | Webhook |
| You need the authoritative result or cost | GET /v1/tasks/{id} |
| Your receiver was down for a while | Polling with stored task IDs |
| You want a fallback when deliveries vanish | Polling on a slow interval |
Webhooks do not remove status queries and they do not add a polling limit. Keep both. Even with webhooks on, a slow reconciliation loop that reads your stored task IDs is cheap insurance.
If you poll, use poll_url, back off while the task is pending, and stop at terminal states. Stop on 401, 403, 404, or when error.retryable == false. Retry 503 async_task_owner_unavailable with backoff. A missing or expired task returns 404 async_task_not_found. See the Async jobs and polling guide for the polling contract.
Three credentials, three jobs
Mixing these up is the fastest way to a broken receiver.
| Credential | Prefix | What it does | Notes |
|---|---|---|---|
| Management Token | mt-… |
Creates, lists, updates, deletes, tests and rotates webhooks on /v1/management/webhooks* |
Sent as Authorization: Bearer mt-…. Workspace scoped |
| API key | sk-… |
Submits model requests and reads task status via GET /v1/tasks/{id} |
Rejected by the Management API |
| Signing secret | whsec_… |
Verifies deliveries on your receiver | Never a Bearer token |
Two things about the Management Token. First, it also authorizes other workspace management operations, so it is not a webhook-only credential. Pick the same workspace as the API key that submits your tasks. Second, you create it at Dashboard → API → Management Tokens. See another Management API example.
Keep mt-… and whsec_… on your backend only. Never ship either to a browser or a mobile client.
Create an endpoint and store the secret immediately
The create call returns 201 with the webhook id and a one-time secret starting with whsec_…. List, get and update never show that secret again. Store it the moment you see it.
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"}'
The same endpoints can be managed three ways, and all three edit the same objects:
- Dashboard → API → Webhooks
- The Management API
- MCP
URL rules are strict. The endpoint must be public HTTPS. No credentials, query string or fragment in the URL. Redirects are not followed, so a 301 counts as a failed delivery.
You can have up to 10 endpoints per workspace. An 11th create returns 409 webhook_limit_reached.
| Method | Path | Purpose |
|---|---|---|
GET |
/v1/management/webhooks |
List endpoints |
POST |
/v1/management/webhooks |
Create an endpoint |
GET |
/v1/management/webhooks/{webhookId} |
Read one endpoint |
PATCH |
/v1/management/webhooks/{webhookId} |
Update, pause or resume |
DELETE |
/v1/management/webhooks/{webhookId} |
Delete |
POST |
/v1/management/webhooks/{webhookId}/rotate-secret |
Rotate the signing secret |
POST |
/v1/management/webhooks/{webhookId}/test |
Send a webhook.test |
GET |
/v1/management/webhooks/{webhookId}/deliveries?page=1&limit=50 |
Delivery history, limit up to 100 |
Pause with PATCH {"is_active": false}. Resume with PATCH {"is_active": true}. Resuming resets the consecutive failure count, which matters after an outage.
What actually arrives
Every delivery is a POST with a JSON envelope. Management API fields are snake_case, but callback fields are camelCase. Do not assume one casing carries over to the other.
| Field | Meaning |
|---|---|
id |
Event ID. Use it to deduplicate |
type |
Event type |
created |
Unix seconds |
data |
Event payload, shape depends on the event |
| Event | Fires when |
|---|---|
task.completed |
Task finished successfully |
task.failed |
Task ended in failure |
task.timeout |
Task hit its time limit |
webhook.test |
Sent only by the test operation |
task.completed carries taskType (for example video or image), taskId, an optional model, durationMs, resultUrls and settledCost.
task.failed carries taskType, taskId, error, errorCode, retryable and refundOutcome.
task.timeout carries taskType, taskId, refundOutcome and wait-time fields. Read the task record for those values; the field set depends on the task.
Subscriptions cover future terminal events for async tasks in the workspace. Synchronous results and historical tasks are not replayed. You receive every workspace task for the event types you selected, so match data.taskId against the ID you stored when you created the task.
Fields can be absent depending on the task. That is why GET /v1/tasks/{id} with the original workspace's sk-… key stays the source of truth for the result and the billing status. The event tells you something finished. The task record tells you what it produced and what it cost.
One more thing about retryable on a failed event. It describes the generation failure, not an instruction to resubmit automatically. A new submission is a new billable task.
Verify the raw bytes, then process once
Every POST carries three headers:
X-Webhook-IDX-Webhook-Timestamp, Unix secondsX-Webhook-Signature, formatted assha256=<hex>
The signature is HMAC-SHA256 over the exact timestamp string, a period, and the raw request body bytes, keyed with the full whsec_… secret. Order matters, and so does the body.
Two mistakes break signature checks more than anything else:
- Verifying parsed JSON. If you parse the body and re-serialize it, the bytes change and the HMAC will not match. Read the raw body. Keep it as bytes until verification passes.
- Verifying with only one secret during rotation. After you rotate, deliveries already in flight may still carry the previous signature. Accept a list of secrets for a short window.
The Node receiver below is dependency-free and uses node:http. It reads the raw body, verifies against a list of secrets, checks the 300-second window, compares the body id with X-Webhook-ID, deduplicates by event ID, enqueues, and returns 204. The dedupe in the sample is an in-memory set; use a unique database constraint in production.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
// During rotation, list both the new and the previous whsec_ secret.
const SECRETS = (process.env.TOKENLAB_WEBHOOK_SECRETS ?? '').split(',').filter(Boolean);
const TOLERANCE_SECONDS = 300;
const seen = new Set(); // Use a unique DB constraint in production, not memory.
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); // verify the exact bytes, before 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); // hand off; do slow work outside the request
}
res.writeHead(204).end();
});
});
function enqueue(event) {
console.log('queued', event.type, event.data?.taskId);
}
server.listen(Number(process.env.PORT ?? 3000));
The receiver was tested locally on 2026-09-28 against requests signed exactly like the production sender: valid delivery, duplicate delivery, previous secret during rotation, wrong secret, stale timestamp, header and body ID mismatch, tampered body, and re-serialized JSON. Eight cases, all pass. A duplicate was queued once.
The Python side is a single verification function. It compares signatures with hmac.compare_digest and expects the raw body bytes from Flask's request.get_data() or FastAPI's 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:
"""Check a TokenLab webhook against one or more whsec_ secrets.
raw_body must be the exact request bytes (Flask: request.get_data(),
FastAPI/Starlette: await request.body()), read before any JSON parsing.
"""
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
Tested on 2026-09-28: valid, previous secret, wrong secret, stale timestamp, tampered body, and a body re-serialized with default json.dumps spacing. Six cases, all pass.
Beyond the signature, do three things on every request:
- Reject timestamps more than 300 seconds from now. That is 5 minutes, and it bounds how old a replay can be.
- Confirm the body
idequalsX-Webhook-ID. - Store the event ID together with your work item in one atomic write, backed by a unique constraint. Then return
2xxquickly and do the heavy work from your own queue.
Deliveries can repeat and ordering is not guaranteed. The timestamp window limits replay age. Event-ID deduplication prevents double processing.
Retries, auto-pause, and the recovery runbook
Each delivery cycle makes up to three attempts.
| Attempt | Wait before it | Attempt timeout |
|---|---|---|
| 1 | none | 10 s |
| 2 | 1 s | 10 s |
| 3 | 4 s | 10 s |
Source: TokenLab webhook guide, observed 2026-09-28.
Every attempt gets a fresh timestamp and signature. That means your signature check must use the timestamp from the same request, not a cached value.
Retryable responses: network failures, 429, and 5xx. Not retried within the cycle: other 4xx, redirects, and invalid network targets. Transient failures can trigger later retries of the same event with the same delivery ID, which is another reason dedupe is not optional.
Ten consecutive failed cycles pause the endpoint automatically.
When your receiver was down, work through this in order:
- Fix the receiver. Confirm it reads raw bytes and returns
2xxfast. - Resume the endpoint with
PATCH {"is_active": true}. This resets the failure count. - Send a test with
POST …/test. A200from the test API only means the attempt was recorded. Check the delivery history and confirmoutcome == "delivered". - Reconcile the gap. Take the task IDs you stored while the endpoint was paused and call
GET /v1/tasks/{id}for each one. - Only then trust the webhook stream again.
The delivery history gives you outcome, http_status, attempts and delivered_at. It stores metadata only, no payloads. Old events cannot be replayed manually, so step 4 is not optional. Your stored task IDs are the recovery path.
Rotate a secret without dropping events
Rotation is not reversible, so plan the window before you start.
- Call
POST /v1/management/webhooks/{webhookId}/rotate-secret. The response returns the new secret once. - Add the new secret to your verification list on the receiver. Keep the old one in that list too.
- Deploy the receiver change before you drop anything. The list must hold both secrets at once.
- Send a test and confirm
outcome == "delivered"in the history. - After a short window, remove the old secret and redeploy.
In-flight deliveries may still carry the previous signature. If you swap secrets in one step, you drop those events. A verifier that holds only one secret can reject deliveries that were signed just before the rotation.
Manage webhooks from MCP
If you drive TokenLab from an agent, the MCP server exposes the same lifecycle. Use @tokenlabai/mcp-server with the full profile. The tools are list_webhooks, create_webhook, get_webhook, update_webhook, delete_webhook, rotate_webhook_secret, test_webhook and list_webhook_deliveries.
The server reads the Management Token from TOKENLAB_MANAGEMENT_TOKEN. The latest published package observed on 2026-09-28 is 0.6.24. MCP edits the same endpoints you see in the Dashboard, so there is no separate state to reconcile.
FAQ
Do image tasks send webhooks?
Yes. Every async task in the workspace, including image tasks, sends its terminal event to the endpoints subscribed to that event type. The taskType field on the payload tells you which kind of task it was, for example video or image. Synchronous results are not covered.
What happens if my endpoint is down?
Each cycle retries up to three times. Ten consecutive failed cycles pause the endpoint automatically. Transient delivery failures can be retried later with the same delivery ID. Once the endpoint is paused, events from the pause are not delivered later and cannot be replayed manually. Fix the receiver, resume the endpoint, send a test, then reconcile the tasks you created during the gap by calling GET /v1/tasks/{id} with your stored task IDs.
Can I replay an old event?
No. The delivery history holds metadata only, not payloads, and there is no manual replay. The timestamp window also rejects anything older than 300 seconds. Reconciliation through the task API is the supported way to catch up.
Is task.failed with retryable: true safe to resubmit automatically?
No. retryable describes the generation failure. It is not an instruction to resubmit. A new submission is a new billable task, so decide on the retry yourself and account for the cost.
Does the Seedance compatibility API use these webhooks?
No. Its per-request callback_url is a separate contract with its own payload. It does not use workspace events or these HMAC headers, so do not point one verifier at both.
Start with the full contract in the webhook guide, then create an API key and turn on your first endpoint in the workspace that submits your tasks.
Sources
- https://docs.tokenlab.sh/guides/webhooksSources checked 2026-09-28
- https://docs.tokenlab.sh/guides/async-jobs-pollingSources checked 2026-09-28
- https://www.npmjs.com/package/@tokenlabai/mcp-serverSources checked 2026-09-28



