Skip to content

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

  1. Create an HTTP endpoint in your backend that can receive POST requests.
  2. Register the endpoint URL with the RealtimeKit Webhooks API.
  3. Choose the event types that should trigger the webhook.
  4. Verify incoming requests with the rtk-signature header.
  5. Return a 2xx response 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.

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 });
},
};

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:

Terminal window
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:

HeaderDescription
rtk-signatureBase64-encoded RSA-SHA256 signature for the request body. Use this header to verify that the request came from RealtimeKit.
rtk-uuidUnique ID for the webhook delivery. Store this value if you need to avoid processing duplicate deliveries.
rtk-webhook-idID 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:

Terminal window
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.

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 });
},
};

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:

EventTrigger
meeting.startedThe first participant joins a meeting.
meeting.endedThe meeting ends because the host ended it or all participants left.
meeting.participantJoinedA participant joins a meeting.
meeting.participantLeftA participant leaves a meeting.
meeting.chatSyncedThe chat export for a completed meeting is available.
recording.statusUpdateA recording changes status.
livestreaming.statusUpdateA livestream changes status.
meeting.transcriptThe transcript for a completed meeting is available.
meeting.summaryThe AI-generated summary for a completed meeting is available.

Fetch the current event list with the Webhooks API:

Terminal window
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"
}