Un webhook es una notificación firmada de que una tarea ha alcanzado un estado terminal. No es el registro en sí. Por lo tanto, la regla es breve: verifique los bytes sin procesar, elimine duplicados mediante el ID del evento, responda 2xx rápidamente y luego lea GET /v1/tasks/{id} para obtener el resultado y el estado de facturación.
Los webhooks de tareas del espacio de trabajo se lanzaron el 27-09-2026. Obtiene una Management API para el ciclo de vida del webhook, entrega de prueba, rotación de secretos e historial de entregas. El Dashboard y la gestión mediante MCP también están disponibles.
Una corrección inicial. Nuestra guía de generación de imágenes asíncronas anterior indicaba que TokenLab no tenía callback de tareas; eso era cierto antes del 27-09-2026, y dicha guía ha sido actualizada junto con esta.
¿Webhooks o polling? Use ambos
Resuelven problemas diferentes y ninguno reemplaza al otro.
| Situación | Utilice |
|---|---|
| Desea reaccionar en el momento en que termina una tarea | Webhook |
| Necesita el resultado o costo oficial | GET /v1/tasks/{id} |
| Su receptor estuvo inactivo durante un tiempo | Polling con IDs de tarea almacenados |
| Desea una alternativa cuando las entregas desaparecen | Polling en un intervalo lento |
Los webhooks no eliminan las consultas de estado y no añaden un límite de polling. Mantenga ambos. Incluso con los webhooks activos, un bucle de reconciliación lento que lea sus IDs de tarea almacenados es un seguro económico.
Si utiliza polling, use poll_url, aplique backoff mientras la tarea esté pendiente y deténgase en estados terminales. Deténgase en 401, 403, 404 o cuando error.retryable == false. Reintente 503 async_task_owner_unavailable con backoff. Una tarea faltante o expirada devuelve 404 async_task_not_found. Consulte la guía de trabajos asíncronos y polling para conocer el contrato de polling.
Tres credenciales, tres trabajos
Mezclarlas es la forma más rápida de romper su receptor.
| Credencial | Prefijo | Qué hace | Notas |
|---|---|---|---|
| Management Token | mt-… |
Crea, lista, actualiza, elimina, prueba y rota webhooks en /v1/management/webhooks* |
Enviado como Authorization: Bearer mt-…. Alcance del espacio de trabajo |
| API key | sk-… |
Envía solicitudes de modelos y lee el estado de la tarea mediante GET /v1/tasks/{id} |
Rechazada por la Management API |
| Signing secret | whsec_… |
Verifica entregas en su receptor | Nunca es un Bearer token |
Dos cosas sobre el Management Token. Primero, también autoriza otras operaciones de gestión del espacio de trabajo, por lo que no es una credencial exclusiva para webhooks. Elija el mismo espacio de trabajo que la API key que envía sus tareas. Segundo, lo crea en Dashboard → API → Management Tokens. Vea otro ejemplo de la Management API.
Mantenga mt-… y whsec_… solo en su backend. Nunca los envíe a un navegador o cliente móvil.
Cree un endpoint y almacene el secreto inmediatamente
La llamada de creación devuelve 201 con el id del webhook y un secret de un solo uso que comienza con whsec_…. Listar, obtener y actualizar nunca vuelven a mostrar ese secreto. Almacénelo en el momento en que lo vea.
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"}'
Los mismos endpoints se pueden gestionar de tres formas, y los tres editan los mismos objetos:
- Dashboard → API → Webhooks
- La Management API
- MCP
Las reglas de URL son estrictas. El endpoint debe ser HTTPS público. Sin credenciales, query string o fragmentos en la URL. No se siguen redirecciones, por lo que un 301 cuenta como una entrega fallida.
Puede tener hasta 10 endpoints por espacio de trabajo. Un undécimo intento de creación devuelve 409 webhook_limit_reached.
| Método | Ruta | Propósito |
|---|---|---|
GET |
/v1/management/webhooks |
Listar endpoints |
POST |
/v1/management/webhooks |
Crear un endpoint |
GET |
/v1/management/webhooks/{webhookId} |
Leer un endpoint |
PATCH |
/v1/management/webhooks/{webhookId} |
Actualizar, pausar o reanudar |
DELETE |
/v1/management/webhooks/{webhookId} |
Eliminar |
POST |
/v1/management/webhooks/{webhookId}/rotate-secret |
Rotar el secreto de firma |
POST |
/v1/management/webhooks/{webhookId}/test |
Enviar un webhook.test |
GET |
/v1/management/webhooks/{webhookId}/deliveries?page=1&limit=50 |
Historial de entregas, límite hasta 100 |
Pause con PATCH {"is_active": false}. Reanude con PATCH {"is_active": true}. Reanudar restablece el contador de fallos consecutivos, lo cual es importante después de una interrupción.
Lo que realmente llega
Cada entrega es un POST con un sobre JSON. Los campos de la Management API están en snake_case, pero los campos de callback están en camelCase. No asuma que una convención de nombres se traslada a la otra.
| Campo | Significado |
|---|---|
id |
ID del evento. Úselo para eliminar duplicados |
type |
Tipo de evento |
created |
Segundos Unix |
data |
Carga útil del evento, su forma depende del evento |
| Evento | Se dispara cuando |
|---|---|
task.completed |
La tarea finalizó con éxito |
task.failed |
La tarea terminó en fallo |
task.timeout |
La tarea alcanzó su límite de tiempo |
webhook.test |
Enviado solo por la operación de prueba |
task.completed incluye taskType (por ejemplo, video o image), taskId, un model opcional, durationMs, resultUrls y settledCost.
task.failed incluye taskType, taskId, error, errorCode, retryable y refundOutcome.
task.timeout incluye taskType, taskId, refundOutcome y campos de tiempo de espera. Lea el registro de la tarea para obtener esos valores; el conjunto de campos depende de la tarea.
Las suscripciones cubren futuros eventos terminales para tareas asíncronas en el espacio de trabajo. Los resultados síncronos y las tareas históricas no se retransmiten. Usted recibe cada tarea del espacio de trabajo para los tipos de eventos que seleccionó, así que compare data.taskId con el ID que almacenó cuando creó la tarea.
Los campos pueden estar ausentes dependiendo de la tarea. Es por eso que GET /v1/tasks/{id} con la clave sk-… original del espacio de trabajo sigue siendo la fuente de verdad para el resultado y el estado de facturación. El evento le indica que algo terminó. El registro de la tarea le indica qué produjo y cuánto costó.
Una cosa más sobre retryable en un evento fallido. Describe el fallo de la generación, no una instrucción para reenviar automáticamente. Un nuevo envío es una nueva tarea facturable.
Verifique los bytes sin procesar, luego procese una vez
Cada POST lleva tres cabeceras:
X-Webhook-IDX-Webhook-Timestamp, segundos UnixX-Webhook-Signature, formateado comosha256=<hex>
La firma es HMAC-SHA256 sobre la cadena exacta de la marca de tiempo, un punto y los bytes sin procesar del cuerpo de la solicitud, cifrados con el secreto completo whsec_…. El orden importa, al igual que el cuerpo.
Dos errores rompen las comprobaciones de firma más que cualquier otra cosa:
- Verificar JSON analizado. Si analiza el cuerpo y lo vuelve a serializar, los bytes cambian y el HMAC no coincidirá. Lea el cuerpo sin procesar. Manténgalo como bytes hasta que la verificación sea exitosa.
- Verificar con un solo secreto durante la rotación. Después de rotar, las entregas ya en tránsito aún pueden llevar la firma anterior. Acepte una lista de secretos durante un breve periodo.
El receptor en Node a continuación no tiene dependencias y utiliza node:http. Lee el cuerpo sin procesar, verifica contra una lista de secretos, comprueba la ventana de 300 segundos, compara el id del cuerpo con X-Webhook-ID, elimina duplicados por ID de evento, encola y devuelve 204. La eliminación de duplicados en el ejemplo es un conjunto en memoria; utilice una restricción de base de datos única en producción.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
// Durante la rotación, liste tanto el nuevo como el anterior secreto whsec_.
const SECRETS = (process.env.TOKENLAB_WEBHOOK_SECRETS ?? '').split(',').filter(Boolean);
const TOLERANCE_SECONDS = 300;
const seen = new Set(); // Use una restricción de BD única en producción, no memoria.
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); // verifique los bytes exactos, antes de 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); // delegar; realice el trabajo lento fuera de la solicitud
}
res.writeHead(204).end();
});
});
function enqueue(event) {
console.log('queued', event.type, event.data?.taskId);
}
server.listen(Number(process.env.PORT ?? 3000));
El receptor fue probado localmente el 28-09-2026 contra solicitudes firmadas exactamente igual que el emisor de producción: entrega válida, entrega duplicada, secreto anterior durante la rotación, secreto incorrecto, marca de tiempo obsoleta, discrepancia entre cabecera e ID de cuerpo, cuerpo manipulado y JSON re-serializado. Ocho casos, todos aprobados. Un duplicado se encoló una vez.
La parte en Python es una función de verificación única. Compara firmas con hmac.compare_digest y espera los bytes del cuerpo sin procesar de request.get_data() de Flask o await request.body() de FastAPI.
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:
"""Comprobar un webhook de TokenLab contra uno o más secretos whsec_.
raw_body debe ser los bytes exactos de la solicitud (Flask: request.get_data(),
FastAPI/Starlette: await request.body()), leídos antes de cualquier análisis 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
Probado el 28-09-2026: válido, secreto anterior, secreto incorrecto, marca de tiempo obsoleta, cuerpo manipulado y un cuerpo re-serializado con el espaciado predeterminado de json.dumps. Seis casos, todos aprobados.
Más allá de la firma, haga tres cosas en cada solicitud:
- Rechace marcas de tiempo con más de 300 segundos de diferencia respecto al momento actual. Son 5 minutos, y limita la antigüedad de una repetición.
- Confirme que el
iddel cuerpo es igual aX-Webhook-ID. - Almacene el ID del evento junto con su elemento de trabajo en una escritura atómica, respaldada por una restricción única. Luego devuelva
2xxrápidamente y realice el trabajo pesado desde su propia cola.
Las entregas pueden repetirse y el orden no está garantizado. La ventana de tiempo limita la antigüedad de la repetición. La eliminación de duplicados por ID de evento evita el procesamiento doble.
Reintentos, pausa automática y manual de recuperación
Cada ciclo de entrega realiza hasta tres intentos.
| Intento | Espera antes | Tiempo de espera del intento |
|---|---|---|
| 1 | ninguna | 10 s |
| 2 | 1 s | 10 s |
| 3 | 4 s | 10 s |
Fuente: Guía de webhooks de TokenLab, observada el 28-09-2026.
Cada intento obtiene una marca de tiempo y una firma nuevas. Eso significa que su comprobación de firma debe usar la marca de tiempo de la misma solicitud, no un valor almacenado en caché.
Respuestas reintentables: fallos de red, 429 y 5xx. No se reintentan dentro del ciclo: otros 4xx, redirecciones y destinos de red no válidos. Los fallos transitorios pueden activar reintentos posteriores del mismo evento con el mismo ID de entrega, lo cual es otra razón por la que la eliminación de duplicados no es opcional.
Diez ciclos fallidos consecutivos pausan el endpoint automáticamente.
Cuando su receptor estuvo inactivo, siga estos pasos en orden:
- Repare el receptor. Confirme que lee bytes sin procesar y devuelve
2xxrápidamente. - Reanude el endpoint con
PATCH {"is_active": true}. Esto restablece el contador de fallos. - Envíe una prueba con
POST …/test. Un200de la API de prueba solo significa que el intento fue registrado. Compruebe el historial de entregas y confirmeoutcome == "delivered". - Reconcilie la brecha. Tome los IDs de tarea que almacenó mientras el endpoint estaba pausado y llame a
GET /v1/tasks/{id}para cada uno. - Solo entonces vuelva a confiar en el flujo de webhooks.
El historial de entregas le proporciona outcome, http_status, attempts y delivered_at. Solo almacena metadatos, no cargas útiles. Los eventos antiguos no se pueden retransmitir manualmente, por lo que el paso 4 no es opcional. Sus IDs de tarea almacenados son la ruta de recuperación.
Rotar un secreto sin perder eventos
La rotación no es reversible, así que planifique la ventana antes de comenzar.
- Llame a
POST /v1/management/webhooks/{webhookId}/rotate-secret. La respuesta devuelve el nuevo secreto una vez. - Añada el nuevo secreto a su lista de verificación en el receptor. Mantenga también el anterior en esa lista.
- Despliegue el cambio del receptor antes de descartar nada. La lista debe contener ambos secretos a la vez.
- Envíe una prueba y confirme
outcome == "delivered"en el historial. - Después de una breve ventana, elimine el secreto antiguo y vuelva a desplegar.
Las entregas en tránsito pueden seguir llevando la firma anterior. Si intercambia secretos en un solo paso, perderá esos eventos. Un verificador que solo tiene un secreto puede rechazar entregas que fueron firmadas justo antes de la rotación.
Gestionar webhooks desde MCP
Si controla TokenLab desde un agente, el servidor MCP expone el mismo ciclo de vida. Use @tokenlabai/mcp-server con el perfil full. Las herramientas son list_webhooks, create_webhook, get_webhook, update_webhook, delete_webhook, rotate_webhook_secret, test_webhook y list_webhook_deliveries.
El servidor lee el Management Token de TOKENLAB_MANAGEMENT_TOKEN. El último paquete publicado observado el 28-09-2026 es 0.6.24. MCP edita los mismos endpoints que ve en el Dashboard, por lo que no hay un estado separado que reconciliar.
Preguntas frecuentes
¿Las tareas de imagen envían webhooks?
Sí. Cada tarea asíncrona en el espacio de trabajo, incluidas las tareas de imagen, envía su evento terminal a los endpoints suscritos a ese tipo de evento. El campo taskType en la carga útil le indica qué tipo de tarea era, por ejemplo video o image. Los resultados síncronos no están cubiertos.
¿Qué sucede si mi endpoint está inactivo?
Cada ciclo reintenta hasta tres veces. Diez ciclos fallidos consecutivos pausan el endpoint automáticamente. Los fallos de entrega transitorios pueden reintentarse más tarde con el mismo ID de entrega. Una vez que el endpoint está pausado, los eventos ocurridos durante la pausa no se entregan más tarde y no se pueden retransmitir manualmente. Repare el receptor, reanude el endpoint, envíe una prueba y luego reconcilie las tareas que creó durante la brecha llamando a GET /v1/tasks/{id} con sus IDs de tarea almacenados.
¿Puedo retransmitir un evento antiguo?
No. El historial de entregas solo contiene metadatos, no cargas útiles, y no hay retransmisión manual. La ventana de tiempo también rechaza cualquier cosa con más de 300 segundos de antigüedad. La reconciliación a través de la API de tareas es la forma admitida de ponerse al día.
¿Es seguro reenviar automáticamente task.failed con retryable: true?
No. retryable describe el fallo de la generación. No es una instrucción para reenviar. Un nuevo envío es una nueva tarea facturable, así que decida el reintento usted mismo y tenga en cuenta el costo.
¿La API de compatibilidad de Seedance utiliza estos webhooks?
No. Su callback_url por solicitud es un contrato separado con su propia carga útil. No utiliza eventos del espacio de trabajo ni estas cabeceras HMAC, así que no apunte un mismo verificador a ambos.
Comience con el contrato completo en la guía de webhooks, luego cree una API key y active su primer endpoint en el espacio de trabajo que envía sus tareas.
Fuentes
- https://docs.tokenlab.sh/guides/webhooksObservado el 2026-09-28
- https://docs.tokenlab.sh/guides/async-jobs-pollingObservado el 2026-09-28
- https://www.npmjs.com/package/@tokenlabai/mcp-serverObservado el 2026-09-28



