Webhook 與事件
API 版本 2026-10-affable-towhee
Lingara 會將你帳戶的課程計畫和用量所發生的事記錄為事件,並接收來自你的遊戲或應用程式的事件。每個事件無論以哪種方式傳遞,都使用同一種封包,事件目錄列出了全部事件。
封包
每個事件都帶有六個欄位。id 以 lgr_evt_ 開頭,獨一無二,是去除重複時所依據的鍵。type 指明事件類型。created_at 是事件發生的時間。api_version 是 data 所採用的版本:也就是你的用戶端固定的版本;在事件源和事件串流中,則是你的要求在 Lingara-Version 中指定的版本。subject 以 lgr_sub_ 開頭,表示事件涉及的對象:它對你的用戶端保持不變,但每個用戶端各不相同,而且絕不會是電子郵件地址、姓名或帳戶 ID。data 很小,只指明資源而不複製資源:請以所需的權限範圍擷取資源。
有兩種類型各需要一句說明。lesson_plan.ready 對同一份計畫可能抵達兩次,先是 data.status 為 partial,然後是 complete:可以依第一次的事件取得一份可用的計畫,或等候 complete 以取得每一組內容。usage.threshold_reached 只會針對按用量計費的帳戶和用戶端傳送,而一次跨過多個門檻時只會回報跨過的最高門檻,因此不要預期每個門檻都有一個事件。
一份日誌,三種接收方式
Webhook 適合擁有公開 HTTPS 端點的伺服器。事件源和事件串流適合沒有公開端點的程式,例如在玩家電腦上執行的遊戲。每種方式上的封包都相同,因此程式可以先使用事件源,之後再改用 Webhook,而不必改變讀取事件的方式。事件路由回應原生程式。在瀏覽器中執行的遊戲目前還無法呼叫它們,因為 /v1/ 不回應跨來源預檢要求。
誰會收到事件
當用戶端持有 events:read 以及該事件類型本身的權限範圍(見事件目錄),而且事件涉及該用戶端的擁有者時,用戶端就會收到該事件。在事件源和事件串流中,存取權杖的權限範圍會進一步縮小範圍,而 types 會將它縮小到你指定的類型。webhook.test 只傳送到它被送往的端點,從不進入事件源,也無法訂閱。app.installed 和 app.uninstalled 只傳送給該應用程式自己的用戶端,絕不會傳給同一帳戶的其他用戶端。
註冊端點
在 Lingara 網頁應用程式的 Webhook 頁面(網址為 app.getlingara.com/admin/webhooks)註冊端點,先選擇用戶端。端點 URL 必須在連接埠 443 上使用 https,且其主機只能解析為公開位址。選擇要傳送的事件:只會列出該用戶端的權限範圍所允許的類型。URL 和要傳送的事件之後都無法編輯:請新增端點並刪除舊的端點。簽章密鑰以 lgr_whsec_ 開頭,只會顯示一次。
驗證傳遞
每次傳遞都是一個 POST,遵循 Standard Webhooks 規格,帶有三個標頭:webhook-id(事件的 id)、webhook-timestamp 和 webhook-signature。HMAC 金鑰是簽章密鑰中 lgr_whsec_ 之後的部分經 base64 解碼的結果,絕不是密鑰字串本身。請在剖析內文之前,以其原始位元組進行驗證,如下所示。拒絕時間戳記與目前時間相差超過五分鐘的傳遞,這是 Standard Webhooks 函式庫的預設值:這樣可以防止被攔截的傳遞遭到重送。
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 為大多數語言發布了驗證函式庫。它們要求密鑰寫成 whsec_ 加上 base64,或純 base64,因此請將 Lingara 密鑰中 lgr_whsec_ 之後的部分傳給它們。Lingara 自己的函式庫接受完整的密鑰。
快速回應,預期重試
請在 10 秒內以任何 2xx 回應,之後再處理工作。其他任何結果,包括逾時或 3xx(不會跟隨重新導向),都會以逐漸拉長的間隔重試約一天。對自動傳遞回應 410 會立即停用該端點;對測試或重新傳遞回應 410 則不會。連續五天傳遞失敗後,端點同樣會被停用。無論哪種情況,都會寄送電子郵件給其擁有者。Lingara 端的故障絕不會計入停用端點的條件。在 Webhook 頁面上,你可以傳送測試,或重新傳遞最近 30 天內的任何傳遞。每次都只嘗試一次,從不重試,即使端點已停用也會傳送。
傳遞至少一次,且不保證順序。同一事件可能抵達兩次,重試也可能在較晚的事件之後才抵達。webhook-id 在每次重試中都相同,在 30 天內的重新傳遞中也相同。請將你處理過的每個 id 記錄 30 天,並忽略重複的事件。如果順序很重要,請依 created_at 排序。
輪換簽章密鑰
一個端點可以同時持有兩組簽章密鑰。兩者都有效期間,webhook-signature 會帶有兩個 v1, 項目,接受其中任一個的接收端就能繼續正常運作。請將新密鑰加入你的伺服器並部署,然後撤銷舊密鑰。
事件源
以用戶端的存取權杖呼叫 GET /v1/events,會傳回 items(封包)、next_cursor 和 has_more。權杖需要 events:read 以及你想接收的每種類型的權限範圍:只有 events:read 時,事件源是空的。它從現在開始。傳入 start=oldest 可取得大約最近 30 天的事件。它不需要公開端點,也不需要簽章密鑰:存取權杖證明了提出要求的是誰。下方的換取要求了課程計畫事件所需的兩個權限範圍。
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 一定存在:請保存它,並作為 cursor 傳回。它是不透明的。has_more 為 true 表示立即再次呼叫,為 false 表示你已趕上最新進度:稍後再輪詢,或開啟事件串流。超過 30 天的游標會遭到拒絕,並傳回 410 與 cursor_expired。不帶游標時,事件源從現在開始,中間的事件會被略過。要找回它們,請以 start=oldest 呼叫,它會回溯到事件保留的最早時間,並略過你已處理過的 id 值。
事件串流
GET /v1/events/stream 以伺服器傳送事件的形式傳送相同的事件。每個 event 框架的 data 是一個封包,每個框架的 id: 是一個游標,與 next_cursor 是同一種值,因此你可以在事件源和事件串流之間切換而不會漏接。連線中斷、收到 done 框架(事件串流會不時自行結束)或 error 框架後,請將 Last-Event-ID 設為你收到的最後一個 id: 並重新連線。大多數 SSE 用戶端會替你處理這件事,Lingara 函式庫中的 tailEvents 也是如此(其中的 streamEvents 只是單一連線)。它是游標,而不是事件的 id。事件串流會傳送心跳訊號,因此沉默的連線就是已斷開的連線。
curl -N "https://api.getlingara.com/v1/events/stream" \
-H "Authorization: Bearer $LINGARA_TOKEN"向 Lingara 傳送事件
以 events:write 呼叫 POST /v1/events,以 {type, data} 的形式向 Lingara 傳送事件:world.context_changed(一個 scene、source_lang、target_lang、level,以及可選的附有 name 和 persona 的 npc,還有 tags)或 world.practice_requested(一個 topic,以及相同的語言和程度)。Idempotency-Key 是必要的:最多 255 個可見 ASCII 字元,例如 UUID。缺少它時,回應為 400 與 idempotency_key_required。每個事件設定一次,重試時傳送相同的鍵。一個鍵就是一個事件:一天之內,使用相同鍵的第二個要求會得到第一次的回應(以 JSON 而言相等,而非逐位元組相同),即使其內文不同;此後它會得到同一個事件,如下所示。傳入事件不會簽章:你的存取權杖就是證明。請描述世界,而不是玩家:scene、npc、topic 或 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}}'每個文字欄位都是一行可見字元,以去除前後空白後計算:scene 和 topic 最多 160,npc.name 最多 32,npc.persona 最多 120。換行字元、定位字元和其他控制字元會遭到拒絕,不可見字元和格式字元同樣會遭到拒絕:方向覆寫字元、零寬度字元(某些文字和表情符號所需的連接字元除外)、標籤區塊字元以及私人使用區字元。tags 最多包含 8 個小寫機器標記,每個最多 24 個字元,且絕不會進入課程計畫。level 為 1 到 9,兩種語言必須不同。超出這些限制的要求會遭到拒絕,並傳回 400,且不記錄任何事件。
使用 "generate": true(world.practice_requested 的預設值)時,權杖還需要 lesson_plans:write。缺少它時,要求會遭到拒絕,並傳回 403,且不記錄任何事件。具備它時,Lingara 會開始一份課程計畫,檢查和計費與直接建立課程計畫相同,202 回應中的 reaction 說明發生了什麼。若為 started 且 plan_status 為 generating,接著會在你使用的每種方式上收到一個 lesson_plan.ready 或 lesson_plan.failed,其 data.plan_id 即回應中的 plan_id。若為 partial 或 complete,則計畫來自計畫庫,現在即可讀取,且不保證會傳送事件:事件仍可能抵達,所以只有 generating 值得等候。若為 refused 或 failed,事件依然成立。它不會在同一個鍵下重試,因此請傳送新事件再試一次。
一天之內的重試會得到第一次的回應。此後的重試會依儲存的事件重建回應,它保留了事件所開始的計畫,但不保留反應遭拒絕的原因。因此,較晚的重試可能以 reaction failed 和 internal 回應:這表示第一次的結果沒有被記錄,而不是說計畫不存在。如果你保留了 plan_id,請依它讀取計畫,或傳送新事件。
Tidewater Games:沒有伺服器的遊戲
Tidewater Games 是一家虛構的工作室,正在以 Godot 製作一款讓玩家探索夜市的遊戲。它的開發者在自己的電腦上、以自己的用戶端執行這款遊戲。
玩家走進一家麵攤。遊戲發布附有 "generate": true 的 world.context_changed(即上方「向 Lingara 傳送事件」一節中的指令),並保留回應中的 plan_id。
如果 plan_status 為 generating,遊戲就讀取事件串流或輪詢事件源,直到附有該 plan_id 的 lesson_plan.ready 抵達。接著它以 lesson_plans:read 讀取計畫,如下所示。如果計畫已經是 complete,它會立即讀取。
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"後來,工作室加入一台附有 HTTPS 端點的小型伺服器,並為 lesson_plan.ready 註冊它。同一事件會以相同的 id 抵達那裡,而遊戲讀取事件的程式碼不必改變。
用戶端密鑰絕不能隨遊戲組建一起發布,因為玩家裝置上的任何東西都能被讀取。在 Lingara 支援代表玩家登入之前,在玩家電腦上執行的遊戲應與自己的伺服器通訊,只有開發者自己的副本才直接與 Lingara 通訊。
事件的費用
與每次 /v1/ 呼叫一樣,你的用戶端用量會計入每個被接受的傳入事件、每次事件源呼叫和每次開啟的事件串流。以 "generate": true 傳送的事件還會計為一份課程計畫。每次 Webhook 傳遞以每個事件每個端點計一次,在其首次 2xx 時計入,無論那是第幾次嘗試。它絕不會再次計入,測試也從不計入。GET /v1/usage 顯示本月至今的用量。
如譯文與英文參考文件有出入,以英文參考文件為準。