Lingara Lingara Documentatie Gidsen API Bibliotheken Apps Bouwen Webapp
Taal: Nederlands

Webhooks en gebeurtenissen

API-versie 2026-10-affable-towhee

Lingara legt als gebeurtenissen vast wat er met de lesplannen en het gebruik van je account gebeurt, en accepteert gebeurtenissen van je game of app. Elke gebeurtenis heeft, langs welke weg ze ook reist, dezelfde envelop, en de gebeurteniscatalogus somt ze allemaal op.

De envelop

Elke gebeurtenis bevat zes velden. id begint met lgr_evt_, is uniek en is de sleutel om op te ontdubbelen. type benoemt de gebeurtenis. created_at geeft aan wanneer ze plaatsvond. api_version is de versie waarin data is opgebouwd: de versie waarop je client is vastgezet of, bij de feed en de stream, de versie die je verzoek in Lingara-Version noemde. subject begint met lgr_sub_ en zegt over wie de gebeurtenis gaat: het is stabiel voor je client maar verschilt per client, en het is nooit een e-mailadres, een naam of een account-ID. data is klein en noemt resources in plaats van ze te kopiëren: haal een resource op met de scope die ervoor nodig is.

Twee typen verdienen elk een zin. lesson_plan.ready kan voor één plan twee keer binnenkomen, eerst met data.status partial en daarna complete: handel op de eerste voor een bruikbaar plan, of wacht op complete om alle sets te hebben. usage.threshold_reached wordt alleen verstuurd voor accounts en clients met betalen naar gebruik, en een sprong over meerdere drempels meldt alleen de hoogste overschreden drempel, dus verwacht niet één gebeurtenis per drempel.

Eén logboek, drie manieren om het te horen

Webhooks passen bij een server met een openbaar HTTPS-endpoint. De feed en de stream passen bij een programma dat er geen heeft, zoals een game op de computer van een speler. De envelop is langs elke weg dezelfde, dus een programma kan met de feed beginnen en later naar webhooks overstappen zonder te veranderen hoe het een gebeurtenis leest. De gebeurtenisroutes bedienen native programma's. Een game die in een browser draait, kan ze nog niet aanroepen, omdat /v1/ op geen enkele cross-origin-preflight antwoordt.

Wie een gebeurtenis hoort

Een client hoort een gebeurtenis wanneer hij events:read en de eigen scope van het gebeurtenistype heeft, die de catalogus vermeldt, en wanneer de gebeurtenis over de eigenaar van de client gaat. Bij de feed en de stream perken de scopes van het toegangstoken dit verder in, en types perkt het in tot de typen die je noemt. webhook.test gaat alleen naar het endpoint waarnaar hij is verzonden, nooit naar de feed, en je kunt je er niet op abonneren. app.installed en app.uninstalled gaan alleen naar de eigen client van de app, nooit naar een andere client van hetzelfde account.

Een endpoint registreren

Registreer een endpoint op de pagina Webhooks van de Lingara-webapp, op app.getlingara.com/admin/webhooks, en kies eerst de client. De URL moet https gebruiken op poort 443, en de host mag alleen naar openbare adressen worden omgezet. Kies de 'Te verzenden gebeurtenissen': alleen de typen die de scopes van de client toestaan, worden aangeboden. De URL en de gebeurtenissen kunnen later niet worden bewerkt: voeg een nieuw endpoint toe en verwijder het oude. Het ondertekeningsgeheim begint met lgr_whsec_ en wordt maar één keer getoond.

Een aflevering verifiëren

Elke aflevering is een POST met drie headers, volgens de specificatie Standard Webhooks: webhook-id (de id van de gebeurtenis), webhook-timestamp en webhook-signature. De HMAC-sleutel is het base64-gedecodeerde deel van het geheim na lgr_whsec_, nooit het geheim als tekenreeks. Verifieer voordat je de body parseert, over de ruwe bytes, zoals hieronder. Weiger een aflevering waarvan het tijdstempel meer dan vijf minuten van nu afwijkt, de standaard van de Standard Webhooks-bibliotheken: zo kan een onderschepte aflevering niet opnieuw worden afgespeeld.

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 publiceert verificatiebibliotheken voor de meeste talen. Die verwachten een geheim geschreven als whsec_ gevolgd door base64, of als kale base64, dus geef ze het deel van het Lingara-geheim na lgr_whsec_. De eigen bibliotheken van Lingara accepteren het hele geheim.

Antwoord snel, reken op herhalingen

Antwoord binnen 10 seconden met een willekeurige 2xx, en doe het werk daarna. Al het andere, inclusief een time-out of een 3xx (omleidingen worden niet gevolgd), wordt met groeiende tussenpozen ongeveer een dag lang opnieuw geprobeerd. Een 410 als antwoord op een automatische aflevering schakelt het endpoint meteen uit; een 410 als antwoord op een test of een nieuwe aflevering niet. Na vijf dagen mislukte afleveringen wordt het endpoint ook uitgeschakeld. In beide gevallen krijgt de eigenaar een e-mail. Een storing aan de kant van Lingara telt nooit mee voor het uitschakelen van een endpoint. Vanaf de pagina Webhooks kun je met 'Test verzenden' een test sturen, of met 'Opnieuw afleveren' elke aflevering van de afgelopen 30 dagen opnieuw sturen. Elk daarvan is één poging, die nooit wordt herhaald, en wordt zelfs naar een uitgeschakeld endpoint verzonden.

Aflevering gebeurt minstens één keer en zonder vaste volgorde. Dezelfde gebeurtenis kan twee keer binnenkomen, en een herhaling kan na een latere gebeurtenis binnenkomen. webhook-id is bij elke herhaling hetzelfde, ook bij een nieuwe aflevering tot 30 dagen later. Bewaar elke id die je hebt verwerkt 30 dagen lang, en negeer een herhaling. Sorteer op created_at als de volgorde ertoe doet.

Een ondertekeningsgeheim roteren

Een endpoint kan twee ondertekeningsgeheimen tegelijk hebben. Zolang beide actief zijn, bevat webhook-signature twee v1,-items, en een ontvanger die een van beide accepteert, blijft werken. Voeg het nieuwe geheim toe aan je server, rol het uit en trek daarna het oude in.

De feed

GET /v1/events met het toegangstoken van een client geeft items (enveloppen), next_cursor en has_more terug. Het token heeft events:read nodig en de scope van elk type dat je wilt horen: met alleen events:read is de feed leeg. Hij begint vanaf nu. Geef start=oldest mee voor de gebeurtenissen van ongeveer de afgelopen 30 dagen. Hij heeft geen openbaar endpoint en geen ondertekeningsgeheim nodig: het toegangstoken bewijst wie er vraagt. De inruil hieronder vraagt om beide scopes die de lesplangebeurtenissen nodig hebben.

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 is altijd aanwezig: sla hem op en geef hem terug als cursor. Hij is ondoorzichtig. has_more true betekent dat je meteen opnieuw moet aanroepen, en false betekent dat je bij bent: vraag later opnieuw op, of open de stream. Een cursor ouder dan 30 dagen wordt geweigerd met 410 en cursor_expired. Zonder cursor begint de feed vanaf nu, en worden de tussenliggende gebeurtenissen overgeslagen. Om ze terug te halen, roep je aan met start=oldest, dat zo ver teruggaat als gebeurtenissen worden bewaard, en sla je de id-waarden over die je al hebt verwerkt.

De stream

GET /v1/events/stream draagt dezelfde gebeurtenissen over als door de server verzonden gebeurtenissen (SSE). De data van elk event-frame is één envelop, en de id: van elk frame is een cursor, hetzelfde token als next_cursor, zodat je zonder gat tussen de feed en de stream kunt wisselen. Maak na een verbroken verbinding, een done-frame (de stream beëindigt zichzelf af en toe) of een error-frame opnieuw verbinding met Last-Event-ID ingesteld op de laatste id: die je hebt ontvangen. De meeste SSE-clients doen dit voor je, en tailEvents in de bibliotheken van Lingara ook (streamEvents is daar één enkele verbinding). Het is een cursor, niet de id van de gebeurtenis. De stream stuurt een hartslag, dus een stille verbinding is een dode verbinding.

curl -N "https://api.getlingara.com/v1/events/stream" \
  -H "Authorization: Bearer $LINGARA_TOKEN"

Een gebeurtenis naar Lingara sturen

POST /v1/events met events:write stuurt een gebeurtenis naar Lingara als {type, data}: world.context_changed (een scene, source_lang, target_lang, level en optioneel een npc met een name en persona, en tags) of world.practice_requested (een topic en dezelfde talen en hetzelfde niveau). Idempotency-Key is verplicht: maximaal 255 zichtbare ASCII-tekens, zoals een UUID. Zonder deze sleutel is het antwoord 400 en idempotency_key_required. Stel hem één keer per gebeurtenis in, en stuur bij een herhaling dezelfde sleutel. Eén sleutel is één gebeurtenis: binnen een dag krijgt een tweede verzoek met dezelfde sleutel het eerste antwoord (gelijk als JSON, niet byte voor byte), ook als de body verschilt, en daarna krijgt het dezelfde gebeurtenis, zoals hieronder. Inkomende gebeurtenissen worden niet ondertekend: je toegangstoken is het bewijs. Beschrijf de wereld, nooit de speler: geen namen of chats in scene, npc, topic of 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}}'

Elk tekstveld is één regel zichtbare tekens, geteld na het weghalen van witruimte aan begin en eind: scene en topic tot 160, npc.name tot 32 en npc.persona tot 120. Regeleinden, tabs en andere besturingstekens worden geweigerd, net als onzichtbare tekens en opmaaktekens: richtingsoverschrijvingen, tekens zonder breedte behalve de verbinders die sommige schriften en emoji nodig hebben, het tagblok en tekens voor privégebruik. tags bevat maximaal 8 machinetokens in kleine letters van elk maximaal 24 tekens, en bereikt nooit het lesplan. level loopt van 1 tot 9, en de twee talen moeten verschillen. Een verzoek buiten deze grenzen wordt geweigerd met 400 en legt geen gebeurtenis vast.

Met "generate": true (de standaard voor world.practice_requested) heeft het token ook lesson_plans:write nodig. Zonder die scope wordt het verzoek geweigerd met 403 en wordt er geen gebeurtenis vastgelegd. Met die scope start Lingara een lesplan, met dezelfde controles en dezelfde rekening als bij het rechtstreeks aanmaken, en zegt reaction in het 202-antwoord wat er is gebeurd. Bij started en plan_status generating volgt een lesson_plan.ready of lesson_plan.failed waarvan de data.plan_id de plan_id van het antwoord is, langs elke weg die je gebruikt. Bij partial of complete kwam het plan uit de bibliotheek en kan het meteen worden gelezen, en wordt er geen gebeurtenis beloofd: er kan er nog een binnenkomen, dus alleen op generating is het de moeite waard te wachten. Bij refused of failed blijft de gebeurtenis staan. Ze wordt onder dezelfde sleutel niet opnieuw geprobeerd, dus stuur een nieuwe gebeurtenis om het opnieuw te proberen.

Een herhaling binnen een dag krijgt het eerste antwoord terug. Een latere herhaling wordt opnieuw opgebouwd uit de opgeslagen gebeurtenis, die het gestarte plan bewaart maar niet waarom een reactie werd geweigerd. Een late herhaling kan dus antwoorden met reaction failed en internal: dat betekent dat de eerste uitkomst niet is vastgelegd, niet dat er geen plan bestaat. Lees het plan via zijn plan_id als je die hebt bewaard, of stuur een nieuwe gebeurtenis.

Tidewater Games: een game zonder server

Tidewater Games, een fictieve studio, bouwt een Godot-game waarin de speler een nachtmarkt verkent. De ontwikkelaar draait de game op de eigen computer, met een eigen client.

De speler loopt een noedelkraam binnen. De game post world.context_changed met "generate": true, het commando uit de sectie over inkomende gebeurtenissen hierboven, en bewaart de plan_id uit het antwoord.

Als plan_status generating is, leest de game de stream, of peilt hij de feed, tot er een lesson_plan.ready met die plan_id binnenkomt. Daarna leest hij het plan met lesson_plans:read, zoals hieronder. Was het plan al complete, dan leest hij het meteen.

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"

Later voegt de studio een kleine server met een HTTPS-endpoint toe en registreert die voor lesson_plan.ready. Daar komt dezelfde gebeurtenis binnen, met dezelfde id, en de code van de game die haar leest, verandert niet.

Een clientgeheim mag nooit in een build van de game worden meegeleverd, want alles op het apparaat van een speler kan worden gelezen. Zolang Lingara inloggen namens een speler niet ondersteunt, praat een game op de computers van spelers met zijn eigen server, en praat alleen het eigen exemplaar van de ontwikkelaar rechtstreeks met Lingara.

Wat gebeurtenissen kosten

Het gebruik van je client telt elke geaccepteerde inkomende gebeurtenis, elke feed-aanroep en elke geopende stream, zoals het elke /v1/-aanroep telt. Een gebeurtenis die met "generate": true is verzonden, telt ook als een lesplan. Elke webhookaflevering telt één keer per gebeurtenis per endpoint, bij de eerste 2xx, welke poging dat ook is. Ze telt nooit opnieuw, en een test telt nooit. GET /v1/usage toont de maand tot nu toe.

Als een vertaling afwijkt van de Engelse referentie, is de Engelse referentie juist.