Quand une tâche asynchrone se termine, Lingo.dev ne vous force pas à la sonder. Il vous rappelle : un POST vers le point de terminaison HTTPS que vous avez enregistré comme callbackUrl. C'est pratique. Mais c'est aussi une surface d'exposition : une URL publique accepte tout ce qu'internet lui envoie, et quiconque découvre la vôtre peut POST un événement falsifié "job completed" vers votre gestionnaire.
La règle est donc toujours la même pour chaque callback : vérifiez avant de faire confiance. Chaque livraison embarque une signature calculée à partir d'un secret que seuls vous et Lingo.dev détenez. Recalculez-la de votre côté, comparez-la en temps constant, et une charge utile falsifiée n'atteindra jamais votre logique métier. Cette page centralise tout ce mécanisme. Les callbacks de localization comme de provisioning l'utilisent tel quel – leurs pages respectives détaillent simplement la structure de leur charge utile et renvoient ici pour la vérification.
Sur cette page
- Les trois en-têtes
- Le secret de signature
- Vérifier une signature
- Pourquoi le corps brut est important
- Empêcher les attaques par rejeu
- Répondre vite, traiter ensuite
- Retries et backoff
Les trois en-têtes#
Lingo.dev suit la spécification Standard Webhooks, un schéma ouvert implémenté par plusieurs fournisseurs. Vous vérifiez donc vos webhooks par rapport à un contrat publié, et non à une implémentation propriétaire. Chaque livraison inclut trois en-têtes :
| En-tête | Description |
|---|---|
webhook-id | Un identifiant unique pour la livraison. |
webhook-timestamp | Horodatage Unix en secondes indiquant quand la livraison a été envoyée. |
webhook-signature | La signature elle-même : v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))} |
Le contenu signé se compose de trois parties reliées par des points – webhook-id, puis webhook-timestamp, puis le corps brut de la requête – dans cet ordre exact. Reconstituez cette chaîne, calculez son HMAC-SHA256 avec votre secret, encodez le résultat en base64, et vous obtenez la valeur à comparer.
L'en-tête webhook-signature peut contenir plusieurs signatures séparées par des espaces, chacune associée à une version de schéma (v1,...). Un vérificateur accepte la livraison si au moins une signature correspond. Parcourir toute la liste, plutôt que lire une seule valeur, est la façon la plus défensive d'analyser cet en-tête ; c'est pourquoi les exemples ci-dessous testent chaque signature présente.
Le secret de signature#
Le secret est généré pour votre organisation la première fois que vous soumettez une tâche avec un callbackUrl. Il commence par le préfixe whsec_, suivi des octets de clé encodés en base64 :
whsec_Mf9aQ7n...base64...key...bytesRetirez le préfixe whsec_, puis décodez le reste en base64 pour récupérer les octets bruts de la clé – c'est cette valeur décodée qui sert de clé HMAC, et non la chaîne préfixée. Signer à partir du texte littéral whsec_... est la raison la plus fréquente pour laquelle une implémentation apparemment correcte ne correspond jamais : commencez donc toujours par décoder.
Traitez ce secret comme une clé d'API
Le secret de signature est ce qui distingue un vrai callback d'un callback falsifié. Gardez-le côté serveur, hors du contrôle de version et hors de tout bundle client. Quiconque le possède peut signer des charges utiles que votre gestionnaire acceptera. Voir API Keys pour savoir comment Lingo.dev gère les identifiants au niveau de l'organisation.
Vérifier une signature#
La vérification tient en une seule fonction, à placer une fois devant votre gestionnaire. Elle fait trois choses : recalculer la signature attendue à partir du corps brut, la comparer à celle reçue avec un contrôle en temps constant, et rejeter tout ce qui ne correspond pas avant que votre code ne s'exécute. La même fonction protège chaque événement asynchrone que Lingo.dev vous envoie – fins de localisation, fins de provisioning, tous les types, sur toutes les surfaces produit.
import crypto from "node:crypto";
function verifyWebhook(payload, headers, secret) {
const msgId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
// Reject timestamps outside a tolerance window (replay prevention)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
throw new Error("Webhook timestamp too old");
}
// Recompute the expected signature over id.timestamp.body
const content = `${msgId}.${timestamp}.${payload}`;
const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64");
const expected = crypto
.createHmac("sha256", secretBytes)
.update(content)
.digest("base64");
// A delivery may carry several signatures; accept if any matches
for (const sig of signatures.split(" ")) {
const [version, value] = sig.split(",", 2);
if (version === "v1" && crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(value)
)) {
return JSON.parse(payload);
}
}
throw new Error("Invalid webhook signature");
}Comparez avec une fonction en temps constant – crypto.timingSafeEqual, hmac.compare_digest – et non avec ==. Une comparaison de chaînes classique s'arrête dès que deux octets diffèrent, et cet écart de timing suffit à divulguer la signature un octet à la fois. La comparaison en temps constant élimine ce canal auxiliaire, d'où son utilisation dans les deux exemples ci-dessus.
Pourquoi le corps brut est important#
Notez que les deux fonctions signent payload – le corps exactement tel qu'il est arrivé sur le réseau, avant toute analyse JSON. C'est le détail qui fait le plus souvent trébucher des intégrations pourtant correctes, et il vaut la peine d'être énoncé clairement là où il pose problème :
La signature est calculée à partir des octets exacts envoyés par Lingo.dev. Dès que vous parsez le corps en objet puis le sérialisez à nouveau, vous risquez de modifier les espaces, l'ordre des clés ou le format des nombres – et le HMAC recalculé ne correspond alors plus à une signature calculée sur les octets d'origine. La charge utile a le même sens ; les octets, eux, ont changé.
Vérifiez la signature sur le corps brut, pas sur l'objet parsé
Capturez le corps brut de la requête avant que votre framework ne le parse, puis transmettez ces octets au vérificateur. Dans Express, utilisez express.raw({ type: "application/json" }) sur la route webhook. Dans FastAPI, lisez await request.body(). Ne parsez qu'après validation de la signature – d'abord la vérification, ensuite le parsing.
Empêcher les attaques par rejeu#
Une charge utile signée valide interceptée par un attaquant peut être rejouée telle quelle – la signature reste valide, car rien ne change entre la première livraison et une copie renvoyée une heure plus tard. L'en-tête webhook-timestamp est ce qui borne cette fenêtre : il enregistre le moment où la livraison a été envoyée, afin que votre vérificateur puisse rejeter tout ce qui dépasse la tolérance que vous avez choisie. Les exemples ci-dessus utilisent cinq minutes.
Le contrôle d'horodatage bloque les rejeux trop anciens : une copie interceptée puis renvoyée au-delà de votre tolérance échoue au test de fraîcheur et n'atteint jamais votre gestionnaire.
Répondre vite, traiter ensuite#
Une fois la livraison vérifiée, renvoyez immédiatement 200, puis faites le vrai travail – écritures en base de données, appels en aval, invalidation de cache – après avoir répondu.
app.post(
"/webhooks/lingo",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = verifyWebhook(req.body.toString(), req.headers, process.env.LINGO_WEBHOOK_SECRET);
} catch {
return res.status(401).send("invalid signature");
}
// Acknowledge first, process after - never block the response on slow work
res.status(200).send("ok");
void handleEvent(event);
}
);La raison est mécanique, pas stylistique. Un gestionnaire lent garde la connexion HTTP ouverte ; s'il met assez longtemps pour expirer, la livraison est considérée comme échouée et fait l'objet d'une nouvelle tentative. Résultat : un traitement lourd dans le chemin de réponse transforme un événement en plusieurs. Accusez réception rapidement, déléguez le travail à une file d'attente ou à une tâche en arrière-plan, et un événement unique restera un événement unique. Les structures de charge utile sur lesquelles vous faites votre routage dans handleEvent sont documentées avec chaque produit : localization callbacks et provisioning callbacks.
Retries et backoff#
Votre point de terminaison sera parfois indisponible – un déploiement, un timeout, une passerelle défaillante. Quand cela arrive, Lingo.dev ne laisse pas tomber l'événement.
Si votre point de terminaison renvoie un statut non 2xx ou est inaccessible, la livraison est retentée avec un backoff exponentiel à partir de 30 secondes, jusqu'à 5 tentatives. Après la cinquième, la livraison est marquée comme échouée et Lingo.dev arrête les tentatives – mais le résultat n'est pas perdu. Il reste récupérable depuis l'enregistrement de la tâche : une période d'indisponibilité peut vous faire manquer un callback, jamais le résultat lui-même. Cet enregistrement de tâche est votre filet de sécurité : utilisez le webhook pour le cas nominal et considérez la tâche stockée comme la source de vérité sur laquelle vous pouvez toujours vous replier. Pour une tâche de traduction, sondez-la directement.
