---
description: Receive RealtimeKit events in your application through signed HTTP callbacks.
title: Webhooks
image: https://developers.cloudflare.com/og-docs.png
---

[Skip to content](#main-content)

> Documentation Index  
> Fetch the complete documentation index at: https://developers.cloudflare.com/realtime/llms.txt  
> Use this file to discover all available pages before exploring further.

# Webhooks

Last updated Jun 12, 2026|Copy as Markdown|[View as Markdown](https://developers.cloudflare.com/realtime/realtimekit/webhooks/index.md)|[Agent setup](https://developers.cloudflare.com/agent-setup/)

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](https://developers.cloudflare.com/api/resources/realtime%5Fkit/subresources/webhooks/).
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.

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

```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;
```

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](https://developers.cloudflare.com/api/resources/realtime%5Fkit/subresources/webhooks/):

```bash
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 ↗](https://dash.cloudflare.com/?to=/:account/realtime/kit).

## 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:

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

The response includes a PEM-encoded public key:

```json
{
	"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.

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

```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>;
```

## 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:

```bash
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`

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

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

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

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

```json
{
	"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](https://developers.cloudflare.com/realtime/realtimekit/recording-guide/monitor-status/).

```json
{
	"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`.

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

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

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

Was this helpful?

YesNo

## On this page

[![](https://developers.cloudflare.com/_astro/logo.te5VL_aD.svg)Docs](https://developers.cloudflare.com/)

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/realtime/realtimekit/webhooks/#page","headline":"Webhooks · Cloudflare Realtime docs","description":"Receive RealtimeKit events in your application through signed HTTP callbacks.","url":"https://developers.cloudflare.com/realtime/realtimekit/webhooks/","inLanguage":"en","image":"https://developers.cloudflare.com/og-docs.png","dateModified":"2026-06-12","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
```
