> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.meetstream.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.meetstream.ai/_mcp/server.

# MeetStream Guide: Zoom Meeting Direct Connect (RTMS)

Record and transcribe a Zoom meeting **without a bot joining it**. Meeting Direct Connect uses Zoom's Realtime Media Streams (RTMS): Zoom streams the meeting's audio, video, screen share, transcript and participant events to MeetStream through your own Zoom app. No "notetaker" tile appears in the meeting, and there is no waiting room or recording-permission prompt.

A Direct Connect session is a normal MeetStream bot with `platform: "ZoomRTMS"`. Every `/api/v1/bots/{bot_id}/...` endpoint you already use (status, audio, video, transcripts, participants, speaker timeline) works with its ID, and you receive the same signed webhooks.

---

## How it works

1. A meeting starts and RTMS starts for your Zoom app. The host can start it, or your Zoom account can start it automatically.
2. Zoom sends a `meeting.rtms_started` webhook.
3. MeetStream connects to the stream. There are two ways this happens:
   - **Option A: you forward the webhook.** Your server receives Zoom's webhook, signs it, and calls `POST /api/v1/meeting_direct_connect`. Your Zoom client secret never leaves your server.
   - **Option B: MeetStream receives the webhook.** You register your Zoom app with MeetStream once and point the app's Event Subscription at MeetStream. You don't need your own server.
4. MeetStream records until Zoom stops the stream, you call `leave`, or the maximum session length is reached. The usual post-call processing then runs, and you get `bot.done`.

---

## Prerequisites

- A **Zoom General app** with RTMS enabled, and these event subscriptions:
  - `meeting.rtms_started`
  - `meeting.rtms_stopped`
- RTMS scopes on the app for the media you want, for example `meeting:read:meeting_audio`, `meeting:read:meeting_video` and `meeting:read:meeting_transcript`.
- RTMS turned on for the meeting. Either the host starts it, or an account setting starts it automatically. Starting RTMS needs Zoom's Developer Pack on the app owner's Zoom account.
- Your MeetStream API key.

---

## Option A: Forward Zoom's webhook

### Endpoint

```
POST https://api.meetstream.ai/api/v1/meeting_direct_connect
```

| Parameter | Location | Required | Description |
|-----------|----------|----------|-------------|
| `Authorization` | Header | Yes | `Token <YOUR_API_KEY>` |
| `zoom_rtms` | Body | Yes | The stream details from Zoom's `meeting.rtms_started` webhook, plus your signature (see below) |

`zoom_rtms` fields:

| Field | Description |
|-------|-------------|
| `meeting_uuid` | `payload.meeting_uuid` from Zoom's webhook |
| `rtms_stream_id` | `payload.rtms_stream_id` from Zoom's webhook |
| `server_urls` | `payload.server_urls` from Zoom's webhook (a string or an array). Each URL must be a `wss://` URL on `zoom.us` or `zoomgov.com` |
| `signature` | `HMAC-SHA256(client_secret, "client_id,meeting_uuid,rtms_stream_id")` as 64 hex characters, using your Zoom app's client ID and secret |

### Optional fields

| Field | Description |
|-------|-------------|
| `bot_name` | A label for the session in your dashboard. It is not shown in the meeting |
| `callback_url` | Where to send status webhooks for this session |
| `custom_attributes` | Your own key/value data, returned with the session |
| `recording_config.transcript.provider` | Post-call transcription: `deepgram`, `assemblyai`, `jigsawstack`, `sarvam` or `meetstream`. Use `meeting_captions` for Zoom's own transcript |
| `live_transcription_required.webhook_url` | Live transcript delivery. Requires `meeting_captions` |
| `video_required` | `true` records the active-speaker video and screen shares |
| `video_separate_streams` | `true` splits the active-speaker video into a file per participant. Requires `video_required` |
| `audio_separate_streams` | `true` records separate audio for each participant |
| `audio_separate_mp3` | `true` adds MP3 copies of the per-participant audio. Requires `audio_separate_streams` |
| `live_audio_required.websocket_url` | Live audio over a WebSocket (`ws://` or `wss://`) |
| `live_video_required.websocket_url` | Live video over a WebSocket (`ws://` or `wss://`) |
| `automatic_leave.in_call_recording_timeout` | Maximum session length in seconds: 600 to 18000. The default is 14400 (4 hours) |

These fields are **not accepted**, because no bot joins the meeting: `meeting_link`, `join_at`, `socket_connection_url`, agents, `video_layout`, `bot_message`, `bot_image_url` and streaming transcription providers.

### Example request

```bash
curl -X POST "https://api.meetstream.ai/api/v1/meeting_direct_connect" \
  -H "Authorization: Token <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "zoom_rtms": {
      "meeting_uuid": "4444AAAiAAAAAiAiAiiAii==",
      "rtms_stream_id": "609340fc1e0b4f1b9e0a1d2c3b4a5f60",
      "server_urls": "wss://example.zoom.us",
      "signature": "<64 hex characters>"
    },
    "callback_url": "https://your-app.example.com/meetstream",
    "recording_config": {
      "transcript": {"provider": {"meeting_captions": {}}}
    }
  }'
```

### Response (201)

```json
{
  "id": "6f0c2a1e-...",
  "bot_id": "6f0c2a1e-...",
  "platform": "ZoomRTMS",
  "status": {"code": "joining", "bot_status": "Joining"},
  "zoom_rtms": {
    "meeting_uuid": "4444AAAiAAAAAiAiAiiAii==",
    "rtms_stream_id": "609340fc1e0b4f1b9e0a1d2c3b4a5f60"
  },
  "transcript_id": "...",
  "created_at": "2026-11-09T10:00:00Z"
}
```

`id` and `bot_id` are the same value. Use it with any `/api/v1/bots/{bot_id}/...` endpoint.

### Example: forwarding server (Python, Flask)

```python
import hashlib
import hmac
import os

import requests
from flask import Flask, request

app = Flask(__name__)
CLIENT_ID = os.environ["ZOOM_CLIENT_ID"]
CLIENT_SECRET = os.environ["ZOOM_CLIENT_SECRET"]


@app.post("/zoom/webhook")
def zoom_webhook():
    event = request.get_json()
    # Also verify Zoom's x-zm-signature header and answer endpoint.url_validation (see Zoom's docs).
    if event.get("event") != "meeting.rtms_started":
        return "", 200
    p = event["payload"]
    message = f"{CLIENT_ID},{p['meeting_uuid']},{p['rtms_stream_id']}"
    signature = hmac.new(CLIENT_SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()
    requests.post(
        "https://api.meetstream.ai/api/v1/meeting_direct_connect",
        headers={"Authorization": f"Token {os.environ['MEETSTREAM_API_KEY']}"},
        json={
            "zoom_rtms": {
                "meeting_uuid": p["meeting_uuid"],
                "rtms_stream_id": p["rtms_stream_id"],
                "server_urls": p["server_urls"],
                "signature": signature,
            },
            "recording_config": {"transcript": {"provider": {"meeting_captions": {}}}},
        },
        timeout=5,
    )
    return "", 200
```

### Example: forwarding server (Node.js, Express)

```js
import crypto from "node:crypto";
import express from "express";

const app = express().use(express.json());
const { ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET, MEETSTREAM_API_KEY } = process.env;

app.post("/zoom/webhook", async (req, res) => {
  // Also verify Zoom's x-zm-signature header and answer endpoint.url_validation (see Zoom's docs).
  if (req.body.event !== "meeting.rtms_started") return res.sendStatus(200);
  const { meeting_uuid, rtms_stream_id, server_urls } = req.body.payload;
  const signature = crypto
    .createHmac("sha256", ZOOM_CLIENT_SECRET)
    .update(`${ZOOM_CLIENT_ID},${meeting_uuid},${rtms_stream_id}`)
    .digest("hex");
  await fetch("https://api.meetstream.ai/api/v1/meeting_direct_connect", {
    method: "POST",
    headers: { Authorization: `Token ${MEETSTREAM_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      zoom_rtms: { meeting_uuid, rtms_stream_id, server_urls, signature },
      recording_config: { transcript: { provider: { meeting_captions: {} } } },
    }),
  });
  res.sendStatus(200);
});

app.listen(8080);
```

Answer Zoom within 3 seconds. If Zoom doesn't get a quick `200`, it retries the webhook. MeetStream rejects a duplicate for a stream that is already active with `409`, so a retry never starts a second session.

---

## Option B: Let MeetStream receive Zoom's webhook

Register your Zoom app once. This is the same app registry used for Zoom OAuth bots (see the Zoom OAuth Meeting Bots guide). Add an `rtms` block with the defaults for every session the app starts:

```bash
curl -X POST "https://api.meetstream.ai/api/v1/zoom/oauth/apps" \
  -H "Authorization: Token <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "<ZOOM_CLIENT_ID>",
    "client_secret": "<ZOOM_CLIENT_SECRET>",
    "webhook_secret": "<ZOOM_SECRET_TOKEN>",
    "label": "Acme RTMS",
    "kind": "user_level",
    "rtms": {
      "enabled": true,
      "video_required": true,
      "callback_url": "https://your-app.example.com/meetstream",
      "recording_config": {"transcript": {"provider": {"meeting_captions": {}}}}
    }
  }'
```

The response includes `zoom_oauth_app_id` and `webhook_url`. In your Zoom app, set the Event Subscription URL to that `webhook_url` and subscribe to `meeting.rtms_started` and `meeting.rtms_stopped`.

From then on, MeetStream checks Zoom's signature on each webhook and starts a session for every `meeting.rtms_started`. Each session's `custom_attributes` include `zoom_oauth_app_id`, `zoom_meeting_id` and `zoom_operator_id`, so you can match it to your meeting.

- Change the defaults: `PATCH /api/v1/zoom/oauth/apps/{zoom_oauth_app_id}` with a new `rtms` block.
- Stop starting sessions for the app: `PATCH` with `{"rtms": {"enabled": false}}`.

The `rtms` block accepts `enabled`, `bot_name`, `callback_url`, `custom_attributes`, `recording_config`, `live_transcription_required`, `live_audio_required`, `live_video_required`, `video_required`, `video_separate_streams`, `audio_separate_streams`, `audio_separate_mp3` and `automatic_leave`. `callback_url` must be an `https://` URL.

---

## Get a session

```
GET https://api.meetstream.ai/api/v1/meeting_direct_connect/{id}
```

Returns the session's status, its status history, and its recordings with download links:

```json
{
  "id": "6f0c2a1e-...",
  "bot_id": "6f0c2a1e-...",
  "platform": "ZoomRTMS",
  "status": {"code": "done", "bot_status": "Done", "updated_at": "..."},
  "status_changes": [{"code": "joining", "bot_status": "Joining", "created_at": "..."}],
  "recordings": [{
    "id": "6f0c2a1e-...",
    "started_at": "...",
    "completed_at": "...",
    "media_shortcuts": {
      "audio_mixed": {"status": {"code": "done"}, "data": {"download_url": "https://...", "format": "wav"}},
      "transcript": {"status": {"code": "done"}, "data": {"download_url": "https://...", "provider": "meeting_captions"}},
      "participant_events": {"status": {"code": "done"}, "data": {"download_url": "https://..."}},
      "video_mixed": {"status": {"code": "done"}, "data": {"download_url": "https://...", "format": "mp4"}}
    }
  }]
}
```

`video_mixed` appears only when the session was started with `video_required: true`. It shows `processing` until the video is ready.

| `status.code` | Meaning |
|---------------|---------|
| `joining` | Connecting to Zoom's stream |
| `in_meeting` | Receiving media |
| `leaving` | The stream is ending |
| `stopped` | The stream ended |
| `processing` | Post-call processing is running |
| `done` | Recordings and transcripts are ready |
| `fatal` | The session failed |

To list all sessions, call `GET /api/v1/bots?platform=ZoomRTMS`.

---

## End a session early

```
POST https://api.meetstream.ai/api/v1/meeting_direct_connect/{id}/leave
```

This stops recording and starts post-call processing. It works the same as `POST /api/v1/bots/{bot_id}/remove_bot`. Calling it on a session that has already finished returns `200` and keeps its final status.

A session also ends when the host stops RTMS, when the meeting ends, or when `in_call_recording_timeout` is reached.

---

## Webhooks

Sessions send the same signed webhooks as bots (`bot.inmeeting`, `bot.recording`, `bot.leaving`, `bot.stopped`, `bot.done` and failure events) to your `callback_url`. `participant_events.join` and `participant_events.leave` are available as real-time events. Verify them the same way as other MeetStream webhooks (see the Webhook Signature Verification guide).

---

## Limitations

- **Receive-only.** Nothing can be sent into the meeting: no chat messages, images, audio playback, raise hand or MIA agents. `send_message`, `send_image`, `send_command`, `pause_recording` and `resume_recording` return `400` with `code: "unsupported_on_zoom_rtms"`.
- **No chat capture yet.** `get_chats` returns an empty list.
- **Participant events are join and leave only.** Microphone, camera, host and email details are not available.
- **Video is the active speaker.** Zoom streams one camera at a time, so `video_separate_streams` gives each participant's video only while they are the active speaker.
- **Per-participant audio** covers up to 3 people speaking at the same time, a Zoom limit.
- **Live transcription** uses Zoom's own transcript (`meeting_captions`). Streaming transcription providers are not supported.
- **No breakout rooms.** RTMS streams the main meeting only.
- **The host stays in control.** A Zoom admin can turn off RTMS during a meeting, and the session then ends.
- **Start-up time.** A session can take up to about a minute to start receiving media.
- **US execution region only.**

---

## Error Responses (`POST /api/v1/meeting_direct_connect`)

| Status | Meaning |
|--------|---------|
| `400` | Invalid request: a missing `zoom_rtms` field, a `server_urls` host outside Zoom, a malformed signature, or an unsupported option. The `error` field says which. |
| `401` | Missing or invalid API key. |
| `403` | Meeting Direct Connect is not enabled for your account, or your balance is too low. |
| `409` | A session for this `rtms_stream_id` is already active. The body includes the existing session's `id`. After a session ends, the same stream ID can be used again, for example to retry with a corrected signature. |
| `422` | Your account runs in a region other than the US. |
| `503` | The session could not be started. Retry. |

If the signature is wrong, the request is still accepted (`201`), but the session moves to `fatal` when Zoom rejects the connection. Check `GET /api/v1/meeting_direct_connect/{id}` or the failure webhook.