v1
PoC / 契約素案

LiveKit Webhook 仕様 — OpenTok 移行リファレンス

サーバーサイドイベント通知(LiveKit → 自社バックエンドへの HTTP POST)を、minedia-www の現状 OpenTok 実装と対比して可視化する。

OpenTok→LiveKit 移行 一次ソース: LiveKit Docs / TokBox Docs 対象: minedia-www サーバーサイド連携

要点:実質的に対応が必要なのは 2 系統だけ

現状の minedia-www が 実処理 を持つ OpenTok コールバックは 2 つのみ。残りは noop で、移行後も noop のまま機能等価。

実装必要 streamCreatedparticipant_joined

パネリストの出席時刻Interview#attend_at)を記録。

OpenTok: streamCreated  →  LiveKit: participant_joined
  • track_published は participant が空(state:JOINING / name・metadata 空)でロール判定不可
  • participant_joined.metadata{"role":"panelist"} が入るので移行先として素直

実装必要 archiveegress_*

OtArchive upsert・録画通知・動画分析・自動文字起こしスケジュール。

OpenTok: archive(status)  →  LiveKit: egress_started / egress_updated / egress_ended
  • 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 同定キーが sessionIdroom.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最初の参加者が空ルームに joinroomsessionCreatedほぼ対応
room_finishedルーム終了(close / 最後の退出+empty timeout)roomsessionDestroyedOpenTok は reason 付き
participant_joinedメディア接続確立後、状態が active にroom, participantconnectionCreatedLiveKit は active 後。OpenTok は接続時点で active 分離なし
participant_left退出+クリーンアップ完了room, participantconnectionDestroyedOpenTok は reason を payload で表現
participant_connection_aborted接続が予期せず中断room, participantconnectionDestroyed に内包LiveKit は異常切断を別イベントに分離
track_publishedトラック公開room, participant, trackstreamCreatedOpenTok は stream.videoType を持つ
track_unpublishedトラック公開停止room, participant, trackstreamDestroyedOpenTok は reason 付き
egress_started録画/配信開始egressInfoArchive started + Broadcast startedLiveKit は同一 Webhook、OpenTok は別 URL
egress_updatedEgress 更新(サイズ変化等)egressInfo(直接対応なし)OpenTok は paused/streamAdded 等の離散ステータス
egress_endedEgress 終了egressInfoArchive stopped/uploaded/available/failedOpenTok は保存先で uploaded/available 区別
ingress_started / ingress_ended外部ストリーム取り込み 開始/終了ingressInfo(対応なし)OpenTok に Ingress 相当なし
(対応なし)sessionNotificationLiveKit にサーバーローテーション予告なし
(対応なし)Archive expiredLiveKit に「DL リンク失効」通知なし

設計差分の押さえどころ

集約 vs 分割

1 Webhook 集約 ⇔ 3 別 URL。移行時にエンドポイント統合を設計し直す。

切断理由の持ち方

OpenTok は reason。LiveKit は participant_connection_aborted に分離+ participant の disconnectReasonSIGNAL_CLOSE 等)で取得可。

録画ステータス粒度

OpenTok は 9 状態。LiveKit は 3 イベント+詳細は egressInfo.statusEGRESS_*)。

認証

LiveKit は署名付き JWT(必須)。OpenTok は Broadcast に任意 signature secret。

ユースケース解説 — minedia-www の現状実装と移行受け入れ条件

受信は単一 URL POST /opentok/callback/Opentok::CallbackController#indexparams[:event]case 分岐し、常に head :ok

🛡️

全イベント共通の受け入れ条件:authenticate_opentok!params[:sessionId]OtSession を検索。無ければ 403。未知 event は warn ログのみで 200。

イベント別 処理と受け入れ条件

現状 OpenTok event状態移行先 LiveKitminedia-www の処理受け入れ条件(ガード)
connectionCreatednoop(対応 Webhook なし)なし
connectionDestroyednoopparticipant_left / ..._abortedなし
streamCreated実装participant_joinedInterview#attend_at を記録下記 AND 条件
streamDestroyednooptrack_unpublishedなし
archive実装egress_started/updated/endedOtArchive 冪等 upsert → status 別後続処理@ot_session.lock!find_or_initialize_by(archive_id:)

streamCreated → 出席記録の受け入れ条件(すべて AND)

ot_session.room.present? interview_role == panelist gid→User / room_id→Room / answer_slot が全て解決 attend_at? が false(未設定のみ更新)

archive の status 別 後続処理

status処理受け入れ条件
started / paused / stoppedプライベートチャットに録画ステータス通知ot_session.room.present?
uploadedOtArchiveAnalysisJob(ffmpeg 尺算出)
② 自動文字起こしスケジュール
Job: status.uploaded?
文字起こし: schedule_auto_transcription?
available / expired / failedDB 保存のみ(通知・Job なし)notify_private_chat_of_status の else で noop

schedule_auto_transcription? = 次の AND:

room.present? status.uploaded? output_mode.composed? size &.positive?

文字起こしスケジュールは begin/rescue で囲まれ、失敗してもコールバックは 200 を返す。

移行時に注意すべき差分

「2 イベント対応」だが、土台とイベント分割の作り替えが効く。

⚠️

1. 出席記録の発火元が変わる:現状 streamCreated。LiveKit の track_published.participantname/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/ended3 別イベント。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 同定キーが sessionIdroom.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)→ ロール判定不可identityparticipant_joined と突合。

participant_joined.metadata にロール(例 {"role":"panelist"})+ permission{}

・異常/正常切断とも participant に disconnectReasonSIGNAL_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_FAILEDerror/errorCode/details が入る
metadata 伝播room.metadata は room/participant 系のみ、track_* には乗らない
track の participanttrack_publishedname/metadata 空 → identity で突合
署名検証失敗不正 / 欠落 / 改ざんいずれも HTTP 401 で拒否
ingress本リファレンス対象外(minedia-www で未使用)

配信リトライ・冪等性

受信側が 5xx を返したときの再送挙動(同一イベントが受信側に複数回届く)。

配信直前からの間隔event.idnumDropped
1(初回)同一 id0
2+1s同一 id0
3+2s同一 id0
4+4s同一 id0
5+8s同一 id0
以降 途絶abandon
🔑

全配信が同一 idcreatedAt も同一)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/errorCodeOtArchive に保存。失敗時は 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.metadatatrack_*/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 ステータス

Authorization 無し → 401 不正 JWT → 401 改ざん body → 401

受信ハンドラは WebhookReceiver.receive を try/catch し、検証失敗時は 401 を返す。401 を返すと前述のリトライ対象になりうる点に留意。