Webhooks
Au lieu d'interroger GET /batches/{batch_name} jusqu'à la fin du traitement, enregistrez une URL de rappel une fois pour toutes et recevez un HTTP POST dès qu'un batch se termine. Les trois en-têtes de signature et la construction du contenu signé id.timestamp.body suivent la spécification ouverte Standard Webhooks — la même approche utilisée par les webhooks d'OpenAI. Une différence par rapport à la convention de la spécification : webhook_secret est ici une simple chaîne hexadécimale, et non une valeur base64 préfixée par whsec_ — une bibliothèque de vérification Standard Webhooks/Svix standard tentera de la décoder en base64 et échouera. Utilisez le secret exactement tel qu'il est retourné, comme des octets de clé HMAC bruts, avec le code de vérification ci-dessous (ou votre propre équivalent) plutôt qu'une bibliothèque qui s'attend à un préfixe.
Enregistrer un webhook
Enregistrez (ou mettez à jour) le callback d'un batch avec PUT /api/v1/batches/{batch_name}/webhook :
curl -X PUT https://imagetotable.ai/api/v1/batches/260716-4K9P/webhook \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"callback_url": "https://example.com/hooks/imagetotable"}'La réponse inclut un webhook_secret par webhook, généré pour vous — un secret par enregistrement, pas une clé partagée au niveau du compte, donc une fuite n'affecte que ce callback :
{
"batch_name": "260716-4K9P",
"callback_url": "https://example.com/hooks/imagetotable",
"webhook_secret": "9f2a3b7c1d8e4f5061728394a5b6c7d81a2b3c4d5e6f7089",
"created_at": "2026-07-16T09:12:03Z",
"fired_at": null
}Conservez webhook_secret — vous en aurez besoin pour vérifier les livraisons entrantes. Il n'existe pas de point d'accès séparé pour « révéler le secret », mais refaire un PUT avec la même callback_url est un moyen sûr de le récupérer si vous le perdez (cela ne fait pas tourner le secret ni ne réinitialise quoi que ce soit).
Enveloppe d'événement
Chaque livraison est un objet JSON enveloppé — jamais les données brutes du batch — afin que la structure puisse évoluer pour couvrir de futurs types d'événements sans casser les intégrations existantes. Vérifiez type pour distinguer les événements ; une URL de rappel enregistrée sur un batch peut recevoir les deux types ci-dessous, mélangés.
{
"type": "batch.completed",
"created_at": "2026-07-16T09:14:31Z",
"data": {
"batch_name": "260716-4K9P",
"status": "succeeded",
"document_count": 3
}
}data.status reflète le résultat global du batch (succeeded ou failed — voir Modèle de tâche asynchrone pour le vocabulaire complet des statuts). Le payload est un avis d'achèvement, pas les résultats eux-mêmes — appelez GET /batches/{batch_name}/results après l'avoir reçu pour récupérer les données extraites réelles.
Déclencher un travail d'annotation bbox sur un document réutilise le webhook déjà enregistré pour le batch de ce document, et livre un événement distinct au lieu d'un autre batch.completed :
{
"type": "document.bbox_completed",
"created_at": "2026-07-16T09:20:07Z",
"data": {
"document_id": "3f9c9e2a-1b7d-4e2b-9a3e-2f5b6c7d8e9f",
"group_batch_id": "bx_8a3c2e1f",
"status": "succeeded"
}
}Comme pour batch.completed, la livraison de cet événement est réclamée de manière atomique, donc vous ne recevrez pas deux livraisons document.bbox_completed distinctes pour un même travail bbox, même si plusieurs groupes de lignes atteignent un statut terminal à quelques instants d'intervalle sur des threads différents. La déduplication habituelle basée sur webhook-id (voir ci-dessous) s'applique en plus pour le cas normal d'une livraison échouée qui est réessayée.
Vérification des signatures
Chaque livraison comporte trois en-têtes, conformément à la spécification Standard Webhooks :
| En-tête | Rôle |
|---|---|
webhook-id | Identifiant unique de cette tentative de livraison. Utilisez-le pour dédupliquer — une livraison réessayée réutilise le même ID. |
webhook-timestamp | Horodatage Unix de l'envoi, pour se prémunir contre les attaques par rejeu (rejetez les livraisons dont l'horodatage est trop ancien). |
webhook-signature | La signature elle-même, formatée sous la forme v1,<signature base64>. |
La signature est un HMAC-SHA256 calculé sur la concaténation de webhook-id, webhook-timestamp, et du corps de requête brut — dans cet ordre, séparés par . — avec pour clé le webhook_secret de votre webhook :
signed_content = "{webhook_id}.{webhook_timestamp}.{raw_request_body}"
signature = base64(hmac_sha256(webhook_secret, signed_content))Recalculez cette valeur de votre côté et comparez-la (à l'aide d'une comparaison en temps constant) avec la valeur située après v1, dans l'en-tête webhook-signature. Vérifiez toujours sur les octets du corps de requête brut, et non sur une version resérialisée du JSON parsé — la resérialisation peut modifier les espaces/l'ordre des clés et produire une non-correspondance de signature, même pour une livraison authentique.
# La vérification de signature n'est pas un appel HTTP, donc on la recrée avec
# openssl sur une livraison déjà sauvegardée sur disque (headers.txt /
# body.json) — utile pour confirmer votre compréhension du schéma à la main
# avant de coder la vraie vérification dans votre gestionnaire de webhook.
WEBHOOK_ID=$(grep -i '^webhook-id:' headers.txt | cut -d' ' -f2 | tr -d '\r')
WEBHOOK_TS=$(grep -i '^webhook-timestamp:' headers.txt | cut -d' ' -f2 | tr -d '\r')
SIGNED_CONTENT="${WEBHOOK_ID}.${WEBHOOK_TS}.$(cat body.json)"
echo -n "$SIGNED_CONTENT" \
| openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary \
| base64import base64
import hashlib
import hmac
import os
headers = dict(line.split(": ", 1) for line in open("headers.txt") if ": " in line)
webhook_id = headers["webhook-id"].strip()
webhook_timestamp = headers["webhook-timestamp"].strip()
body = open("body.json", "rb").read()
signed_content = f"{webhook_id}.{webhook_timestamp}.".encode() + body
signature = base64.b64encode(
hmac.new(os.environ["WEBHOOK_SECRET"].encode(), signed_content, hashlib.sha256).digest()
)
print(signature.decode())import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";
const headers = Object.fromEntries(
readFileSync("headers.txt", "utf8")
.split("\n")
.filter((line) => line.includes(": "))
.map((line) => line.split(": "))
);
const webhookId = headers["webhook-id"].trim();
const webhookTimestamp = headers["webhook-timestamp"].trim();
const body = readFileSync("body.json", "utf8");
const signedContent = `${webhookId}.${webhookTimestamp}.${body}`;
const signature = createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(signedContent)
.digest("base64");
console.log(signature);Si vous testez à la main — en sauvegardant le corps d'une livraison dans body.json avant d'exécuter l'un des extraits ci-dessus — attention au saut de ligne final ajouté par votre éditeur ou méthode de capture. La version curl/openssl ci-dessus y échappe par hasard (le $(cat ...) de bash supprime les sauts de ligne finaux), mais les versions Python et JavaScript lisent les octets exacts du fichier et produiront silencieusement une signature qui ne correspond pas si un \n supplémentaire s'est glissé — le symptôme classique est « curl dit que c'est valide mais mon vérificateur Python dit que non », ce qui est déroutant précisément parce que le code lui-même n'a pas de bug. Capturez le corps via l'accesseur de corps de requête brut de votre framework web (le request.get_data() de Flask, le middleware raw-body d'Express) dans votre véritable gestionnaire de webhook plutôt que de copier-coller/sauvegarder manuellement, et ce problème ne se pose pas en production — c'est seulement un piège des tests manuels.
Nouvelles tentatives
La livraison est tentée une fois immédiatement à la fin du batch. Si cette tentative échoue (timeout, erreur de connexion ou réponse non-2xx), une nouvelle tentative est effectuée avec un backoff exponentiel : 1 minute, 5 minutes, 30 minutes, 2 heures, 6 heures, 24 heures — jusqu'à 7 tentatives au total (la tentative initiale plus 6 nouvelles tentatives). Si aucune d'entre elles ne réussit dans les 24 heures suivant la première tentative, la livraison est marquée comme échouée et aucune autre tentative n'est effectuée. Faites en sorte que votre endpoint réponde avec un statut 2xx dès que vous avez enregistré l'événement de manière durable — effectuez les traitements lents (comme la récupération et l'analyse réelles des résultats du batch) après avoir répondu, pas avant, afin qu'une étape aval lente n'entraîne pas elle-même une nouvelle tentative.
Retraiter un batch : une notification par vague
Une notification batch.completed donnée se déclenche une fois, au moment où chaque document du batch a atteint un statut terminal. Si vous ajoutez ultérieurement d'autres documents à ce même batch_name et appelez à nouveau process — « une nouvelle vague » — vous serez à nouveau averti une fois que cette vague se termine également, automatiquement. process_batch réarme le webhook lui-même dès qu'il trouve de nouveaux documents éligibles dans un batch dont la vague précédente vous a déjà averti ; vous n'avez pas besoin de refaire un PUT du webhook ni de faire quoi que ce soit d'autre entre les vagues.
Si deux vagues se chevauchent — vous ajoutez et traitez plus de documents alors qu'une vague précédente est toujours en cours, plutôt qu'après sa fin — vous recevrez exactement une notification, couvrant tous les documents des deux vagues, livrée une fois que le dernier d'entre eux (de l'une ou l'autre vague) se termine. Vous ne recevez deux notifications distinctes que lorsque le batch est réellement devenu inactif (chaque document a atteint un statut terminal au moins une fois) entre les deux appels à process.
Les versions antérieures de ce document décrivaient le déclenchement unique comme une limitation permanente à contourner en refaisant un PUT du webhook avant chaque vague. Ce conseil n'a en fait jamais fonctionné — l'endpoint d'enregistrement était, et est toujours, délibérément non interventionniste concernant fired_at (voir la référence Webhooks) — et le réarmement est désormais géré automatiquement par process à la place. Si vous utilisez une intégration écrite selon l'ancien conseil, vous pouvez cesser en toute sécurité de vous réenregistrer avant chaque vague ; cela n'a jamais rien fait.