---
title: Webhooks
description: Receive RealtimeKit events in your application through signed HTTP callbacks.
image: https://edgetunnel-b2h.pages.dev/dev-products-preview.png
---

> Documentation Index  
> Fetch the complete documentation index at: https://edgetunnel-b2h.pages.dev/realtime/llms.txt  
> Use this file to discover all available pages before exploring further. 

[Skip to content](#%5Ftop) 

# 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](https://edgetunnel-b2h.pages.dev/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.

* [  JavaScript ](#tab-panel-10821)
* [  TypeScript ](#tab-panel-10822)

**src/index.js**

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

```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://edgetunnel-b2h.pages.dev/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.

* [  JavaScript ](#tab-panel-10823)
* [  TypeScript ](#tab-panel-10824)

**src/index.js**

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

```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://edgetunnel-b2h.pages.dev/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"
}
```

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://edgetunnel-b2h.pages.dev/realtime/realtimekit/webhooks/#page","headline":"Webhooks · Cloudflare Realtime docs","description":"Receive RealtimeKit events in your application through signed HTTP callbacks.","url":"https://edgetunnel-b2h.pages.dev/realtime/realtimekit/webhooks/","inLanguage":"en","image":"https://edgetunnel-b2h.pages.dev/dev-products-preview.png","dateModified":"2026-06-12","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://edgetunnel-b2h.pages.dev/#website","name":"Cloudflare Docs","url":"https://edgetunnel-b2h.pages.dev/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/realtime/","name":"Realtime"}},{"@type":"ListItem","position":3,"item":{"@id":"/realtime/realtimekit/","name":"RealtimeKit"}},{"@type":"ListItem","position":4,"item":{"@id":"/realtime/realtimekit/webhooks/","name":"Webhooks"}}]}
```
