INTEGRITY Документация

Webhooks

Вебхуки позволяют вашему бэкенду получать события RealtimeKit в реальном времени. RealtimeKit отправляет HTTP POST запрос на настроенный вами endpoint с JSON payload при возникновении события, на которое вы подписаны, например когда встреча начинается, участник присоединяется или запись загружается.

Используйте вебхуки для серверных процессов, которые зависят от асинхронных событий, например для запуска обработки после встречи, скачивания транскриптов, отслеживания статуса записи или обновления собственных записей сессий.

Как работают вебхуки

  1. Создайте HTTP-эндпойнт в вашем бэкенде, который может принимать POST запросы.
  2. Зарегистрируйте URL эндпойнта в RealtimeKit Webhooks API.
  3. Выберите типы событий, которые должны запускать вебхук.
  4. Проверяйте входящие запросы с помощью rtk-signature заголовок.
  5. Возвращает 2xx ответ после принятия события.

События вебхука доступны только по подписке. Ваш эндпойнт получает только события, включённые в подписку events массив.

Создание эндпойнта вебхука

Ваш эндпойнт вебхука должен принимать JSON POST запросы. Endpoint может обрабатывать несколько типов событий, используя переключение по event поле в теле запроса.

src/index.js
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.ts
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, в фоновую задачу.

Зарегистрируйте вебхук

Зарегистрируйте общедоступный 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 Dashboard.

Заголовки вебхука

RealtimeKit добавляет заголовки, которые помогают идентифицировать, дедуплицировать и проверять доставку вебхуков:

Header Описание
rtk-signature Подпись RSA-SHA256 в кодировке Base64 для тела запроса. Используйте этот заголовок, чтобы убедиться, что запрос пришёл от RealtimeKit.
rtk-uuid Уникальный ID для доставки вебхука. Сохраните это значение, если хотите избежать повторной обработки дублирующихся доставок.
rtk-webhook-id ID конфигурации вебхука, инициировавшей доставку.

Проверяйте подписи вебхуков

RealtimeKit подписывает тело каждого запроса вебхука с помощью RSA-SHA256. Проверяйте подпись перед обработкой события.

Получение публичного ключа

Получите публичный ключ webhook RealtimeKit по адресу:

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.js
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.ts
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 повторяет доставку. Если ваш endpoint возвращает код, отличный от2xx ответ ниже 500, RealtimeKit фиксирует доставку как неудачную и не повторяет попытку.

После повторных сбоев доставки RealtimeKit может временно снизить частоту попыток доставки на этот URL вебхука. Верните 2xx ответ только после того, как ваше приложение приняло событие.

Поддерживаемые события

RealtimeKit поддерживает следующие события вебхуков:

Событие Триггер
meeting.started Первый участник присоединяется ко встрече.
meeting.ended Встреча завершается, потому что организатор её завершил или все участники покинули её.
meeting.participantJoined Участник присоединяется к встрече.
meeting.participantLeft Участник покидает встречу.
meeting.chatSynced Экспорт чата завершённой встречи доступен.
recording.statusUpdate Статус записи меняется.
livestreaming.statusUpdate Статус прямой трансляции меняется.
meeting.transcript Транскрипт завершённой встречи доступен.
meeting.summary Сформированная ИИ сводка завершённой встречи доступна.

Получите текущий список событий с помощью Webhooks API:

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

Полезная нагрузка событий

Все полезные нагрузки вебхуков включают 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 при переходе записи между этапами её жизненного цикла. Статусы записи включают RECORDING, UPLOADING, UPLOADED, а также ERRORED. Дополнительную информацию см. в Отслеживание статуса записи.

{
	"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

Статусы прямой трансляции включают LIVE, OFFLINE, а также IDLE.

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