Un webhook est une notification signée indiquant qu'une tâche a atteint un état final. Il ne s'agit pas de l'enregistrement lui-même. La règle est donc simple : vérifiez les octets bruts, dédupliquez par ID d'événement, répondez rapidement par un code 2xx, puis lisez GET /v1/tasks/{id} pour obtenir le résultat et le statut de facturation.
Les webhooks de tâches d'espace de travail (Workspace) ont été lancés le 27/09/2026. Vous bénéficiez d'une API de gestion (Management API) pour le cycle de vie des webhooks, les tests de livraison, la rotation des secrets et l'historique des livraisons. La gestion via le tableau de bord et MCP est également disponible.
Une correction préalable : notre guide sur les webhooks de génération d'images asynchrones indiquait que TokenLab ne disposait pas de rappel de tâche ; c'était vrai avant le 27/09/2026, et ce guide a été mis à jour en même temps que celui-ci.
Webhooks ou polling ? Utilisez les deux
Ils résolvent des problèmes différents et ne se remplacent pas l'un l'autre.
| Situation | Privilégiez |
|---|---|
| Vous voulez réagir dès qu'une tâche se termine | Webhook |
| Vous avez besoin du résultat faisant autorité ou du coût | GET /v1/tasks/{id} |
| Votre récepteur a été indisponible pendant un moment | Polling avec des ID de tâches stockés |
| Vous voulez une solution de secours en cas de disparition des livraisons | Polling à intervalle lent |
Les webhooks ne suppriment pas les requêtes de statut et n'ajoutent pas de limite de polling. Gardez les deux. Même avec les webhooks activés, une boucle de réconciliation lente qui lit vos ID de tâches stockés constitue une assurance peu coûteuse.
Si vous utilisez le polling, utilisez poll_url, attendez (back off) pendant que la tâche est en attente, et arrêtez-vous aux états terminaux. Arrêtez-vous sur 401, 403, 404, ou lorsque error.retryable == false. Réessayez sur 503 async_task_owner_unavailable avec un backoff. Une tâche manquante ou expirée renvoie 404 async_task_not_found. Consultez le guide sur les tâches asynchrones et le polling pour connaître le contrat de polling.
Trois identifiants, trois rôles
Les mélanger est le moyen le plus rapide de casser votre récepteur.
| Identifiant | Préfixe | Fonction | Notes |
|---|---|---|---|
| Management Token | mt-… |
Crée, liste, met à jour, supprime, teste et fait tourner les webhooks sur /v1/management/webhooks* |
Envoyé en tant que Authorization: Bearer mt-…. Portée Workspace |
| Clé API | sk-… |
Soumet des requêtes de modèles et lit le statut des tâches via GET /v1/tasks/{id} |
Rejeté par la Management API |
| Secret de signature | whsec_… |
Vérifie les livraisons sur votre récepteur | Jamais un jeton Bearer |
Deux points concernant le Management Token. Premièrement, il autorise également d'autres opérations de gestion de l'espace de travail, ce n'est donc pas un identifiant réservé aux webhooks. Choisissez le même espace de travail que la clé API qui soumet vos tâches. Deuxièmement, vous le créez dans Tableau de bord → API → Management Tokens. Voir un autre exemple de Management API.
Gardez mt-… et whsec_… uniquement sur votre backend. Ne les envoyez jamais à un navigateur ou à un client mobile.
Créez un endpoint et stockez le secret immédiatement
L'appel de création renvoie 201 avec l'id du webhook et un secret à usage unique commençant par whsec_…. Les fonctions de liste, récupération et mise à jour ne montreront plus jamais ce secret. Stockez-le dès que vous le voyez.
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"}'
Les mêmes endpoints peuvent être gérés de trois manières, et toutes modifient les mêmes objets :
- Tableau de bord → API → Webhooks
- La Management API
- MCP
Les règles d'URL sont strictes. L'endpoint doit être en HTTPS public. Pas d'identifiants, de chaîne de requête ou de fragment dans l'URL. Les redirections ne sont pas suivies, donc un 301 compte comme une livraison échouée.
Vous pouvez avoir jusqu'à 10 endpoints par espace de travail. Une 11ème création renvoie 409 webhook_limit_reached.
| Méthode | Chemin | Objectif |
|---|---|---|
GET |
/v1/management/webhooks |
Lister les endpoints |
POST |
/v1/management/webhooks |
Créer un endpoint |
GET |
/v1/management/webhooks/{webhookId} |
Lire un endpoint |
PATCH |
/v1/management/webhooks/{webhookId} |
Mettre à jour, suspendre ou reprendre |
DELETE |
/v1/management/webhooks/{webhookId} |
Supprimer |
POST |
/v1/management/webhooks/{webhookId}/rotate-secret |
Faire tourner le secret de signature |
POST |
/v1/management/webhooks/{webhookId}/test |
Envoyer un webhook.test |
GET |
/v1/management/webhooks/{webhookId}/deliveries?page=1&limit=50 |
Historique des livraisons, limite jusqu'à 100 |
Suspendez avec PATCH {"is_active": false}. Reprenez avec PATCH {"is_active": true}. La reprise réinitialise le compteur d'échecs consécutifs, ce qui est important après une panne.
Ce qui arrive réellement
Chaque livraison est un POST avec une enveloppe JSON. Les champs de la Management API sont en snake_case, mais les champs de rappel sont en camelCase. Ne supposez pas qu'une casse s'applique à l'autre.
| Champ | Signification |
|---|---|
id |
ID de l'événement. Utilisez-le pour dédupliquer |
type |
Type d'événement |
created |
Secondes Unix |
data |
Charge utile de l'événement, la forme dépend de l'événement |
| Événement | Déclenché quand |
|---|---|
task.completed |
La tâche s'est terminée avec succès |
task.failed |
La tâche s'est terminée par un échec |
task.timeout |
La tâche a atteint sa limite de temps |
webhook.test |
Envoyé uniquement par l'opération de test |
task.completed contient taskType (par exemple video ou image), taskId, un model optionnel, durationMs, resultUrls et settledCost.
task.failed contient taskType, taskId, error, errorCode, retryable et refundOutcome.
task.timeout contient taskType, taskId, refundOutcome et les champs de temps d'attente. Lisez l'enregistrement de la tâche pour ces valeurs ; l'ensemble des champs dépend de la tâche.
Les abonnements couvrent les futurs événements terminaux pour les tâches asynchrones dans l'espace de travail. Les résultats synchrones et les tâches historiques ne sont pas rejoués. Vous recevez chaque tâche de l'espace de travail pour les types d'événements que vous avez sélectionnés, alors faites correspondre data.taskId avec l'ID que vous avez stocké lors de la création de la tâche.
Les champs peuvent être absents selon la tâche. C'est pourquoi GET /v1/tasks/{id} avec la clé sk-… de l'espace de travail d'origine reste la source de vérité pour le résultat et le statut de facturation. L'événement vous indique que quelque chose est terminé. L'enregistrement de la tâche vous indique ce qu'il a produit et ce qu'il a coûté.
Une chose de plus concernant retryable sur un événement d'échec. Il décrit l'échec de la génération, pas une instruction de resoumettre automatiquement. Une nouvelle soumission est une nouvelle tâche facturable.
Vérifiez les octets bruts, puis traitez une fois
Chaque POST comporte trois en-têtes :
X-Webhook-IDX-Webhook-Timestamp, secondes UnixX-Webhook-Signature, formaté commesha256=
La signature est un HMAC-SHA256 sur la chaîne de temps exacte, un point, et les octets bruts du corps de la requête, avec le secret complet whsec_…. L'ordre compte, tout comme le corps.
Deux erreurs cassent les vérifications de signature plus que toute autre chose :
- Vérifier le JSON analysé. Si vous analysez le corps et le resérialisez, les octets changent et le HMAC ne correspondra pas. Lisez le corps brut. Gardez-le sous forme d'octets jusqu'à ce que la vérification réussisse.
- Vérifier avec un seul secret pendant la rotation. Après avoir fait tourner les secrets, les livraisons déjà en cours peuvent encore porter l'ancienne signature. Acceptez une liste de secrets pendant une courte fenêtre.
Le récepteur Node ci-dessous est sans dépendance et utilise node:http. Il lit le corps brut, vérifie par rapport à une liste de secrets, vérifie la fenêtre de 300 secondes, compare l'id du corps avec X-Webhook-ID, déduplique par ID d'événement, met en file d'attente et renvoie 204. La déduplication dans l'exemple est un ensemble en mémoire ; utilisez une contrainte de base de données unique en production.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
// Pendant la rotation, listez à la fois le nouveau et l'ancien secret whsec_.
const SECRETS = (process.env.TOKENLAB_WEBHOOK_SECRETS ?? '').split(',').filter(Boolean);
const TOLERANCE_SECONDS = 300;
const seen = new Set(); // Utilisez une contrainte DB unique en production, pas la mémoire.
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); // vérifiez les octets exacts, avant 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); // déléguez ; faites le travail lent en dehors de la requête
}
res.writeHead(204).end();
});
});
function enqueue(event) {
console.log('queued', event.type, event.data?.taskId);
}
server.listen(Number(process.env.PORT ?? 3000));
Le récepteur a été testé localement le 28/09/2026 contre des requêtes signées exactement comme l'expéditeur de production : livraison valide, livraison en double, ancien secret pendant la rotation, mauvais secret, horodatage périmé, non-concordance entre l'en-tête et l'ID du corps, corps altéré et JSON resérialisé. Huit cas, tous réussis. Un doublon a été mis en file d'attente une seule fois.
Le côté Python est une fonction de vérification unique. Elle compare les signatures avec hmac.compare_digest et attend les octets bruts du corps via request.get_data() de Flask ou 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:
"""Vérifie un webhook TokenLab avec un ou plusieurs secrets whsec_.
raw_body doit être les octets exacts de la requête (Flask: request.get_data(),
FastAPI/Starlette: await request.body()), lus avant toute analyse 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
Testé le 28/09/2026 : valide, ancien secret, mauvais secret, horodatage périmé, corps altéré, et un corps resérialisé avec l'espacement par défaut de json.dumps. Six cas, tous réussis.
Au-delà de la signature, faites trois choses à chaque requête :
- Rejetez les horodatages de plus de 300 secondes par rapport à maintenant. Cela représente 5 minutes, et limite l'âge d'une relecture (replay).
- Confirmez que l'
iddu corps est égal àX-Webhook-ID. - Stockez l'ID de l'événement avec votre élément de travail dans une écriture atomique, soutenue par une contrainte unique. Ensuite, renvoyez
2xxrapidement et effectuez le travail lourd depuis votre propre file d'attente.
Les livraisons peuvent se répéter et l'ordre n'est pas garanti. La fenêtre d'horodatage limite l'âge de la relecture. La déduplication par ID d'événement empêche le double traitement.
Tentatives, pause automatique et plan de récupération
Chaque cycle de livraison effectue jusqu'à trois tentatives.
| Tentative | Attente avant celle-ci | Timeout de la tentative |
|---|---|---|
| 1 | aucune | 10 s |
| 2 | 1 s | 10 s |
| 3 | 4 s | 10 s |
Source : guide des webhooks TokenLab, observé le 28/09/2026.
Chaque tentative reçoit un horodatage et une signature frais. Cela signifie que votre vérification de signature doit utiliser l'horodatage de la même requête, et non une valeur mise en cache.
Réponses pouvant être réessayées : échecs réseau, 429, et 5xx. Non réessayé dans le cycle : autres 4xx, redirections, et cibles réseau invalides. Les échecs transitoires peuvent déclencher des tentatives ultérieures du même événement avec le même ID de livraison, ce qui est une autre raison pour laquelle la déduplication n'est pas optionnelle.
Dix cycles d'échec consécutifs suspendent l'endpoint automatiquement.
Lorsque votre récepteur était hors ligne, procédez comme suit :
- Réparez le récepteur. Confirmez qu'il lit les octets bruts et renvoie
2xxrapidement. - Reprenez l'endpoint avec
PATCH {"is_active": true}. Cela réinitialise le compteur d'échecs. - Envoyez un test avec
POST …/test. Un200de l'API de test signifie seulement que la tentative a été enregistrée. Vérifiez l'historique des livraisons et confirmezoutcome == "delivered". - Réconciliez l'écart. Prenez les ID de tâches que vous avez stockés pendant que l'endpoint était suspendu et appelez
GET /v1/tasks/{id}pour chacun d'eux. - Seulement alors, faites à nouveau confiance au flux de webhooks.
L'historique des livraisons vous donne outcome, http_status, attempts et delivered_at. Il stocke uniquement les métadonnées, pas les charges utiles. Les anciens événements ne peuvent pas être rejoués manuellement, donc l'étape 4 n'est pas optionnelle. Vos ID de tâches stockés sont le chemin de récupération.
Faire tourner un secret sans perdre d'événements
La rotation n'est pas réversible, alors planifiez la fenêtre avant de commencer.
- Appelez
POST /v1/management/webhooks/{webhookId}/rotate-secret. La réponse renvoie le nouveau secret une seule fois. - Ajoutez le nouveau secret à votre liste de vérification sur le récepteur. Gardez l'ancien dans cette liste également.
- Déployez le changement du récepteur avant de supprimer quoi que ce soit. La liste doit contenir les deux secrets à la fois.
- Envoyez un test et confirmez
outcome == "delivered"dans l'historique. - Après une courte fenêtre, supprimez l'ancien secret et redéployez.
Les livraisons en cours peuvent encore porter l'ancienne signature. Si vous échangez les secrets en une seule étape, vous perdez ces événements. Un vérificateur qui ne détient qu'un seul secret peut rejeter des livraisons qui ont été signées juste avant la rotation.
Gérer les webhooks depuis MCP
Si vous pilotez TokenLab depuis un agent, le serveur MCP expose le même cycle de vie. Utilisez @tokenlabai/mcp-server avec le profil full. Les outils sont list_webhooks, create_webhook, get_webhook, update_webhook, delete_webhook, rotate_webhook_secret, test_webhook et list_webhook_deliveries.
Le serveur lit le Management Token depuis TOKENLAB_MANAGEMENT_TOKEN. Le dernier package publié observé le 28/09/2026 est le 0.6.24. MCP modifie les mêmes endpoints que ceux que vous voyez dans le tableau de bord, il n'y a donc pas d'état séparé à réconcilier.
FAQ
Les tâches d'image envoient-elles des webhooks ?
Oui. Chaque tâche asynchrone dans l'espace de travail, y compris les tâches d'image, envoie son événement terminal aux endpoints abonnés à ce type d'événement. Le champ taskType sur la charge utile vous indique de quel type de tâche il s'agissait, par exemple video ou image. Les résultats synchrones ne sont pas couverts.
Que se passe-t-il si mon endpoint est hors ligne ?
Chaque cycle réessaie jusqu'à trois fois. Dix cycles d'échec consécutifs suspendent l'endpoint automatiquement. Les échecs de livraison transitoires peuvent être réessayés plus tard avec le même ID de livraison. Une fois l'endpoint suspendu, les événements survenus pendant la pause ne sont pas livrés plus tard et ne peuvent pas être rejoués manuellement. Réparez le récepteur, reprenez l'endpoint, envoyez un test, puis réconciliez les tâches que vous avez créées pendant l'écart en appelant GET /v1/tasks/{id} avec vos ID de tâches stockés.
Puis-je rejouer un ancien événement ?
Non. L'historique des livraisons contient uniquement des métadonnées, pas des charges utiles, et il n'y a pas de relecture manuelle. La fenêtre d'horodatage rejette également tout ce qui a plus de 300 secondes. La réconciliation via l'API des tâches est le moyen pris en charge pour rattraper le retard.
Est-il sûr de resoumettre automatiquement task.failed avec retryable: true ?
Non. retryable décrit l'échec de la génération. Ce n'est pas une instruction de resoumettre. Une nouvelle soumission est une nouvelle tâche facturable, alors décidez vous-même de la nouvelle tentative et tenez compte du coût.
L'API de compatibilité Seedance utilise-t-elle ces webhooks ?
Non. Son callback_url par requête est un contrat séparé avec sa propre charge utile. Il n'utilise pas les événements de l'espace de travail ou ces en-têtes HMAC, alors ne pointez pas un vérificateur vers les deux.
Commencez par le contrat complet dans le guide des webhooks, puis créez une clé API et activez votre premier endpoint dans l'espace de travail qui soumet vos tâches.
Sources
- https://docs.tokenlab.sh/guides/webhooksObservé le 2026-09-28
- https://docs.tokenlab.sh/guides/async-jobs-pollingObservé le 2026-09-28
- https://www.npmjs.com/package/@tokenlabai/mcp-serverObservé le 2026-09-28



