Webhooks y eventos
Versión de la API 2026-10-affable-towhee
Lingara registra como eventos lo que ocurre con los planes de lección y el uso de tu cuenta, y acepta eventos de tu juego o aplicación. Cada evento, viaje por donde viaje, tiene el mismo sobre, y el catálogo de eventos los enumera todos.
El sobre
Cada evento lleva seis campos. id empieza por lgr_evt_, es único y es la clave para deduplicar. type nombra el evento. created_at indica cuándo ocurrió. api_version es la versión con la que se forma data: la versión a la que está fijado tu cliente o, en el feed y el flujo, la versión que tu solicitud indicó en Lingara-Version. subject empieza por lgr_sub_ e indica a quién se refiere el evento: es estable para tu cliente pero distinto para cada cliente, y nunca es un correo electrónico, un nombre ni un ID de cuenta. data es pequeño y nombra recursos en lugar de copiarlos: obtén un recurso con el ámbito que necesita.
Dos tipos merecen una frase cada uno. lesson_plan.ready puede llegar dos veces para un mismo plan, primero con data.status partial y después complete: actúa con el primero para tener un plan utilizable, o espera a complete para tener todos los conjuntos. usage.threshold_reached solo se envía para cuentas y clientes con facturación por uso, y un salto que supera varios umbrales solo informa del más alto superado, así que no esperes un evento por umbral.
Un registro, tres formas de escucharlo
Los webhooks son adecuados para un servidor con un endpoint HTTPS público. El feed y el flujo son adecuados para un programa que no lo tiene, como un juego en el equipo de un jugador. El sobre es el mismo por cualquier vía, así que un programa puede empezar con el feed y pasar más tarde a los webhooks sin cambiar cómo lee un evento. Las rutas de eventos responden a programas nativos. Un juego que se ejecuta en un navegador aún no puede llamarlas, porque /v1/ no responde a ninguna solicitud preliminar de origen cruzado.
Quién recibe un evento
Un cliente recibe un evento cuando tiene events:read y el ámbito propio del tipo de evento, que indica el catálogo, y cuando el evento se refiere al propietario del cliente. En el feed y el flujo, los ámbitos del token de acceso lo restringen aún más, y types lo restringe a los tipos que indiques. webhook.test solo va al endpoint al que se envió, nunca al feed, y no admite suscripción. app.installed y app.uninstalled solo van al cliente propio de la app, nunca a otro cliente de la misma cuenta.
Registrar un endpoint
Registra un endpoint en la página Webhooks de la aplicación web de Lingara, en app.getlingara.com/admin/webhooks, eligiendo primero el cliente. Su URL debe usar https en el puerto 443, y su host solo debe resolverse a direcciones públicas. Elige los «Eventos que enviar»: solo se ofrecen los tipos que permiten los ámbitos del cliente. La URL y los eventos no se pueden editar después: añade un endpoint nuevo y elimina el antiguo. El secreto de firma empieza por lgr_whsec_ y se muestra una sola vez.
Verificar una entrega
Cada entrega es un POST con tres cabeceras, según la especificación Standard Webhooks: webhook-id (el id del evento), webhook-timestamp y webhook-signature. La clave HMAC es la parte del secreto posterior a lgr_whsec_, decodificada en base64, nunca el secreto como cadena. Verifica antes de analizar el cuerpo, sobre sus bytes sin procesar, como se muestra abajo. Rechaza una entrega cuya marca de tiempo difiera más de cinco minutos de la hora actual, el valor predeterminado de las bibliotecas de Standard Webhooks: así se impide reproducir una entrega capturada.
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 publica verificadores para la mayoría de los lenguajes. Esperan un secreto escrito como whsec_ seguido de base64, o como base64 sin más, así que pásales la parte del secreto de Lingara posterior a lgr_whsec_. Las bibliotecas propias de Lingara aceptan el secreto completo.
Responde rápido y espera reintentos
Responde con cualquier 2xx en un plazo de 10 segundos y haz el trabajo después. Cualquier otra cosa, incluido un tiempo de espera agotado o un 3xx (no se siguen las redirecciones), se reintenta con intervalos crecientes durante aproximadamente un día. Un 410 en respuesta a una entrega automática desactiva el endpoint de inmediato; un 410 en respuesta a una prueba o a un reenvío, no. Tras cinco días de entregas fallidas, el endpoint también se desactiva. En ambos casos, su propietario recibe un correo electrónico. Un fallo por parte de Lingara nunca cuenta para desactivar un endpoint. Desde la página Webhooks puedes «Enviar prueba» o «Reenviar» cualquier entrega de los últimos 30 días. Cada una es un único intento, nunca se reintenta y se envía incluso a un endpoint desactivado.
La entrega es al menos una vez y sin orden. El mismo evento puede llegar dos veces, y un reintento puede llegar después de un evento posterior. webhook-id es el mismo en cada reintento, y en un reenvío hasta 30 días después. Registra durante 30 días cada id que hayas procesado e ignora las repeticiones. Ordena por created_at si el orden importa.
Rotar un secreto de firma
Un endpoint puede tener dos secretos de firma a la vez. Mientras ambos están activos, webhook-signature lleva dos entradas v1,, y un receptor que acepte cualquiera de ellas sigue funcionando. Añade el secreto nuevo a tu servidor, despliégalo y luego revoca el antiguo.
El feed
GET /v1/events con el token de acceso de un cliente devuelve items (sobres), next_cursor y has_more. El token necesita events:read y el ámbito de cada tipo que quieras recibir: solo con events:read, el feed está vacío. Empieza a partir de ahora. Pasa start=oldest para obtener los eventos de aproximadamente los últimos 30 días. No necesita endpoint público ni secreto de firma: el token de acceso demuestra quién pregunta. El intercambio siguiente solicita los dos ámbitos que necesitan los eventos de planes de lección.
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 siempre está presente: guárdalo y devuélvelo como cursor. Es opaco. has_more true significa que vuelvas a llamar ya, y false que estás al día: consulta más tarde o abre el flujo. Un cursor de más de 30 días se rechaza con 410 y cursor_expired. Sin cursor, el feed empieza a partir de ahora y se omiten los eventos intermedios. Para recuperarlos, llama con start=oldest, que llega tan atrás como se conservan los eventos, y omite los valores id que ya hayas procesado.
El flujo
GET /v1/events/stream transmite los mismos eventos como eventos enviados por el servidor (SSE). El data de cada trama event es un sobre, y el id: de cada trama es un cursor, el mismo token que next_cursor, así que puedes cambiar entre el feed y el flujo sin huecos. Tras una conexión caída, una trama done (el flujo termina por sí solo de vez en cuando) o una trama error, vuelve a conectarte con Last-Event-ID establecido en el último id: que recibiste. La mayoría de los clientes SSE lo hacen por ti, igual que tailEvents en las bibliotecas de Lingara (streamEvents es allí una única conexión). Es un cursor, no el id del evento. El flujo envía un latido, así que una conexión silenciosa es una conexión muerta.
curl -N "https://api.getlingara.com/v1/events/stream" \
-H "Authorization: Bearer $LINGARA_TOKEN"Enviar un evento a Lingara
POST /v1/events con events:write envía un evento a Lingara como {type, data}: world.context_changed (una scene, source_lang, target_lang, level y, opcionalmente, un npc con name y persona, y tags) o world.practice_requested (un topic y los mismos idiomas y nivel). Idempotency-Key es obligatoria: hasta 255 caracteres ASCII visibles, como un UUID. Sin ella, la respuesta es 400 e idempotency_key_required. Defínela una vez por evento y envía la misma clave en un reintento. Una clave es un evento: durante un día, una segunda solicitud con la misma clave recibe la primera respuesta (igual como JSON, no byte a byte), aunque su cuerpo sea distinto, y después recibe el mismo evento, como se explica abajo. Los eventos entrantes no se firman: tu token de acceso es la prueba. Describe el mundo, nunca al jugador: nada de nombres ni chats en scene, npc, topic o 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}}'Cada campo de texto es una sola línea de caracteres visibles, contados tras recortar los espacios de los extremos: scene y topic hasta 160, npc.name hasta 32 y npc.persona hasta 120. Se rechazan los saltos de línea, las tabulaciones y otros caracteres de control, y también los caracteres invisibles y de formato: anulaciones de dirección, caracteres de ancho cero distintos de los unidores que necesitan algunas escrituras y los emoji, el bloque de etiquetas y los caracteres de uso privado. tags contiene hasta 8 tokens de máquina en minúsculas de hasta 24 caracteres cada uno, y nunca llega al plan de lección. level va de 1 a 9, y los dos idiomas deben ser distintos. Una solicitud fuera de estos límites se rechaza con 400 y no registra ningún evento.
Con "generate": true (el valor predeterminado de world.practice_requested), el token también necesita lesson_plans:write. Sin él, la solicitud se rechaza con 403 y no se registra ningún evento. Con él, Lingara inicia un plan de lección, con las mismas comprobaciones y la misma facturación que al crearlo directamente, y el campo reaction de la respuesta 202 indica qué ocurrió. Con started y plan_status generating, a continuación llega un lesson_plan.ready o lesson_plan.failed cuyo data.plan_id es el plan_id de la respuesta, por cada vía que uses. Con partial o complete, el plan salió de la biblioteca y se puede leer ya, y no se promete ningún evento: puede que llegue uno igualmente, así que solo vale la pena esperar con generating. Con refused o failed, el evento sigue en pie. No se reintenta con la misma clave, así que envía un evento nuevo para volver a intentarlo.
Un reintento dentro del mismo día recibe de vuelta la primera respuesta. Un reintento posterior se reconstruye a partir del evento almacenado, que conserva el plan que inició pero no el motivo por el que se rechazó una reacción. Así que un reintento tardío puede responder con reaction failed e internal: eso significa que el primer resultado no se registró, no que no exista ningún plan. Lee el plan por su plan_id si lo guardaste, o envía un evento nuevo.
Tidewater Games: un juego sin servidor
Tidewater Games, un estudio ficticio, crea un juego en Godot en el que el jugador explora un mercado nocturno. Su desarrollador ejecuta el juego en su propio equipo, con su propio cliente.
El jugador entra en un puesto de fideos. El juego publica world.context_changed con "generate": true, el comando de la sección de eventos entrantes de arriba, y guarda el plan_id de la respuesta.
Si plan_status es generating, el juego lee el flujo, o consulta el feed, hasta que llega un lesson_plan.ready con ese plan_id. Después lee el plan con lesson_plans:read, como se muestra abajo. Si el plan ya estaba complete, lo lee al momento.
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"Más adelante, el estudio añade un pequeño servidor con un endpoint HTTPS y lo registra para lesson_plan.ready. El mismo evento llega allí, con el mismo id, y el código del juego que lo lee no cambia.
Un secreto de cliente nunca debe incluirse en una compilación del juego, porque todo lo que hay en el dispositivo de un jugador se puede leer. Hasta que Lingara admita el inicio de sesión en nombre de un jugador, un juego en los equipos de los jugadores se comunica con su propio servidor, y solo la copia del propio desarrollador se comunica directamente con Lingara.
Cuánto cuestan los eventos
El uso de tu cliente cuenta cada evento entrante aceptado, cada llamada al feed y cada flujo abierto, igual que cuenta cada llamada a /v1/. Un evento enviado con "generate": true cuenta además como un plan de lección. Cada entrega de webhook cuenta una vez por evento y por endpoint, en su primer 2xx, sea cual sea el intento. Nunca vuelve a contar, y una prueba nunca cuenta. GET /v1/usage muestra lo que va de mes.
Si una traducción y la referencia en inglés difieren, prevalece la referencia en inglés.