← Realtime / realtime / realtimekit
Webhooks
Webhooks let your backend receive RealtimeKit events as they happen. RealtimeKit sends an HTTP POST request to your configured endpoint with a JSON payload when a subscribed event occurs, such as when a meeting starts, a participant joins, or a recording is uploaded.
Use webhooks for backend workflows that depend on asynchronous events, such as starting post-meeting processing, downloading transcripts, tracking recording status, or updating your own session records.
How webhooks work
- Create an HTTP endpoint in your backend that can receive
POSTrequests. - Register the endpoint URL with the RealtimeKit Webhooks API.
- Choose the event types that should trigger the webhook.
- Verify incoming requests with the
rtk-signatureheader. - Return a
2xxresponse after accepting the event.
Webhook events are subscription-only. Your endpoint receives only the events included in the webhook's events array.
Create a webhook endpoint
Your webhook endpoint must accept JSON POST requests. The endpoint can handle multiple event types by switching on the event field in the request body.
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 });
},
};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;Your endpoint should return a 2xx response as soon as it accepts the event. Move slow work, such as downloading files or calling third-party APIs, to a background job.
Register a webhook
Register the publicly accessible endpoint URL using the 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
}'You can also manage webhooks from the RealtimeKit dashboard ↗.
Webhook headers
RealtimeKit includes headers that help you identify, deduplicate, and verify webhook deliveries:
| Header | Description |
|---|---|
rtk-signature |
Base64-encoded RSA-SHA256 signature for the request body. Use this header to verify that the request came from RealtimeKit. |
rtk-uuid |
Unique ID for the webhook delivery. Store this value if you need to avoid processing duplicate deliveries. |
rtk-webhook-id |
ID of the webhook configuration that triggered the delivery. |
Verify webhook signatures
RealtimeKit signs each webhook request body with RSA-SHA256. Verify the signature before processing the event.
Fetch the public key
Fetch the RealtimeKit webhook public key from:
curl "https://api.realtime.cloudflare.com/.well-known/webhooks.json"The response includes a PEM-encoded public key:
{
"success": true,
"data": {
"publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
},
"message": ""
}Verify the request body
Verify rtk-signature against the raw request body. Do not reserialize parsed JSON before verification because changes in whitespace or key order can change the signed bytes.
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 });
},
};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>;Retry behavior
RealtimeKit treats any 2xx response as a successful delivery.
If your endpoint returns a 5xx response or the request fails because of a network error, RealtimeKit retries the delivery. If your endpoint returns a non-2xx response below 500, RealtimeKit records the delivery as failed and does not retry it.
After repeated delivery failures, RealtimeKit may temporarily reduce delivery attempts to that webhook URL. Return a 2xx response only after your application has accepted the event.
Supported events
RealtimeKit supports these webhook events:
| Event | Trigger |
|---|---|
meeting.started |
The first participant joins a meeting. |
meeting.ended |
The meeting ends because the host ended it or all participants left. |
meeting.participantJoined |
A participant joins a meeting. |
meeting.participantLeft |
A participant leaves a meeting. |
meeting.chatSynced |
The chat export for a completed meeting is available. |
recording.statusUpdate |
A recording changes status. |
livestreaming.statusUpdate |
A livestream changes status. |
meeting.transcript |
The transcript for a completed meeting is available. |
meeting.summary |
The AI-generated summary for a completed meeting is available. |
Fetch the current event list with the 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 payloads
All webhook payloads include an event field. The remaining fields depend on the event type.
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"
}The reason value can be HOST_ENDED_MEETING or 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"
}
}Use customParticipantId for your own participant identifier. clientSpecificId is included for compatibility with older integrations.
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 sends recording.statusUpdate when a recording moves through its lifecycle. Recording statuses include RECORDING, UPLOADING, UPLOADED, and ERRORED. For more information, refer to Monitor Recording Status.
{
"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
Livestream statuses include LIVE, OFFLINE, and 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"
}