Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

Webhook

最終更新 Markdown で表示Agent セットアップ

Webhook を使うと、バックエンドは RealtimeKit のイベントを発生時点で受け取れます。RealtimeKit は、購読しているイベント(ミーティング開始、参加者の入室、録画のアップロードなど)が起きると、設定したエンドポイントへ JSON ペイロード付きの HTTP POST リクエストを送ります。

非同期イベントに依存するバックエンド処理(ミーティング後の処理開始、トランスクリプトのダウンロード、録画状態の追跡、独自のセッション記録の更新など)に Webhook を使います。

Webhook の仕組み

  1. バックエンドに POST リクエストを受け取れる HTTP エンドポイントを作成します。
  2. RealtimeKit の Webhooks API にエンドポイント URL を登録します。
  3. Webhook を発火させるイベント種別を選びます。
  4. rtk-signature ヘッダーで受信リクエストを検証します。
  5. イベントを受け付けたあと、2xx レスポンスを返します。

Webhook イベントは購読方式です。エンドポイントが受け取るのは、Webhook の events 配列に含まれるイベントだけです。

Webhook エンドポイントを作成する

Webhook エンドポイントは JSON の POST リクエストを受け付ける必要があります。リクエストボディの event フィールドで分岐すれば、複数のイベント種別を同じエンドポイントで処理できます。

src/index.jsjs
async function handleEvent(event) {
	switch (event.event) {
		case "meeting.participantJoined":
			// Update attendance records.
			break;
		case "recording.statusUpdate":
			// Track recording state changes.
			break;
		default:
			console.log(`Unhandled RealtimeKit event: ${event.event}`);
	}
}

export default {
	async fetch(request, _env, ctx) {
		const url = new URL(request.url);

		if (request.method !== "POST" || url.pathname !== "/webhook") {
			return new Response("Not found", { status: 404 });
		}

		const event = await request.json();
		ctx.waitUntil(handleEvent(event));

		return new Response(null, { status: 200 });
	},
};
src/index.tsts
type RealtimeKitWebhookEvent = {
	event: string;
};

async function handleEvent(event: RealtimeKitWebhookEvent): Promise<void> {
	switch (event.event) {
		case "meeting.participantJoined":
			// Update attendance records.
			break;
		case "recording.statusUpdate":
			// Track recording state changes.
			break;
		default:
			console.log(`Unhandled RealtimeKit event: ${event.event}`);
	}
}

export default {
	async fetch(request, _env, ctx): Promise<Response> {
		const url = new URL(request.url);

		if (request.method !== "POST" || url.pathname !== "/webhook") {
			return new Response("Not found", { status: 404 });
		}

		const event = await request.json<RealtimeKitWebhookEvent>();
		ctx.waitUntil(handleEvent(event));

		return new Response(null, { status: 200 });
	},
} satisfies ExportedHandler;

エンドポイントは、イベントを受け付けたらすぐに 2xx レスポンスを返してください。ファイルのダウンロードやサードパーティ API の呼び出しなど、時間がかかる処理はバックグラウンドジョブへ移します。

Webhook を登録する

公開アクセス可能なエンドポイント URL を、RealtimeKit の Webhooks API で登録します。

curl --request POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/realtime/kit/$APP_ID/webhooks" \
  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Production webhook",
    "url": "https://example.com/webhook",
    "events": [
      "meeting.started",
      "meeting.ended",
      "meeting.participantJoined",
      "meeting.participantLeft",
      "recording.statusUpdate"
    ],
    "enabled": true
  }'

RealtimeKit ダッシュボード からも Webhook を管理できます。

Webhook ヘッダー

RealtimeKit は、配信の識別、重複排除、検証に使えるヘッダーを付けます。

ヘッダー 説明
rtk-signature リクエストボディの Base64 エンコードされた RSA-SHA256 署名です。RealtimeKit からのリクエストであることを検証するために使います。
rtk-uuid Webhook 配信の一意な ID です。重複配信の処理を避ける必要がある場合は、この値を保存します。
rtk-webhook-id 配信をトリガーした Webhook 設定の ID です。

Webhook 署名を検証する

RealtimeKit は各 Webhook のリクエストボディを RSA-SHA256 で署名します。イベントを処理する前に署名を検証してください。

公開鍵を取得する

RealtimeKit の Webhook 公開鍵は、次の URL から取得します。

curl "https://api.realtime.cloudflare.com/.well-known/webhooks.json"

レスポンスには PEM 形式の公開鍵が含まれます。

{
	"success": true,
	"data": {
		"publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
	},
	"message": ""
}

リクエストボディを検証する

生のリクエストボディに対して rtk-signature を検証します。検証前にパース済み JSON を再シリアライズしないでください。空白やキーの順序が変わると、署名対象のバイト列が変わります。

src/index.jsjs
async function verifySignature(publicKeyPem, signature, body) {
	const publicKey = await crypto.subtle.importKey(
		"spki",
		Uint8Array.from(atob(publicKeyPem), (c) => c.charCodeAt(0)),
		{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
		false,
		["verify"],
	);

	return crypto.subtle.verify(
		"RSASSA-PKCS1-v1_5",
		publicKey,
		Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)),
		body,
	);
}

async function handleEvent(event) {
	// Process the event.
}

export default {
	async fetch(request, env, ctx) {
		const signature = request.headers.get("rtk-signature");

		if (!signature) {
			return new Response("Missing signature", {
				status: 400,
			});
		}

		const body = await request.arrayBuffer();

		const resp = await fetch(env.REALTIMEKIT_WEBHOOK_PUBLIC_KEY_URL);
		if (!resp.ok) {
			return new Response("Missing public key", {
				status: 400,
			});
		}

		const respBody = await resp.json();

		const cleanPem = respBody.data.publicKey
			.replace(/\\n/g, "")
			.replace(/-----BEGIN PUBLIC KEY-----/, "")
			.replace(/-----END PUBLIC KEY-----/, "")
			.replace(/\s+/g, "");

		const verified = await verifySignature(cleanPem, signature, body);

		if (!verified) {
			return new Response("Invalid signature", { status: 401 });
		}

		const event = JSON.parse(new TextDecoder().decode(body));

		ctx.waitUntil(handleEvent(event));

		return new Response(null, { status: 200 });
	},
};
src/index.tsts
type Env = {
	REALTIMEKIT_WEBHOOK_PUBLIC_KEY_URL: string;
};

type RealtimeKitWebhookEvent = {
	event: string;
};

async function verifySignature(
	publicKeyPem: string,
	signature: string,
	body: ArrayBuffer,
): Promise<boolean> {
	const publicKey = await crypto.subtle.importKey(
		"spki",
		Uint8Array.from(atob(publicKeyPem), (c) => c.charCodeAt(0)),
		{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
		false,
		["verify"],
	);

	return crypto.subtle.verify(
		"RSASSA-PKCS1-v1_5",
		publicKey,
		Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)),
		body,
	);
}

async function handleEvent(event: RealtimeKitWebhookEvent): Promise<void> {
	// Process the event.
}

export default {
	async fetch(
		request: Request,
		env: Env,
		ctx: ExecutionContext,
	): Promise<Response> {
		const signature = request.headers.get("rtk-signature");

		if (!signature) {
			return new Response("Missing signature", {
				status: 400,
			});
		}

		const body = await request.arrayBuffer();

		const resp = await fetch(env.REALTIMEKIT_WEBHOOK_PUBLIC_KEY_URL);
		if (!resp.ok) {
			return new Response("Missing public key", {
				status: 400,
			});
		}

		const respBody = await resp.json<{
			success: true;
			data: { publicKey: string };
		}>();

		const cleanPem = respBody.data.publicKey
			.replace(/\\n/g, "")
			.replace(/-----BEGIN PUBLIC KEY-----/, "")
			.replace(/-----END PUBLIC KEY-----/, "")
			.replace(/\s+/g, "");

		const verified = await verifySignature(cleanPem, signature, body);

		if (!verified) {
			return new Response("Invalid signature", { status: 401 });
		}

		const event = JSON.parse(new TextDecoder().decode(body));

		ctx.waitUntil(handleEvent(event));

		return new Response(null, { status: 200 });
	},
} satisfies ExportedHandler<Env>;

リトライの動作

RealtimeKit は、2xx レスポンスを成功した配信として扱います。

エンドポイントが 5xx を返す場合、またはネットワークエラーでリクエストが失敗した場合、RealtimeKit は配信を再試行します。500 未満の非 2xx レスポンスの場合、RealtimeKit は配信を失敗として記録し、再試行しません。

配信の失敗が続くと、RealtimeKit はその Webhook URL への配信試行を一時的に減らすことがあります。アプリケーションがイベントを受け付けたあとでのみ、2xx を返してください。

対応イベント

RealtimeKit は次の Webhook イベントに対応しています。

イベント トリガー
meeting.started 最初の参加者がミーティングに入室したとき。
meeting.ended ホストが終了した、または全員が退出したためにミーティングが終了したとき。
meeting.participantJoined 参加者がミーティングに入室したとき。
meeting.participantLeft 参加者がミーティングから退出したとき。
meeting.chatSynced 終了したミーティングのチャット書き出しが利用可能になったとき。
recording.statusUpdate 録画の状態が変わったとき。
livestreaming.statusUpdate ライブ配信の状態が変わったとき。
meeting.transcript 終了したミーティングのトランスクリプトが利用可能になったとき。
meeting.summary 終了したミーティングの AI 生成サマリーが利用可能になったとき。

Webhooks API で現在のイベント一覧を取得できます。

curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/realtime/kit/$APP_ID/webhooks/all" \
  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

イベントペイロード

すべての Webhook ペイロードには event フィールドがあります。残りのフィールドはイベント種別によって異なります。

meeting.started

{
	"event": "meeting.started",
	"meeting": {
		"id": "bbb8940e-1b97-402a-97d6-2708b7feca41",
		"title": "Weekly sync",
		"status": "LIVE",
		"createdAt": "2026-06-03T10:00:00.000Z",
		"sessionId": "05e57591-d89e-45c9-ae44-08dc1eaad0e0",
		"startedAt": "2026-06-03T10:00:00.000Z",
		"organizedBy": {
			"id": "c94c437b-592a-4a39-b9e2-47ef1451e43b",
			"name": "Example organization"
		}
	}
}

meeting.ended

{
	"event": "meeting.ended",
	"meeting": {
		"id": "bbb8940e-1b97-402a-97d6-2708b7feca41",
		"sessionId": "05e57591-d89e-45c9-ae44-08dc1eaad0e0",
		"title": "Weekly sync",
		"status": "LIVE",
		"createdAt": "2026-06-03T10:00:00.000Z",
		"startedAt": "2026-06-03T10:00:00.000Z",
		"endedAt": "2026-06-03T10:30:00.000Z",
		"organizedBy": {
			"id": "c94c437b-592a-4a39-b9e2-47ef1451e43b",
			"name": "Example organization"
		}
	},
	"reason": "ALL_PARTICIPANTS_LEFT"
}

reason の値は HOST_ENDED_MEETING または ALL_PARTICIPANTS_LEFT です。

meeting.participantJoined

{
	"event": "meeting.participantJoined",
	"meeting": {
		"id": "bbb8940e-1b97-402a-97d6-2708b7feca41",
		"sessionId": "05e57591-d89e-45c9-ae44-08dc1eaad0e0",
		"title": "Weekly sync",
		"status": "LIVE",
		"createdAt": "2026-06-03T10:00:00.000Z",
		"startedAt": "2026-06-03T10:00:00.000Z",
		"organizedBy": {
			"id": "c94c437b-592a-4a39-b9e2-47ef1451e43b",
			"name": "Example organization"
		}
	},
	"participant": {
		"peerId": "e32fb785-ddd0-4b96-b577-879327c0082f",
		"userDisplayName": "Mary Sue",
		"customParticipantId": "user-123",
		"joinedAt": "2026-06-03T10:05:00.000Z"
	}
}

customParticipantId は、独自の参加者識別子として使います。古い連携との互換性のため、clientSpecificId も含まれます。

meeting.participantLeft

{
	"event": "meeting.participantLeft",
	"meeting": {
		"id": "bbb8940e-1b97-402a-97d6-2708b7feca41",
		"title": "Weekly sync",
		"status": "LIVE",
		"createdAt": "2026-06-03T10:00:00.000Z",
		"sessionId": "05e57591-d89e-45c9-ae44-08dc1eaad0e0",
		"startedAt": "2026-06-03T10:00:00.000Z",
		"endedAt": "2026-06-03T10:30:00.000Z",
		"organizedBy": {
			"id": "c94c437b-592a-4a39-b9e2-47ef1451e43b",
			"name": "Example organization"
		}
	},
	"participant": {
		"peerId": "e32fb785-ddd0-4b96-b577-879327c0082f",
		"userDisplayName": "Mary Sue",
		"customParticipantId": "user-123",
		"joinedAt": "2026-06-03T10:05:00.000Z",
		"leftAt": "2026-06-03T10:25:00.000Z"
	}
}

meeting.chatSynced

{
	"event": "meeting.chatSynced",
	"title": "Weekly sync",
	"endedAt": "2026-06-03T10:30:00.000Z",
	"createdAt": "2026-06-03T10:00:00.000Z",
	"meetingId": "bbb8940e-1b97-402a-97d6-2708b7feca41",
	"sessionId": "05e57591-d89e-45c9-ae44-08dc1eaad0e0",
	"startedAt": "2026-06-03T10:00:00.000Z",
	"chatDownloadUrl": "https://example.com/chat.json",
	"chatDownloadUrlExpiry": "2026-06-10T10:30:00.000Z",
	"organizedBy": {
		"id": "c94c437b-592a-4a39-b9e2-47ef1451e43b",
		"name": "Example organization"
	}
}

recording.statusUpdate

RealtimeKit は、録画がライフサイクルを進むときに recording.statusUpdate を送ります。録画の状態には RECORDINGUPLOADINGUPLOADEDERRORED があります。詳細は 録画ステータスの監視 を参照してください。

{
	"event": "recording.statusUpdate",
	"recording": {
		"id": "97cb480d-5840-4528-ace3-919b5e386c68",
		"recordingId": "97cb480d-5840-4528-ace3-919b5e386c68",
		"status": "UPLOADED",
		"downloadUrl": "https://example.com/recording.mp4",
		"audioDownloadUrl": "https://example.com/recording.mp3",
		"downloadUrlExpiry": "2026-06-10T10:30:00.000Z",
		"startedTime": "2026-06-03T10:00:00.000Z",
		"stoppedTime": "2026-06-03T10:30:00.000Z",
		"fileSize": "2044680",
		"outputFileName": "weekly-sync.mp4",
		"meetingId": "50c8940e-1b97-402a-97d6-2708b7feca41",
		"recordingDuration": 1800,
		"organizationId": "c94c437b-592a-4a39-b9e2-47ef1451e43b",
		"roomUUID": "05e57591-d89e-45c9-ae44-08dc1eaad0e0"
	},
	"meeting": {
		"id": "bbb8940e-1b97-402a-97d6-2708b7feca41",
		"sessionId": "05e57591-d89e-45c9-ae44-08dc1eaad0e0",
		"title": "Weekly sync",
		"status": "LIVE",
		"createdAt": "2026-06-03T10:00:00.000Z",
		"startedAt": "2026-06-03T10:00:00.000Z",
		"endedAt": "2026-06-03T10:30:00.000Z",
		"organizedBy": {
			"id": "c94c437b-592a-4a39-b9e2-47ef1451e43b",
			"name": "Example organization"
		}
	}
}

livestreaming.statusUpdate

ライブ配信の状態には LIVEOFFLINEIDLE があります。

{
	"event": "livestreaming.statusUpdate",
	"streamId": "d231d346-c422-43a6-a324-c0d65b79c8a7",
	"status": "LIVE",
	"manualIngest": false,
	"playbackUrl": "https://example.com/live.m3u8",
	"ingestServer": "rtmps://example.com/live",
	"streamKey": "stream-key",
	"meeting": {
		"id": "bbb8940e-1b97-402a-97d6-2708b7feca41",
		"title": "Weekly sync",
		"createdAt": "2026-06-03T10:00:00.000Z",
		"status": "LIVE",
		"organizedBy": {
			"id": "c94c437b-592a-4a39-b9e2-47ef1451e43b",
			"name": "Example organization"
		}
	}
}

meeting.transcript

{
	"event": "meeting.transcript",
	"meeting": {
		"id": "bbb8940e-1b97-402a-97d6-2708b7feca41",
		"title": "Weekly sync",
		"endedAt": "2026-06-03T10:30:00.000Z",
		"createdAt": "2026-06-03T10:00:00.000Z",
		"sessionId": "05e57591-d89e-45c9-ae44-08dc1eaad0e0",
		"startedAt": "2026-06-03T10:00:00.000Z",
		"status": "LIVE",
		"organizedBy": {
			"id": "c94c437b-592a-4a39-b9e2-47ef1451e43b",
			"name": "Example organization"
		}
	},
	"transcriptDownloadUrl": "https://example.com/transcript.csv",
	"transcriptDownloadUrlExpiry": "2026-06-10T10:30:00.000Z"
}

meeting.summary

{
	"event": "meeting.summary",
	"meeting": {
		"id": "bbb8940e-1b97-402a-97d6-2708b7feca41",
		"sessionId": "05e57591-d89e-45c9-ae44-08dc1eaad0e0",
		"organizedBy": {
			"id": "c94c437b-592a-4a39-b9e2-47ef1451e43b",
			"name": "Example organization"
		}
	},
	"summaryDownloadUrl": "https://example.com/summary.txt",
	"summaryDownloadUrlExpiry": "2026-06-10T10:30:00.000Z"
}

役に立ちましたか?