Webhooks
Webhooks
Recevoir la fin d'un run par HTTP, et vérifier la signature de chaque livraison.
Un webhook vous notifie qu'un run est terminé, sans que vous ayez à l'interroger ni à garder une connexion ouverte : passez un webhook_url à POST /v1/runs (voir Lancer un run) et leem appelle cette URL une fois le run arrivé à un état terminal (et, pour un run avec outils client, à chaque fois qu'il attend vos résultats).
Créer le secret de signature
Un webhook n'est accepté sur un run que si votre organisation a déjà un secret de signature. Créez-le depuis la console « Développeurs », ou par l'API (droit agents) :
curl https://leem-api.lcom-corp.com/v1/webhook_secret \
-X POST \
-H "Authorization: Bearer $LEEM_API_KEY"
{ "data": { "secret": "whsec_0123456789abcdefghijklmnopqrstuvwxyzABCD", "created_at": "2026-09-21T10:00:00+00:00" } }
Le secret complet n'est renvoyé qu'à cet instant : ni cette route ni la console ne le montrent à nouveau ensuite. Rappeler cette route remplace immédiatement le secret existant (pas de période de recouvrement) : toute livraison en cours signée avec l'ancien secret échouera sa vérification côté récepteur si vous ne le mettez pas à jour partout où vous le vérifiez.
webhook_url doit être une URL HTTPS publique, port 443 : un hôte interne ou privé est refusé dès POST /v1/runs (400 invalid_parameter).
Corps d'une livraison
Chaque livraison porte le run complet, dans la même forme que GET /v1/runs/{id} :
{
"id": "whd_01k6example0000000000000000",
"type": "run.completed",
"created_at": "2026-09-21T10:13:20+00:00",
"data": { "run": { "id": "run_01k6example0000000000000000", "status": "succeeded", "...": "..." } }
}
type vaut run.completed, run.failed ou run.cancelled : les trois états terminaux d'un run, un seul webhook terminal par run.
Un run qui appelle un outil client envoie en plus un webhook run.requires_action à chaque pause, avant son webhook terminal. Son data.run a le statut requires_action et porte required_action : les appels à exécuter, avec leur arguments (qui peuvent contenir des données personnelles, caviardées après 30 jours). Un run peut se mettre en pause plusieurs fois, donc en envoyer plusieurs. Il est signé et réessayé comme les autres ; répondez 2xx puis exécutez les appels et envoyez les résultats à POST /v1/runs/{run}/tool_outputs.
{
"id": "whd_01k6example0000000000002",
"type": "run.requires_action",
"created_at": "2026-10-30T09:00:04+00:00",
"data": { "run": { "id": "run_01k6example0000000000000000", "status": "requires_action", "required_action": { "type": "submit_tool_outputs", "tool_calls": [ { "id": "call_01k8examplecall00000000000", "name": "consulter_mes_livraisons", "arguments": { "date": "2026-10-31" } } ] }, "...": "..." } }
}
En-têtes et vérification
Leem-Signature: t=1790000000, v1=e8760db3adfbb418c3baa93e291624a6f111fc1f7335a0fe6647b95647a9f594
Leem-Webhook-Id: whd_01k6example0000000000000000
Leem-Webhook-Id identifie la livraison, et reste le même d'une tentative à l'autre : utilisez-le pour écarter un doublon (un réémetteur réseau, ou une nouvelle tentative arrivée après que vous ayez déjà répondu 2xx sans que leem l'ait vu). Leem-Signature porte l'horodatage t (secondes Unix) et la signature v1, un HMAC-SHA256 hexadécimal de la chaîne "{t}.{corps brut}" avec votre secret.
Vérifiez toujours sur le corps brut, avant tout JSON.parse : un ré-encodage JSON (ordre des clés, espaces) casserait la comparaison. Comparez la signature en temps constant, et refusez un t trop ancien ou trop récent (tolérance recommandée : 5 minutes) pour vous protéger d'un rejeu.
Le bloc ci-dessous est celui que ce guide teste réellement contre le vecteur ci-dessus :
const crypto = require("node:crypto");
function verifyLeemSignature(secret, header, rawBody, toleranceSeconds = 300, now = Math.floor(Date.now() / 1000)) {
const parts = {};
for (const item of String(header ?? "").split(",")) {
const [name, value] = item.trim().split("=");
if (name && value) parts[name] = value;
}
const t = Number(parts.t);
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
if (Math.abs(now - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parts.v1, "hex"));
}
Express
const express = require("express");
const app = express();
app.post(
"/webhooks/leem",
express.raw({ type: "application/json" }), // keeps req.body as a Buffer: the RAW bytes, before any parsing
(req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifyLeemSignature(process.env.LEEM_WEBHOOK_SECRET, req.header("Leem-Signature"), rawBody)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(rawBody);
// handle event.type, event.data.run ...
res.sendStatus(200);
},
);
PHP
function verifyLeemSignature(string $secret, ?string $header, string $rawBody, int $toleranceSeconds = 300, ?int $now = null): bool
{
$now ??= time();
$parts = [];
foreach (explode(',', (string) $header) as $item) {
[$name, $value] = array_pad(explode('=', trim($item), 2), 2, null);
if ($name !== null && $value !== null) {
$parts[$name] = $value;
}
}
$t = $parts['t'] ?? null;
if (!ctype_digit((string) $t) || !preg_match('/^[0-9a-f]{64}$/', $parts['v1'] ?? '')) {
return false;
}
if (abs($now - (int) $t) > $toleranceSeconds) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// $rawBody = file_get_contents('php://input'); — the raw bytes, read before any json_decode.
Python
import hashlib
import hmac
import re
import time
def verify_leem_signature(secret, header, raw_body, tolerance_seconds=300, now=None):
now = int(time.time()) if now is None else now
parts = {}
for item in (header or "").split(","):
if "=" in item:
name, value = item.strip().split("=", 1)
parts[name] = value
t = parts.get("t")
v1 = parts.get("v1", "")
if not (t and t.isdigit()) or not re.fullmatch(r"[0-9a-f]{64}", v1):
return False
if abs(now - int(t)) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# raw_body must be the exact bytes received, decoded as text — never a re-serialized dict.
Tentatives
Une livraison échouée (délai dépassé, connexion refusée, statut hors 2xx) est retentée jusqu'à 6 fois au total, espacées de 1 min, 5 min, 30 min, 2 h puis 21 h, soit environ 24 heures du premier essai au dernier. Répondez 2xx en moins de 10 secondes : traitez l'événement de façon asynchrone si besoin plutôt que de faire attendre la réponse. Aucune redirection n'est suivie : une URL qui redirige est traitée comme un échec de cette tentative.