Lingara Lingara Docs Guides API Libraries Apps Build Web app
Language: English

Webhooks and events

API version 2026-10-affable-towhee

Lingara records what happens to your account's lesson plans and usage as events, and accepts events from your game or app. Every event, whichever way it travels, has the same envelope, and the event catalogue lists them all.

The envelope

Every event carries six fields. id begins lgr_evt_, is unique, and is the key to deduplicate on. type names the event. created_at is when it happened. api_version is the version data is shaped at: your client's pin, or, on the feed and the stream, the version your request named in Lingara-Version. subject begins lgr_sub_ and says who the event is about: it is stable for your client but different for each client, and it is never an email, a name or an account ID. data is small and names resources rather than copying them: fetch a resource with the scope it needs.

Two types need a sentence each. lesson_plan.ready can arrive twice for one plan, first with data.status partial and then complete: act on the first for a usable plan, or wait for complete to have every set. usage.threshold_reached is sent only for metered accounts and clients, and a jump past several thresholds reports only the highest one crossed, so do not expect one event per threshold.

One log, three ways to hear it

Webhooks suit a server with a public HTTPS endpoint. The feed and the stream suit a program that has none, such as a game on a player's machine. The envelope is the same on every door, so a program can start on the feed and move to webhooks later without changing how it reads an event. The event routes answer native programs. A game running in a browser cannot call them yet, because /v1/ answers no cross-origin preflight.

Who hears an event

A client hears an event when it holds events:read and the event type's own scope, which the catalogue lists, and when the event is about the client's owner. On the feed and the stream, the access token's scopes narrow this further, and types narrows it to the types you name. webhook.test goes only to the endpoint it was sent to, never to the feed, and cannot be subscribed to. app.installed and app.uninstalled go only to the app's own client, never to another client of the same account.

Register an endpoint

Register an endpoint on the Webhooks page of the Lingara web app, at app.getlingara.com/admin/webhooks, choosing the client first. Its URL must use https on port 443, and its host must resolve only to public addresses. Choose the events to send: only the types the client's scopes allow are offered. The URL and the events cannot be edited later: add a new endpoint and delete the old one. The signing secret begins lgr_whsec_ and is shown only once.

Verify a delivery

Every delivery is a POST with three headers, following the Standard Webhooks specification: webhook-id (the event's id), webhook-timestamp and webhook-signature. The HMAC key is the base64-decoded part of the secret after lgr_whsec_, never the secret as a string. Verify before parsing the body, over its raw bytes, as below. Refuse a delivery whose timestamp is more than five minutes from now, the Standard Webhooks libraries' default: that stops a captured delivery from being replayed.

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 publishes verifiers for most languages. They expect a secret written as whsec_ followed by base64, or as bare base64, so pass them the part of the Lingara secret after lgr_whsec_. Lingara's own libraries accept the whole secret.

Answer quickly, expect retries

Answer with any 2xx within 10 seconds, and do the work afterwards. Anything else, including a timeout or a 3xx (redirects are not followed), is retried with growing gaps for about a day. A 410 in answer to an automatic delivery disables the endpoint at once; a 410 in answer to a test or a redelivery does not. After five days of failed deliveries the endpoint is disabled too. Either way, its owner is emailed. A fault on Lingara's side never counts toward disabling an endpoint. From the Webhooks page you can send a test, or redeliver any delivery of the last 30 days. Each is one attempt, never retried, and is sent even to a disabled endpoint.

Delivery is at least once and unordered. The same event can arrive twice, and a retry can arrive after a later event. webhook-id is the same on every retry, and on a redelivery up to 30 days later. Record each id you have handled for 30 days, and ignore a repeat. Order by created_at if order matters.

Rotate a signing secret

An endpoint can hold two signing secrets at once. While both are live, webhook-signature carries two v1, entries, and a receiver that accepts either keeps working. Add the new secret to your server, deploy it, then revoke the old one.

The feed

GET /v1/events with a client's access token returns items (envelopes), next_cursor and has_more. The token needs events:read and the scope of each type you want to hear: with events:read alone, the feed is empty. It starts from now. Pass start=oldest for the events of about the last 30 days. It needs no public endpoint and no signing secret: the access token proves who is asking. The exchange below asks for both scopes the lesson plan events need.

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 always present: store it and pass it back as cursor. It is opaque. has_more true means call again now, and false means you are caught up: poll later, or open the stream. A cursor older than 30 days is refused with 410 and cursor_expired. Without a cursor the feed starts from now, and the events in between are skipped. To recover them, call with start=oldest, which reaches back as far as events are kept, and skip the id values you have already handled.

The stream

GET /v1/events/stream carries the same events as server-sent events. Each event frame's data is one envelope, and each frame's id: is a cursor, the same token as next_cursor, so you can switch between the feed and the stream with no gap. After a dropped connection, a done frame (the stream ends itself from time to time) or an error frame, reconnect with Last-Event-ID set to the last id: you received. Most SSE clients do this for you, and so does tailEvents in Lingara's libraries (streamEvents there is a single connection). It is a cursor, not the event's id. The stream sends a heartbeat, so a silent connection is a dead one.

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

Send an event to Lingara

POST /v1/events with events:write sends an event to Lingara as {type, data}: world.context_changed (a scene, source_lang, target_lang, level, and optionally an npc with a name and persona, and tags) or world.practice_requested (a topic and the same languages and level). Idempotency-Key is required: up to 255 visible ASCII characters, such as a UUID. Without it the answer is 400 and idempotency_key_required. Set it once per event, and send the same key on a retry. One key is one event: within a day, a second request with the same key gets the first answer (equal as JSON, not byte for byte), even if its body differs, and after that it gets the same event, as below. Inbound events are not signed: your access token is the proof. Describe the world, never the player: no names or chat in scene, npc, topic or 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}}'

Each text field is one line of visible characters, counted after trimming: scene and topic up to 160, npc.name up to 32, and npc.persona up to 120. Line breaks, tabs and other control characters are refused, and so are invisible and formatting characters: direction overrides, zero-width characters other than the joiners some scripts and emoji need, the tag block and private-use characters. tags holds up to 8 lowercase machine tokens of up to 24 characters each, and never reaches the lesson plan. level is 1 to 9, and the two languages must differ. A request outside these limits is refused with 400 and records no event.

With "generate": true (the default for world.practice_requested), the token also needs lesson_plans:write. Without it the request is refused with 403 and no event is recorded. With it, Lingara starts a lesson plan, with the same checks and the same bill as creating one directly, and the 202 answer's reaction says what happened. With started and plan_status generating, a lesson_plan.ready or lesson_plan.failed whose data.plan_id is the answer's plan_id follows, on every door you use. With partial or complete, the plan came from the library and can be read now, and no event is promised: one may still arrive, so only generating is worth waiting for. With refused or failed, the event still stands. It is not retried under the same key, so send a new event to try again.

A retry within a day gets the first answer back. A retry after that is rebuilt from the stored event, which keeps the plan it started but not why a reaction was refused. So a late retry can answer with reaction failed and internal: that means the first outcome was not recorded, not that no plan exists. Read the plan by its plan_id if you kept it, or send a new event.

Tidewater Games: a game with no server

Tidewater Games, a fictional studio, builds a Godot game in which the player explores a night market. Its developer runs the game on their own machine, with their own client.

The player walks into a noodle stall. The game posts world.context_changed with "generate": true, the command in the inbound section above, and keeps the plan_id from the answer.

If plan_status is generating, the game reads the stream, or polls the feed, until a lesson_plan.ready with that plan_id arrives. It then reads the plan with lesson_plans:read, as below. If the plan was already complete, it reads it at once.

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 the studio adds a small server with an HTTPS endpoint and registers it for lesson_plan.ready. The same event arrives there, with the same id, and the game's code for reading it does not change.

A client secret must never ship inside a game build, because anything on a player's device can be read. Until Lingara supports signing in on behalf of a player, a game on players' machines talks to its own server, and only the developer's own copy talks to Lingara directly.

What events cost

Your client's usage counts each accepted inbound event, each feed call and each stream opened, as it counts every /v1/ call. An event sent with "generate": true also counts as a lesson plan. Each webhook delivery counts once per event per endpoint, at its first 2xx, whichever attempt that is. It never counts again, and a test never counts. GET /v1/usage shows the month so far.