LiveKit Webhook 仕様 — OpenTok 移行リファレンス
サーバーサイドイベント通知(LiveKit → 自社バックエンドへの HTTP POST)を、minedia-www の現状 OpenTok 実装と対比して可視化する。
★要点:実質的に対応が必要なのは 2 系統だけ
現状の minedia-www が 実処理 を持つ OpenTok コールバックは 2 つのみ。残りは noop で、移行後も noop のまま機能等価。
実装必要 streamCreated → participant_joined
パネリストの出席時刻(Interview#attend_at)を記録。
track_publishedは participant が空(state:JOINING/ name・metadata 空)でロール判定不可participant_joined.metadataに{"role":"panelist"}が入るので移行先として素直
実装必要 archive → egress_*
OtArchive upsert・録画通知・動画分析・自動文字起こしスケジュール。
- OpenTok は 1 イベントの
status違い → LiveKit は 3 つの別イベントに分割 - 録画完了は
egress_ended+status == EGRESS_COMPLETE
noop イベント:connectionCreated / connectionDestroyed / streamDestroyed は現状なにもしていない。移行先(participant_left / participant_connection_aborted / track_unpublished)も noop で機能等価。
ただし土台の作り替えは両系統に共通で必須:認証が sessionId 照合 → 署名付き JWT(WebhookReceiver)検証へ、session→room 同定キーが sessionId → room.name / room metadata へ変わる。
§Webhook の前提(押さえどころ)
📨 サーバー→自社バックエンド
Webhook は LiveKit が自社 BE へ送る HTTP POST。クライアント SDK イベント(TrackSubscribed 等)とは別系統で、名前も発火タイミングも一致しないことがある。
🔏 署名付きだが到達保証なし
payload の sha256 を含む JWT を Authorization に付与(要・生ボディ検証)。配信保証はなく複数回リトライ後 abandon されうる → 冪等設計必須。
📦 単一エンドポイント集約
LiveKit は room/participant/track/egress/ingress を 1 Webhook に集約。OpenTok は Session / Archive / Broadcast の 3 つの別 URL。
🪪 共通フィールド
全イベントに id(UUID) / createdAt(UNIX秒) / event(名前)。bigint(timestamp/size/duration)は文字列、キーは camelCase。
①OpenTok との差分
LiveKit Webhook ⇔ OpenTok(移行元)のイベント対応と差分。OpenTok は通知を 3 つの別コールバック URL(Session / Archive / Broadcast)に分けて登録する。
| LiveKit Webhook | 発火タイミング | ペイロード | OpenTok 相当 | 差分の要点 |
|---|---|---|---|---|
room_started | 最初の参加者が空ルームに join | room | sessionCreated | ほぼ対応 |
room_finished | ルーム終了(close / 最後の退出+empty timeout) | room | sessionDestroyed | OpenTok は reason 付き |
participant_joined | メディア接続確立後、状態が active に | room, participant | connectionCreated | LiveKit は active 後。OpenTok は接続時点で active 分離なし |
participant_left | 退出+クリーンアップ完了 | room, participant | connectionDestroyed | OpenTok は reason を payload で表現 |
participant_connection_aborted | 接続が予期せず中断 | room, participant | connectionDestroyed に内包 | LiveKit は異常切断を別イベントに分離 |
track_published | トラック公開 | room, participant, track | streamCreated | OpenTok は stream.videoType を持つ |
track_unpublished | トラック公開停止 | room, participant, track | streamDestroyed | OpenTok は reason 付き |
egress_started | 録画/配信開始 | egressInfo | Archive started + Broadcast started | LiveKit は同一 Webhook、OpenTok は別 URL |
egress_updated | Egress 更新(サイズ変化等) | egressInfo | (直接対応なし) | OpenTok は paused/streamAdded 等の離散ステータス |
egress_ended | Egress 終了 | egressInfo | Archive stopped/uploaded/available/failed | OpenTok は保存先で uploaded/available 区別 |
ingress_started / ingress_ended | 外部ストリーム取り込み 開始/終了 | ingressInfo | (対応なし) | OpenTok に Ingress 相当なし |
| (対応なし) | — | — | sessionNotification | LiveKit にサーバーローテーション予告なし |
| (対応なし) | — | — | Archive expired | LiveKit に「DL リンク失効」通知なし |
設計差分の押さえどころ
集約 vs 分割
1 Webhook 集約 ⇔ 3 別 URL。移行時にエンドポイント統合を設計し直す。
切断理由の持ち方
OpenTok は reason。LiveKit は participant_connection_aborted に分離+ participant の disconnectReason(SIGNAL_CLOSE 等)で取得可。
録画ステータス粒度
OpenTok は 9 状態。LiveKit は 3 イベント+詳細は egressInfo.status(EGRESS_*)。
認証
LiveKit は署名付き JWT(必須)。OpenTok は Broadcast に任意 signature secret。
②ユースケース解説 — minedia-www の現状実装と移行受け入れ条件
受信は単一 URL POST /opentok/callback/ → Opentok::CallbackController#index。params[:event] で case 分岐し、常に head :ok。
全イベント共通の受け入れ条件:authenticate_opentok! が params[:sessionId] で OtSession を検索。無ければ 403。未知 event は warn ログのみで 200。
イベント別 処理と受け入れ条件
| 現状 OpenTok event | 状態 | 移行先 LiveKit | minedia-www の処理 | 受け入れ条件(ガード) |
|---|---|---|---|---|
connectionCreated | noop | (対応 Webhook なし) | なし | — |
connectionDestroyed | noop | participant_left / ..._aborted | なし | — |
streamCreated | 実装 | participant_joined | Interview#attend_at を記録 | 下記 AND 条件 |
streamDestroyed | noop | track_unpublished | なし | — |
archive | 実装 | egress_started/updated/ended | OtArchive 冪等 upsert → status 別後続処理 | @ot_session.lock! 後 find_or_initialize_by(archive_id:) |
streamCreated → 出席記録の受け入れ条件(すべて AND)
archive の status 別 後続処理
| status | 処理 | 受け入れ条件 |
|---|---|---|
started / paused / stopped | プライベートチャットに録画ステータス通知 | ot_session.room.present? |
uploaded | ① OtArchiveAnalysisJob(ffmpeg 尺算出)② 自動文字起こしスケジュール |
Job: status.uploaded?文字起こし: schedule_auto_transcription? |
available / expired / failed | DB 保存のみ(通知・Job なし) | notify_private_chat_of_status の else で noop |
schedule_auto_transcription? = 次の AND:
文字起こしスケジュールは begin/rescue で囲まれ、失敗してもコールバックは 200 を返す。
③移行時に注意すべき差分
「2 イベント対応」だが、土台とイベント分割の作り替えが効く。
1. 出席記録の発火元が変わる:現状 streamCreated。LiveKit の track_published.participant は name/metadata 空・state:JOINING でロール判定不可。interview_role == panelist 判定が肝なので、participant_joined(metadata にロールあり)で記録する方が素直。意味的にも OpenTok「streamCreated=メディア確立」≒ LiveKit「participant_joined=active 後」。
2. status 分岐 → event 分岐への組み替え:OpenTok archive 1 イベントの status 違い → LiveKit は egress_started/updated/ended の 3 別イベント。started/stopped 通知 ⇔ egress_started/ended、uploaded ⇔ egress_ended + EGRESS_COMPLETE に対応づける。
3. 録画完了の payload 直取りで ffmpeg 不要化:egress_ended.egressInfo.fileResults[] から location(S3 URL) / size / duration(ns) が取れる。現状 OtArchiveAnalysisJob の ffmpeg 尺計測は不要化できる。size/duration_from_tokbox も payload から直接。
4. failed/失効ステータスの代替:LiveKit に available/expired 相当の Webhook なし(現状 noop なので影響軽微)。failed 検知は egressInfo.status == EGRESS_FAILED / error で代替。
5. 認証と同定キーの作り替え(両系統共通の土台):sessionId 照合 → JWT 署名検証(WebhookReceiver、要・生ボディ)。session→room 同定キーが sessionId → room.name or room metadata に変わるため、ルーム名/metadata に room_id を埋める設計が要る。
6. 配信保証なし前提の冪等性維持:Webhook は abandon されうる。現状の find_or_initialize_by(archive_id:) upsert 設計はそのまま維持し、重要遷移は ListEgress 等 Server API で照合する。
④ペイロードと配信挙動
デコード後の Webhook ペイロード(全イベントで WebhookReceiver の署名 JWT / sha256 検証を通過)と、配信リトライ・録画失敗・metadata 伝播など実装の前提とすべき挙動をまとめる。
ペイロードの要点:
・track_published.participant は最小限(name/metadata 空・state:JOINING)→ ロール判定不可。identity で participant_joined と突合。
・participant_joined.metadata にロール(例 {"role":"panelist"})+ permission{}。
・異常/正常切断とも participant に disconnectReason(SIGNAL_CLOSE 等)。
・egress_ended.egressInfo.fileResults[] に location/size/duration(ns)。S3 鍵はマスク({access_key})。
・キーは camelCase、bigint(timestamp/size/duration)は文字列。
participant_joined — フル participant(metadata にロール / permission / disconnectReason)
{
"event": "participant_joined",
"room": { "sid": "RM_YeJKzbEAkipq", "name": "precall-iutonw", "emptyTimeout": 300, "creationTimeMs": "1781858998888", ... },
"participant": {
"sid": "PA_PT5dLYBkkwcY",
"identity": "precall-PanelistA-1781858998702",
"state": "ACTIVE",
"metadata": "{\"role\":\"panelist\"}",
"joinedAt": "1781858999",
"name": "PanelistA",
"permission": { "canSubscribe": true, "canPublish": true, "canPublishData": true, "hidden": false, "recorder": false, ... },
"isPublisher": false,
"kind": "STANDARD",
"disconnectReason": "UNKNOWN_REASON",
"joinedAtMs": "1781858999591",
"clientProtocol": 1
},
"id": "EV_ayv9wM5dR9c2",
"createdAt": "1781858999",
"numDropped": 0
}
participant_connection_aborted — 異常切断。disconnectReason: "SIGNAL_CLOSE" を保持
{
"event": "participant_connection_aborted",
"room": { "sid": "RM_YeJKzbEAkipq", "name": "precall-iutonw", ... },
"participant": {
"sid": "PA_sHV67RSAPDT9",
"identity": "precall-PanelistA-1781858998702",
"state": "DISCONNECTED",
"metadata": "{\"role\":\"panelist\"}",
"name": "PanelistA",
"disconnectReason": "SIGNAL_CLOSE",
"joinedAtMs": "1781858998891",
"clientProtocol": 0
},
"id": "EV_fkpuADPrA5pM",
"createdAt": "1781858999",
"numDropped": 0
}
track_published — participant は最小限(name/metadata 空・state JOINING)、track は詳細
{
"event": "track_published",
"room": { "sid": "RM_YeJKzbEAkipq", "name": "precall-iutonw", "creationTimeMs": "0", ... },
"participant": {
"sid": "PA_qUncgdaCnuBa",
"identity": "precall-PanelistA-1781858998702",
"state": "JOINING",
"metadata": "",
"name": "",
"disconnectReason": "UNKNOWN_REASON",
"clientProtocol": 0
},
"id": "EV_topsrMTG3tus",
"createdAt": "1781859009",
"track": {
"sid": "TR_VCABzqazLAm8q7",
"type": "VIDEO", "width": 1280, "height": 720, "simulcast": true,
"source": "CAMERA", "mimeType": "video/VP8", "stream": "camera",
"layers": [ { "quality": "LOW", "width": 320, "height": 180, "bitrate": 160000 },
{ "quality": "MEDIUM", "width": 640, "height": 360, "bitrate": 450000 },
{ "quality": "HIGH", "width": 1280, "height": 720, "bitrate": 1700000 } ]
},
"numDropped": 0
}
room_started / room_finished — room オブジェクト(emptyTimeout / enabledCodecs 等)。両者同形
{
"event": "room_started", // room_finished も同形
"room": {
"sid": "RM_YeJKzbEAkipq",
"name": "precall-iutonw",
"emptyTimeout": 300,
"maxParticipants": 0,
"creationTime": "1781858998",
"enabledCodecs": [ { "mime": "audio/opus" }, { "mime": "video/VP8" }, { "mime": "video/H264" }, ... ],
"numParticipants": 0,
"activeRecording": false,
"departureTimeout": 20,
"creationTimeMs": "1781858998888"
},
"id": "EV_ETuKvZiv2or3",
"createdAt": "1781858998",
"numDropped": 0
}
egress_started — 録画開始(EGRESS_STARTING)。S3 鍵はマスク
{
"event": "egress_started",
"id": "EV_YbFEADdLD8va",
"createdAt": "1781859185",
"egressInfo": {
"egressId": "EG_zwf4CH9Z9gNK",
"roomId": "RM_dh2ckfyMUnbS",
"status": "EGRESS_STARTING",
"roomComposite": {
"roomName": "interview-001",
"fileOutputs": [ { "filepath": "recordings/interview-001/recording.mp4",
"s3": { "accessKey": "{access_key}", "secret": "{secret}", "region": "us-east-1",
"bucket": "minedia-recordings", "forcePathStyle": true } } ]
},
"startedAt": "1781859185642186544",
"endedAt": "0",
"roomName": "interview-001",
"fileResults": [ { "filename": "recordings/interview-001/recording.mp4",
"size": "0", "location": "", "duration": "0" } ],
"sourceType": "EGRESS_SOURCE_TYPE_WEB"
},
"numDropped": 0
}
egress_updated — 録画更新(EGRESS_ACTIVE)
{
"event": "egress_updated",
"id": "EV_uoVBNwKqKLoP",
"createdAt": "1781859188",
"egressInfo": {
"egressId": "EG_zwf4CH9Z9gNK",
"status": "EGRESS_ACTIVE",
"file": { "filename": "recordings/interview-001/recording.mp4",
"startedAt": "1781859188629110212", "endedAt": "0", "size": "0", "location": "", "duration": "0" },
"startedAt": "1781859185670013335",
"updatedAt": "1781859188629117378",
"sourceType": "EGRESS_SOURCE_TYPE_WEB"
},
"numDropped": 0
}
egress_ended — 録画完了(EGRESS_COMPLETE)。fileResults[] に location/size/duration(ns)
{
"event": "egress_ended",
"id": "EV_fdtXcpxfMJMj",
"createdAt": "1781859223",
"egressInfo": {
"egressId": "EG_zwf4CH9Z9gNK",
"roomId": "RM_dh2ckfyMUnbS",
"status": "EGRESS_COMPLETE",
"startedAt": "1781859185670013335",
"endedAt": "1781859223243180214",
"roomName": "interview-001",
"fileResults": [ {
"filename": "recordings/interview-001/recording.mp4",
"startedAt": "1781859188629110212",
"endedAt": "1781859223149610381",
"size": "6089393",
"location": "https://s3.example.com/minedia-recordings/recordings/interview-001/recording.mp4",
"duration": "34520500169"
} ],
"details": "End reason: Source closed",
"manifestLocation": "https://s3.example.com/minedia-recordings/recordings/interview-001/EG_zwf4CH9Z9gNK.json",
"sourceType": "EGRESS_SOURCE_TYPE_WEB"
},
"numDropped": 0
}
※ 紙幅のため room/codecs 等の繰り返しフィールドは一部省略。
実装の前提とすべき配信・ペイロード挙動
| 観点 | 挙動 |
|---|---|
| リトライ / 重複 / abandon | 同一 id で指数バックオフ再送、計5回で abandon → id を冪等キーに |
| イベント順序 | 正常系では発火順(room → participant → track)の相対順が保たれる |
| egress 失敗 | EGRESS_FAILED に error/errorCode/details が入る |
| metadata 伝播 | room.metadata は room/participant 系のみ、track_* には乗らない |
| track の participant | track_published は name/metadata 空 → identity で突合 |
| 署名検証失敗 | 不正 / 欠落 / 改ざんいずれも HTTP 401 で拒否 |
| ingress | 本リファレンス対象外(minedia-www で未使用) |
配信リトライ・冪等性
受信側が 5xx を返したときの再送挙動(同一イベントが受信側に複数回届く)。
| 配信 | 直前からの間隔 | event.id | numDropped |
|---|---|---|---|
| 1(初回) | — | 同一 id | 0 |
| 2 | +1s | 同一 id | 0 |
| 3 | +2s | 同一 id | 0 |
| 4 | +4s | 同一 id | 0 |
| 5 | +8s | 同一 id | 0 |
| 以降 途絶 | — | abandon | — |
全配信が同一 id(createdAt も同一) → id を冪等キー(dedup)に使える。間隔は 1→2→4→8 秒の指数バックオフで計 5 回配信後に abandon(総計 ~15 秒)。numDropped はリトライでは 0 のまま(厳密な意味は proto 定義で要確認・推測しない)。
移行示唆:webhook 受信は処理済み id を記録して冪等化すれば再送・重複に安全。リトライ上限は数回・短時間なので、録画完了等の確実性が要る遷移は ListEgress 等 Server API 照合を併用。
録画(egress)失敗時ペイロード
アップロード失敗等で egress が失敗した場合の egress_ended。遷移は EGRESS_STARTING → EGRESS_ACTIVE → EGRESS_ENDING → EGRESS_FAILED。
egress_ended — EGRESS_FAILED。location 空・size 0 だが duration は残る
{
"event": "egress_ended",
"egressInfo": {
"status": "EGRESS_FAILED",
"error": "S3 upload failed: operation error S3: CreateMultipartUpload, https response error StatusCode: 404, ... api error NoSuchBucket: The specified bucket does not exist",
"errorCode": 400,
"details": "End reason: Source closed",
"startedAt": "1781965426029452214",
"endedAt": "1781965474678756167",
"fileResults": [
{
"filename": "recordings/interview-001/recording.mp4",
"location": "",
"size": "0",
"duration": "45640236702"
}
]
}
}
移行示唆:OpenTok の Archive failed 代替は egress_ended && status==EGRESS_FAILED を契機に error/errorCode を OtArchive に保存。失敗時は location 空・size 0 のため、録画完了処理(URL保存・文字起こし起点)は EGRESS_COMPLETE のみに限定し FAILED と明確に分岐する。
metadata の伝播(room_id 紐付け)
| イベント | room.metadata(CreateRoom で設定時) |
|---|---|
room_started | {"room_id":12345,"slot_id":678} ✅ |
participant_joined | {"room_id":12345,"slot_id":678} ✅ |
participant_left | {"room_id":12345,"slot_id":678} ✅ |
track_published / track_unpublished | ''(空)— 公式記載どおり乗らない |
前提:room を入室時の auto-create に任せると room.metadata は空になる。metadata が乗るのは CreateRoom / UpdateRoomMetadata(サーバー API)で明示設定した場合のみ。
設計指針:minedia-www 側は room を auto-create 任せにせず、明示的に createRoom(metadata: {room_id: ...}) を呼ぶ実装を設ける。room_id 紐付けは room_started/participant_* では room.metadata、track_*/egress_* では room.name/egressInfo.roomName を併用するのが堅い。
track_published の participant(公式記載との差異)
track_published/track_unpublished の participant は name=''・metadata=''・state:JOINING。公式記載「room/participant は SID・name・identity を含む」に反し name は空。track_published.participant.identity == participant_joined.participant.identity は一致するため、突合キーは identity。出席記録のロール判定は track_published 不可、participant_joined 一択。
署名検証失敗時の HTTP ステータス
受信ハンドラは WebhookReceiver.receive を try/catch し、検証失敗時は 401 を返す。401 を返すと前述のリトライ対象になりうる点に留意。