Choose Auto, TokenLab Verified, or Official for each request, with prices shown up front. See what's new

Async AI Task Webhooks: Verify the Signature, Then Read the Task

CryptoCrypto
·September 28, 2026·11 min read·Updated September 28, 2026·35 views
#webhooks#async-tasks#api-integration#security
Async AI Task Webhooks: Verify the Signature, Then Read the Task

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:

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-ID
  • X-Webhook-Timestamp, Unix seconds
  • X-Webhook-Signature, formatted as sha256=<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:

  1. 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.
  2. 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 id equals X-Webhook-ID.
  • Store the event ID together with your work item in one atomic write, backed by a unique constraint. Then return 2xx quickly 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:

  1. Fix the receiver. Confirm it reads raw bytes and returns 2xx fast.
  2. Resume the endpoint with PATCH {"is_active": true}. This resets the failure count.
  3. Send a test with POST …/test. A 200 from the test API only means the attempt was recorded. Check the delivery history and confirm outcome == "delivered".
  4. Reconcile the gap. Take the task IDs you stored while the endpoint was paused and call GET /v1/tasks/{id} for each one.
  5. 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.

  1. Call POST /v1/management/webhooks/{webhookId}/rotate-secret. The response returns the new secret once.
  2. Add the new secret to your verification list on the receiver. Keep the old one in that list too.
  3. Deploy the receiver change before you drop anything. The list must hold both secrets at once.
  4. Send a test and confirm outcome == "delivered" in the history.
  5. 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

Recent model releases

Try the models from this article

Chat, create images, or make video with the same TokenLab balance.