Webhooks et événements
Version de l'API 2026-10-affable-towhee
Lingara enregistre sous forme d'événements ce qui arrive aux plans de leçon et à l'utilisation de votre compte, et accepte les événements de votre jeu ou de votre application. Chaque événement, quel que soit son mode de transmission, a la même enveloppe, et le catalogue des événements les liste tous.
L'enveloppe
Chaque événement porte six champs. id commence par lgr_evt_, est unique et sert de clé de déduplication. type nomme l'événement. created_at indique quand il s'est produit. api_version est la version selon laquelle data est structuré : celle à laquelle votre client est épinglé ou, sur le fil et le flux, la version que votre requête a indiquée dans Lingara-Version. subject commence par lgr_sub_ et indique qui l'événement concerne : il est stable pour votre client mais différent pour chaque client, et ce n'est jamais une adresse e-mail, un nom ni un identifiant de compte. data est court et désigne des ressources plutôt que de les copier : récupérez une ressource avec la portée dont elle a besoin.
Deux types méritent chacun une phrase. lesson_plan.ready peut arriver deux fois pour un même plan, d'abord avec data.status partial puis complete : agissez sur le premier pour un plan utilisable, ou attendez complete pour disposer de toutes les séries. usage.threshold_reached n'est envoyé que pour les comptes et clients facturés à l'usage, et un saut au-delà de plusieurs seuils ne signale que le plus élevé franchi : n'attendez donc pas un événement par seuil.
Un journal, trois façons de l'écouter
Les webhooks conviennent à un serveur doté d'un point de terminaison HTTPS public. Le fil et le flux conviennent à un programme qui n'en a pas, comme un jeu sur la machine d'un joueur. L'enveloppe est la même quelle que soit la voie, si bien qu'un programme peut commencer par le fil et passer plus tard aux webhooks sans changer sa façon de lire un événement. Les routes d'événements répondent aux programmes natifs. Un jeu qui tourne dans un navigateur ne peut pas encore les appeler, car /v1/ ne répond à aucune requête préliminaire cross-origin.
Qui reçoit un événement
Un client reçoit un événement lorsqu'il détient events:read et la portée propre au type d'événement, indiquée dans le catalogue, et lorsque l'événement concerne le propriétaire du client. Sur le fil et le flux, les portées du jeton d'accès restreignent encore cet ensemble, et types le restreint aux types que vous nommez. webhook.test ne va qu'au point de terminaison auquel il a été envoyé, jamais au fil, et ne peut pas faire l'objet d'un abonnement. app.installed et app.uninstalled ne vont qu'au client propre de l'application, jamais à un autre client du même compte.
Enregistrer un point de terminaison
Enregistrez un point de terminaison sur la page Webhooks de l'application web Lingara, à l'adresse app.getlingara.com/admin/webhooks, en choisissant d'abord le client. Son URL doit utiliser https sur le port 443, et son hôte ne doit se résoudre qu'en adresses publiques. Choisissez les « Événements à envoyer » : seuls les types que les portées du client autorisent sont proposés. L'URL et les événements ne peuvent pas être modifiés par la suite : ajoutez un nouveau point de terminaison et supprimez l'ancien. Le secret de signature commence par lgr_whsec_ et n'est affiché qu'une fois.
Vérifier une livraison
Chaque livraison est un POST avec trois en-têtes, conformément à la spécification Standard Webhooks : webhook-id (l'id de l'événement), webhook-timestamp et webhook-signature. La clé HMAC est la partie du secret qui suit lgr_whsec_, décodée en base64, jamais le secret en tant que chaîne. Vérifiez avant d'analyser le corps, sur ses octets bruts, comme ci-dessous. Refusez une livraison dont l'horodatage s'écarte de plus de cinq minutes de l'heure actuelle, la valeur par défaut des bibliothèques Standard Webhooks : cela empêche de rejouer une livraison interceptée.
signed = webhook-id + "." + webhook-timestamp + "." + raw request body
key = base64_decode(the secret after its prefix)
expected = "v1," + base64(hmac_sha256(key, signed))
accept if |now - webhook-timestamp| <= 5 minutes
and some entry of webhook-signature (space-separated) equals expected
(compare in constant time)Standard Webhooks publie des vérificateurs pour la plupart des langages. Ils attendent un secret écrit sous la forme whsec_ suivi de base64, ou en base64 seul : transmettez-leur donc la partie du secret Lingara qui suit lgr_whsec_. Les bibliothèques de Lingara acceptent le secret entier.
Répondre vite, prévoir les nouvelles tentatives
Répondez par n'importe quel 2xx dans un délai de 10 secondes, et faites le travail ensuite. Toute autre réponse, y compris un dépassement de délai ou un 3xx (les redirections ne sont pas suivies), donne lieu à de nouvelles tentatives, à intervalles croissants, pendant environ une journée. Un 410 en réponse à une livraison automatique désactive aussitôt le point de terminaison ; un 410 en réponse à un test ou à un renvoi ne le fait pas. Après cinq jours de livraisons échouées, le point de terminaison est également désactivé. Dans les deux cas, son propriétaire reçoit un e-mail. Une panne du côté de Lingara ne compte jamais dans la désactivation d'un point de terminaison. Depuis la page Webhooks, vous pouvez « Envoyer un test », ou « Renvoyer » toute livraison des 30 derniers jours. Chacun est une tentative unique, jamais relancée, et est envoyé même à un point de terminaison désactivé.
La livraison se fait au moins une fois et sans ordre garanti. Un même événement peut arriver deux fois, et une nouvelle tentative peut arriver après un événement plus récent. webhook-id est identique à chaque nouvelle tentative, et lors d'un renvoi jusqu'à 30 jours plus tard. Conservez pendant 30 jours chaque id que vous avez traité, et ignorez les répétitions. Triez par created_at si l'ordre compte.
Renouveler un secret de signature
Un point de terminaison peut détenir deux secrets de signature à la fois. Tant que les deux sont actifs, webhook-signature porte deux entrées v1,, et un récepteur qui accepte l'une ou l'autre continue de fonctionner. Ajoutez le nouveau secret à votre serveur, déployez-le, puis révoquez l'ancien.
Le fil
GET /v1/events avec un jeton d'accès d'un client renvoie items (des enveloppes), next_cursor et has_more. Le jeton a besoin de events:read et de la portée de chaque type que vous voulez recevoir : avec events:read seul, le fil est vide. Il commence à partir de maintenant. Passez start=oldest pour obtenir les événements des 30 derniers jours environ. Il ne nécessite ni point de terminaison public ni secret de signature : le jeton d'accès prouve qui demande. L'échange ci-dessous demande les deux portées dont ont besoin les événements de plan de leçon.
export LINGARA_TOKEN="$(curl -sS --fail-with-body -X POST "https://api.getlingara.com/oauth/token" \
-u "$LINGARA_CLIENT_ID:$LINGARA_CLIENT_SECRET" \
-d "grant_type=client_credentials" \
--data-urlencode "scope=events:read lesson_plans:read" | jq -r '.access_token // error(.error)')"curl "https://api.getlingara.com/v1/events" \
-H "Authorization: Bearer $LINGARA_TOKEN"next_cursor est toujours présent : stockez-le et renvoyez-le comme cursor. Il est opaque. has_more à true signifie qu'il faut rappeler tout de suite, et false que vous êtes à jour : interrogez plus tard, ou ouvrez le flux. Un curseur de plus de 30 jours est refusé avec 410 et cursor_expired. Sans curseur, le fil commence à partir de maintenant, et les événements intermédiaires sont ignorés. Pour les récupérer, appelez avec start=oldest, qui remonte aussi loin que les événements sont conservés, et ignorez les valeurs id que vous avez déjà traitées.
Le flux
GET /v1/events/stream transmet les mêmes événements sous forme d'événements envoyés par le serveur (SSE). Le data de chaque trame event est une enveloppe, et l'id: de chaque trame est un curseur, le même jeton que next_cursor : vous pouvez donc passer du fil au flux sans trou. Après une connexion interrompue, une trame done (le flux se termine de lui-même de temps en temps) ou une trame error, reconnectez-vous avec Last-Event-ID défini sur le dernier id: reçu. La plupart des clients SSE le font pour vous, tout comme tailEvents dans les bibliothèques de Lingara (streamEvents y est une connexion unique). C'est un curseur, pas l'id de l'événement. Le flux envoie un signal de présence régulier, donc une connexion silencieuse est une connexion morte.
curl -N "https://api.getlingara.com/v1/events/stream" \
-H "Authorization: Bearer $LINGARA_TOKEN"Envoyer un événement à Lingara
POST /v1/events avec events:write envoie un événement à Lingara sous la forme {type, data} : world.context_changed (une scene, source_lang, target_lang, level et, en option, un npc avec un name et une persona, ainsi que tags) ou world.practice_requested (un topic et les mêmes langues et niveau). Idempotency-Key est obligatoire : jusqu'à 255 caractères ASCII visibles, par exemple un UUID. Sans elle, la réponse est 400 et idempotency_key_required. Définissez-la une fois par événement, et envoyez la même clé lors d'une nouvelle tentative. Une clé correspond à un événement : pendant une journée, une deuxième requête avec la même clé reçoit la première réponse (identique en JSON, pas octet pour octet), même si son corps diffère, et ensuite elle reçoit le même événement, comme ci-dessous. Les événements entrants ne sont pas signés : votre jeton d'accès sert de preuve. Décrivez le monde, jamais le joueur : ni nom ni conversation dans scene, npc, topic ou tags.
export LINGARA_TOKEN="$(curl -sS --fail-with-body -X POST "https://api.getlingara.com/oauth/token" \
-u "$LINGARA_CLIENT_ID:$LINGARA_CLIENT_SECRET" \
-d "grant_type=client_credentials" \
--data-urlencode "scope=events:write lesson_plans:write" | jq -r '.access_token // error(.error)')"curl -X POST "https://api.getlingara.com/v1/events" \
-H "Authorization: Bearer $LINGARA_TOKEN" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"world.context_changed","data":{"scene":"A night market in Taipei, just after rain","npc":{"name":"Auntie Lin","persona":"a street-food vendor who likes to haggle"},"source_lang":"en","target_lang":"zh","level":3,"tags":["market","food","chapter-2"],"generate":true}}'Chaque champ texte est une seule ligne de caractères visibles, comptés après suppression des espaces de début et de fin : scene et topic jusqu'à 160, npc.name jusqu'à 32 et npc.persona jusqu'à 120. Les sauts de ligne, tabulations et autres caractères de contrôle sont refusés, de même que les caractères invisibles et de mise en forme : forçages de direction, caractères de largeur nulle autres que les liants dont certaines écritures et les emoji ont besoin, le bloc des étiquettes et les caractères à usage privé. tags contient jusqu'à 8 jetons machine en minuscules de 24 caractères au plus chacun, et n'atteint jamais le plan de leçon. level va de 1 à 9, et les deux langues doivent différer. Une requête hors de ces limites est refusée avec 400 et n'enregistre aucun événement.
Avec "generate": true (la valeur par défaut pour world.practice_requested), le jeton a aussi besoin de lesson_plans:write. Sans cette portée, la requête est refusée avec 403 et aucun événement n'est enregistré. Avec elle, Lingara lance un plan de leçon, avec les mêmes vérifications et la même facturation qu'une création directe, et le champ reaction de la réponse 202 indique ce qui s'est passé. Avec started et plan_status generating, un lesson_plan.ready ou lesson_plan.failed dont le data.plan_id est le plan_id de la réponse suit, sur chaque voie que vous utilisez. Avec partial ou complete, le plan provient de la bibliothèque et peut être lu tout de suite, et aucun événement n'est promis : il peut encore en arriver un, donc seul generating vaut la peine d'attendre. Avec refused ou failed, l'événement reste valable. Il n'est pas retenté sous la même clé : envoyez un nouvel événement pour réessayer.
Une nouvelle tentative dans la journée reçoit la première réponse. Une nouvelle tentative au-delà est reconstruite à partir de l'événement stocké, qui conserve le plan qu'il a lancé mais pas la raison du refus d'une réaction. Une nouvelle tentative tardive peut donc répondre avec reaction failed et internal : cela signifie que le premier résultat n'a pas été enregistré, et non qu'aucun plan n'existe. Lisez le plan par son plan_id si vous l'avez conservé, ou envoyez un nouvel événement.
Tidewater Games : un jeu sans serveur
Tidewater Games, un studio fictif, développe un jeu Godot dans lequel le joueur explore un marché de nuit. Son développeur fait tourner le jeu sur sa propre machine, avec son propre client.
Le joueur entre dans une échoppe de nouilles. Le jeu publie world.context_changed avec "generate": true, la commande de la section sur les événements entrants ci-dessus, et conserve le plan_id de la réponse.
Si plan_status vaut generating, le jeu lit le flux, ou interroge le fil, jusqu'à l'arrivée d'un lesson_plan.ready portant ce plan_id. Il lit ensuite le plan avec lesson_plans:read, comme ci-dessous. Si le plan était déjà complete, il le lit immédiatement.
export LINGARA_TOKEN="$(curl -sS --fail-with-body -X POST "https://api.getlingara.com/oauth/token" \
-u "$LINGARA_CLIENT_ID:$LINGARA_CLIENT_SECRET" \
-d "grant_type=client_credentials" \
--data-urlencode "scope=lesson_plans:read" | jq -r '.access_token // error(.error)')"curl "https://api.getlingara.com/v1/lesson-plans/$ID" \
-H "Authorization: Bearer $LINGARA_TOKEN"Plus tard, le studio ajoute un petit serveur doté d'un point de terminaison HTTPS et l'enregistre pour lesson_plan.ready. Le même événement y arrive, avec le même id, et le code du jeu qui le lit ne change pas.
Un secret du client ne doit jamais être livré dans une version du jeu, car tout ce qui se trouve sur l'appareil d'un joueur peut être lu. Tant que Lingara ne prend pas en charge la connexion au nom d'un joueur, un jeu installé sur les machines des joueurs communique avec son propre serveur, et seule la copie du développeur communique directement avec Lingara.
Ce que coûtent les événements
L'utilisation de votre client compte chaque événement entrant accepté, chaque appel au fil et chaque flux ouvert, comme elle compte chaque appel /v1/. Un événement envoyé avec "generate": true compte aussi comme un plan de leçon. Chaque livraison de webhook compte une fois par événement et par point de terminaison, à son premier 2xx, quelle que soit la tentative. Elle ne compte plus jamais ensuite, et un test ne compte jamais. GET /v1/usage indique le mois en cours.
En cas de divergence entre une traduction et la référence en anglais, la référence en anglais fait foi.