跳转到内容
搜索文档

Webhooks

最后更新 查看 MarkdownAgent 设置

Webhooks 让您的后端能够实时接收 RealtimeKit 事件。当订阅的事件发生时(例如会议开始、参与者加入或录制文件上传),RealtimeKit 会向您配置的端点发送带有 JSON 负载的 HTTP POST 请求。

在依赖异步事件的后端工作流中使用 webhooks,例如开始会后处理、下载转录文本、跟踪录制状态或更新您自己的会话记录。

Webhooks 工作原理

  1. 在您的后端创建一个可以接收 POST 请求的 HTTP 端点。
  2. 将端点 URL 注册到 RealtimeKit Webhooks API
  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":
			// 更新考勤记录。
			break;
		case "recording.statusUpdate":
			// 跟踪录制状态变化。
			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":
			// 更新考勤记录。
			break;
		case "recording.statusUpdate":
			// 跟踪录制状态变化。
			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

使用 RealtimeKit Webhooks API 注册公开可访问的端点 URL:

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 仪表板管理 webhooks。

Webhook 请求头

RealtimeKit 包含请求头,可帮助您识别、去重和验证 webhook 递送:

请求头 描述
rtk-signature 请求体的 Base64 编码 RSA-SHA256 签名。使用此请求头来验证请求是否来自 RealtimeKit。
rtk-uuid webhook 递送的唯一 ID。如果需要避免处理重复递送,请存储此值。
rtk-webhook-id 触发此递送的 webhook 配置的 ID。

验证 webhook 签名

RealtimeKit 使用 RSA-SHA256 对每个 webhook 请求体进行签名。在处理事件之前验证签名。

获取公钥

从以下地址获取 RealtimeKit webhook 公钥:

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) {
	// 处理事件。
}

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> {
	// 处理事件。
}

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_MEETINGALL_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"
}

这篇文档对您有帮助吗?