# Load environment variables from .env > This quickstart is for meetings, but can easily be repurposed for webinars. Just replace the meeting scopes and events with their webinar equivalent. For example, replace `meeting.rtms_started` with `webinar.rtms_started`. In this guide, you'll build a server that uses WebSockets to get Zoom meeting transcripts from Realtime Media Streams (RTMS). [Skip to the bottom](#full-code) if you want the full code. You can also find it on GitHub, [Node.js](https://github.com/zoom/rtms-samples/tree/main/transcript/print_incoming_transcripts_js) and [Python](https://github.com/zoom/rtms-samples/tree/main/transcript/print_incoming_transcripts_python). The server will: 1. Listen for incoming webhook events `meeting.rtms_started` and `meeting.rtms_stopped` 2. Generate a signature for handshake requests 3. Connect to the WebSocket endpoint for the meeting 4. Receive meeting transcripts in real time ## Step 1: Create project First, create a new Node.js project and install [express](https://expressjs.com/), [dotenv](https://www.npmjs.com/package/dotenv), and [ws](https://www.npmjs.com/package/ws) as dependencies. ```shell npm init -y npm install express dotenv ws ``` Create a new file named `index.js` and add the following code to it: ```javascript import express from "express"; import dotenv from "dotenv"; import WebSocket from "ws"; // Load environment variables from .env dotenv.config(); const app = express(); // Enable JSON body parsing app.use(express.json()); // Basic root route for testing app.get("/", (req, res) => { res.send("Zoom RTMS Server is up and running."); }); // Listen on localhost:3000 const PORT = 3000; app.listen(PORT, () => { console.log(`Server is listening on http://localhost:${PORT}`); }); ``` First, create a new Python project and install [FastAPI](https://fastapi.tiangolo.com/), [uvicorn](https://www.uvicorn.org/), [python-dotenv](https://pypi.org/project/python-dotenv/), and [websockets](https://websockets.readthedocs.io/en/stable/) as dependencies. Create a `requirements.txt` file and add the following. ```plaintext fastapi uvicorn python-dotenv websockets ``` Install the dependencies ```shell pip install -r requirements.txt ``` Create a new file named `main.py` and add the following code to it. ```python from fastapi import FastAPI, Request import os from dotenv import load_dotenv import asyncio import websockets import json import hmac import hashlib import uvicorn # Load environment variables from .env load_dotenv() app = FastAPI() # Basic root route for testing @app.get('/') async def home(): return 'Zoom RTMS Server is up and running.' # Listen on localhost:3000 if __name__ == '__main__': PORT = 3000 print(f"Server is listening on http://localhost:{PORT}") uvicorn.run(app, host='localhost', port=PORT) ``` ## Step 2: Create an RTMS client on a local tunnel Expose this app on a local server over HTTPS. For this quickstart, we'll use [ngrok](https://ngrok.com/downloads/). You can also use [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/), [localtunnel](https://www.npmjs.com/package/localtunnel), or [other alternatives](https://github.com/anderspitman/awesome-tunneling). 1. [Install ngrok and setup your account](https://ngrok.com/docs/getting-started/). 2. Open a new terminal window and run the command to open an HTTPS forwarding URL at port `3000`. This outputs a public URL we'll use to receive [`meeting.rtms_started`](/docs/rtms/event-reference/) webhook events. ```shell ngrok http 3000 ``` ## Step 3: Write the code Now we'll write the code to handle transcript data over a WebSocket connection. ### Build the webhook receiver When a RTMS session starts or stops, your app will receive a webhook event with the following payloads. When a stream starts: [`meeting.rtms_started`](/docs/api/rtms/events/#tag/meeting/POSTmeeting.rtms_started) **meeting.rtms_started sample** ```json { "event": "meeting.rtms_started", "event_ts": 1626230691572, "payload": { "meeting_uuid": "xxxxxxxxxx", "meeting_id": "xxxxxxxxxx", "account_id": "xxxxxxxxxx", // user account ID "operator_id": "xxxxxxxxxx", "is_original_host": true, "rtms_stream_id": "xxxxxxxxxx", "server_urls": "wss://127.0.0.1:443" } } ``` When a stream stops: [`meeting.rtms_stopped`](/docs/api/rtms/events/#tag/meeting/POSTmeeting.rtms_stopped) **meeting.rtms_stopped sample** ```json { "event": "meeting.rtms_stopped", "event_ts": 1732313171881, "payload": { "meeting_uuid": "xxxxxxxxxx", "rtms_stream_id": "xxxxxxxxxxc", "stop_reason": 6 } } ``` To connect to the stream, our app needs the `meeting_uuid`, `rtms_stream_id`, and `server_urls` from the payload. To handle these webhook events, we'll build a simple webhook receiver in and create a `/webhook` route to receive the POST requests from our event subscriptions. Add the following code to `index.js`. ```javascript app.use(express.json()); app.post("/webhook", (req, res) => { const { event, payload } = req.body; console.log("Webhook received:", event); console.log("Payload:", JSON.stringify(payload, null, 2)); res.sendStatus(200); }); ``` Next we will handle the `meeting.rtms_started` and `meeting.rtms_stopped` events. When we receive the `meeting.rtms_started` event, we extract the meeting details to open a signaling WebSocket connection to start the RTMS handshake. ```javascript // Handle RTMS start event if (event === "meeting.rtms_started") { const { meeting_uuid, rtms_stream_id, server_urls } = payload; console.log(`Starting RTMS for meeting ${meeting_uuid}`); // Connect to signaling WebSocket to establish RTMS connection connectToSignalingWebSocket(meeting_uuid, rtms_stream_id, server_urls); } ``` ```javascript // Handle RTMS stop event if (event === "meeting.rtms_stopped") { const { meeting_uuid } = payload; console.log(`Stopping RTMS for meeting ${meeting_uuid}`); } ``` Put together, the code for the webhook receiver looks like this: ```javascript app.post("/webhook", (req, res) => { const { event, payload } = req.body; // Handle RTMS start event if (event === "meeting.rtms_started") { const { meeting_uuid, rtms_stream_id, server_urls } = payload; console.log(`Starting RTMS for meeting ${meeting_uuid}`); // Connect to signaling WebSocket to establish RTMS connection connectToSignalingWebSocket(meeting_uuid, rtms_stream_id, server_urls); // Handle RTMS stop event } else if (event === "meeting.rtms_stopped") { const { meeting_uuid } = payload; console.log(`Stopping RTMS for meeting ${meeting_uuid}`); } else { console.log("Unknown event:", event); } res.sendStatus(200); }); ``` Add the following code to `main.py`. ```python @app.post('/webhook') async def webhook(request: Request): data = await request.json() event = data.get('event') payload = data.get('payload', {}) print(f'Webhook received: {event}') print(f'Payload: {json.dumps(payload, indent=2)}') return {'status': 'ok'} ``` Next we will handle the `meeting.rtms_started` and `meeting.rtms_stopped` events. When we receive the `meeting.rtms_started` event, we extract the meeting details to open a signaling WebSocket connection to start the RTMS handshake. ```python # Handle RTMS start event if event == 'meeting.rtms_started': meeting_uuid = payload.get('meeting_uuid') rtms_stream_id = payload.get('rtms_stream_id') server_urls = payload.get('server_urls') print(f"Starting RTMS for meeting {meeting_uuid}") # Connect to signaling WebSocket to establish RTMS connection asyncio.create_task( connect_to_signaling_websocket(meeting_uuid, rtms_stream_id, server_urls) ) ``` ```python if event == 'meeting.rtms_stopped': meeting_uuid = payload.get('meeting_uuid') print(f"Stopping RTMS for meeting {meeting_uuid}") ``` Put together, the code for the webhook receiver looks like this: ```python @app.post('/webhook') async def webhook(request: Request): data = await request.json() event = data.get('event') payload = data.get('payload', {}) # Handle RTMS start event if event == 'meeting.rtms_started': meeting_uuid = payload.get('meeting_uuid') rtms_stream_id = payload.get('rtms_stream_id') server_urls = payload.get('server_urls') print(f"Starting RTMS for meeting {meeting_uuid}") asyncio.create_task( connect_to_signaling_websocket(meeting_uuid, rtms_stream_id, server_urls) ) # Handle RTMS stop event elif event == 'meeting.rtms_stopped': meeting_uuid = payload.get('meeting_uuid') print(f"Stopping RTMS for meeting {meeting_uuid}") return {'status': 'ok'} ``` ### Create the signature generator Next, we will create a function to generate the signature for the signaling WebSocket connection using HMAC SHA256. This will be used to authenticate the handshake request to the signaling server. Add the following code to `index.js`. ```javascript import crypto from "crypto"; function generateSignature(meetingUuid, rtmsStreamId) { const clientID = process.env.ZOOM_CLIENT_ID; const clientSecret = process.env.ZOOM_CLIENT_SECRET; const message = `${clientID},${meetingUuid},${rtmsStreamId}`; const signature = crypto .createHmac("sha256", clientSecret) .update(message) .digest("hex"); console.log(`Generated signature: ${signature}`); return signature; } ``` Add the following code to `main.py`. ```python def generate_signature(meeting_uuid, rtms_stream_id): message = f"{os.getenv('ZOOM_CLIENT_ID')},{meeting_uuid},{rtms_stream_id}" signature = hmac.new( os.getenv('ZOOM_CLIENT_SECRET').encode(), message.encode(), hashlib.sha256 ).hexdigest() print(f'Generated signature: {signature}') return signature ``` This helper returns the computed signature string. ### Connect to the signaling server with WebSockets Next, we'll use the signature inside a `connectToSignalingWebSocket()` function to establish the signaling connection. The [signaling handshake request](/docs/rtms/event-reference/#signaling-handshake-request) passes in the `meeting_uuid` and `rtms_stream_id` from the [`meeting.rtms_started`](/docs/rtms/event-reference/) event and includes fields like `msg_type`, `protocol_version` and `sequence` as required. Add the following code to `index.js`. ```javascript function connectToSignalingWebSocket(meetingUuid, rtmsStreamId, serverUrls) { const signalingWs = new WebSocket(serverUrls); signalingWs.on("open", () => { console.log(`Signaling WebSocket opened for meeting ${meetingUuid}`); const signature = generateSignature(meetingUuid, rtmsStreamId); const handshakeMsg = { msg_type: 1, // SIGNALING_HAND_SHAKE_REQ meeting_uuid: meetingUuid, rtms_stream_id: rtmsStreamId, signature, }; console.log("Sending handshake message:", handshakeMsg); signalingWs.send(JSON.stringify(handshakeMsg)); }); signalingWs.on("error", (error) => { console.error("Signaling WebSocket error:", error); }); signalingWs.on("close", (code, reason) => { console.log("Signaling WebSocket closed:", code, reason); }); } ``` Add the following code to `main.py`. ```python def connect_to_signaling_websocket(meeting_uuid, rtms_stream_id, server_urls): def on_open(ws): print(f'Signaling WebSocket opened for meeting {meeting_uuid}') signature = generate_signature(meeting_uuid, rtms_stream_id) handshake_msg = { 'msg_type': 1, # SIGNALING_HAND_SHAKE_REQ 'meeting_uuid': meeting_uuid, 'rtms_stream_id': rtms_stream_id, 'signature': signature } print(f'Sending handshake message: {handshake_msg}') ws.send(json.dumps(handshake_msg)) signaling_ws = websocket.WebSocketApp(server_urls, on_open=on_open) # Start WebSocket in a separate thread threading.Thread(target=signaling_ws.run_forever).start() ``` This function sends the signature and required handshake fields to the signaling server to authorize the connection. #### Handling keep-alive requests When the signaling WebSocket connection is active, the RTMS server periodically sends [keep-alive messages](/docs/rtms/event-reference/#keep-alive-request) to check if the client is still connected. The client needs to respond promptly with a [keep-alive response message](/docs/rtms/event-reference/#keep-alive-response), including the timestamp received in the request, to maintain the WebSocket connection. Add this if-statement: Add the following code to `index.js`. ```javascript if (msg.msg_type === 12) { // KEEP_ALIVE_REQ console.log("Received KEEP_ALIVE_REQ, responding with KEEP_ALIVE_RESP"); signalingWs.send( JSON.stringify({ msg_type: 13, // KEEP_ALIVE_RESP timestamp: msg.timestamp, }), ); } ``` Add the following code to `main.py`. ```python if msg.get('msg_type') == 12: # KEEP_ALIVE_REQ print('Received KEEP_ALIVE_REQ, responding with KEEP_ALIVE_ACK') await signaling_ws.send(json.dumps({ 'msg_type': 13, # KEEP_ALIVE_ACK 'timestamp': msg.get('timestamp') })) ``` ### Connect to the media server with a WebSocket When the signaling handshake is successful, the RTMS signaling server sends a [handshake response](/docs/rtms/event-reference/#signaling-handshake-response) with media server URLs in `media_server.server_urls`: **Signaling handshake response sample** ```json { "msg_type": 2, "protocol_version": 1, "sequence": 0, "status_code": 0, "reason": "", "media_server": { "server_urls": { "audio": "wss://...", "video": "wss://...", "transcript": "wss://...", "all": "wss://..." } } } ``` Next, our app will need to open one of the media URLs to open a WebSocket connection to receive media data. In this example, we will request the transcript stream, which uses `media_type: 8`. When the Media WebSocket connection opens, we build and send a [handshake request to the media server](/docs/rtms/event-reference/#media-handshake-request) with our meeting details and signature: Add the following code to `index.js`. ```javascript function connectToMediaWebSocket( mediaUrl, meetingUuid, rtmsStreamId, signalingSocket, ) { // Open the media WebSocket connection using the URL from the handshake response const mediaWs = new WebSocket(mediaUrl); mediaWs.on("open", () => { // Build the media handshake for transcripts only const handshakeMsg = { msg_type: 3, // DATA_HAND_SHAKE_REQ protocol_version: 1, sequence: 0, meeting_uuid: meetingUuid, rtms_stream_id: rtmsStreamId, signature: generateSignature(meetingUuid, rtmsStreamId), media_type: 8, // Request only transcripts (TRANSCRIPT enum) }; console.log("Sending transcript handshake:", handshakeMsg); mediaWs.send(JSON.stringify(handshakeMsg)); }); // Listen for incoming transcript data packets mediaWs.on("message", (data) => { console.log("Received transcript data:", data); }); } ``` Add the following code to `main.py`. ```python async def connect_to_media_websocket(media_url, meeting_uuid, stream_id, signaling_socket): async with websockets.connect(media_url) as media_ws: # Build the media handshake for transcripts only handshake_msg = { 'msg_type': 3, # DATA_HAND_SHAKE_REQ 'protocol_version': 1, 'sequence': 0, 'meeting_uuid': meeting_uuid, 'rtms_stream_id': stream_id, 'signature': generate_signature(meeting_uuid, stream_id), 'media_type': 8 # Request only transcripts (TRANSCRIPT enum) } print('Sending transcript handshake:', handshake_msg) await media_ws.send(json.dumps(handshake_msg)) # Listen for incoming transcript data packets async for message in media_ws: print('Received transcript data:', message) ``` After a successful handshake to the media server, the RTMS server responds with a [media handshake response](/docs/rtms/event-reference/#media-handshake-response): **Client ready acknowledgement (ACK) sample** ```json { "msg_type": 4, "protocol_version": 1, "status_code": 0, "reason": "", "sequence": 0, "payload_encrypted": true, "media_params": { "transcript": { "content_type": 5 } } } ``` #### Send the client ready acknowledgement (ACK) To verify our app is ready to receive media, we send a client ready ACK message back to the signaling WebSocket. This tells the RTMS server our client is ready to receive a stream on the media server: Add the following code to `index.js`. ```javascript // If handshake response is OK, send CLIENT_READY_ACK on signaling socket if (msg.msg_type === 4 && msg.status_code === 0) { console.log( "Media handshake successful, sending CLIENT_READY_ACK via signaling socket", ); signalingSocket.send( JSON.stringify({ msg_type: 7, // CLIENT_READY_ACK rtms_stream_id: rtmsStreamId, }), ); } ``` Add the following code to `main.py`. ```python # If handshake response is OK, send CLIENT_READY_ACK on signaling socket if msg.get('msg_type') == 4 and msg.get('status_code') == 0: print('Media handshake successful, sending CLIENT_READY_ACK via signaling socket') await signaling_socket.send(json.dumps({ 'msg_type': 7, # CLIENT_READY_ACK 'rtms_stream_id': rtms_stream_id })) ``` #### Receive transcript data Once the `CLIENT_READY_ACK` is sent, the RTMS server will begin streaming the actual media data, in our case transcripts, through the media WebSocket. Incoming media packets have different `msg_type` values depending on the type of media you requested in your `DATA_HAND_SHAKE_REQ`. For transcripts, each chunk arrives as a message with `msg_type 17`. When the media WebSocket is active and the stream has started, you need to handle incoming packets. Add the following code to `index.js`. ```javascript // When receiving a MEDIA_DATA_TRANSCRIPT message if (msg.msg_type === 17) { console.log("Received transcript:", msg.content); } ``` Add the following code to `main.py`. ```python # When receiving a MEDIA_DATA_TRANSCRIPT message if msg.get('msg_type') == 17: print('Received transcript:', msg.get('content')) ``` #### Send a keep-alive message to the media WebSocket Similar to the signaling connection, we also need to keep the media connection alive. We will use the same logic. Add the following code to `index.js`. ```javascript if (msg.msg_type === 12) { // KEEP_ALIVE_REQ console.log("Received KEEP_ALIVE_REQ, responding with KEEP_ALIVE_ACK"); mediaWs.send( JSON.stringify({ msg_type: 13, // KEEP_ALIVE_ACK timestamp: msg.timestamp, }), ); } ``` Add the following code to `main.py`. ```python if msg.get('msg_type') == 12: # KEEP_ALIVE_REQ print('Received KEEP_ALIVE_REQ, responding with KEEP_ALIVE_ACK') await media_ws.send(json.dumps({ 'msg_type': 13, # KEEP_ALIVE_ACK 'timestamp': msg.get('timestamp') })) ``` ## Step 4: Set up a Zoom app to use RTMS The next step is to set up an RTM-enabled app with the `meeting:read:meeting_audio` scope. For more information, see [Add Realtime Media Streams to your app ](/docs/rtms/meetings/add-features/). Now that you've created the app, we need to add the ngrok URL from [Step 2](/docs/rtms/meetings/quickstart-websockets/#step-2-create-an-rtms-client-on-a-local-tunnel) to your app and add your app to your account so we can test our implementation. ### To add your ngrok URL and add your app 1. Sign into the [Zoom App Marketplace](https://marketplace.zoom.us/). 2. To go to the app, in the upper-right of the screen, choose **Manage**. 3. Select your app from the list. 4. In the navigation pane, choose **Basic Information**. 5. In the **OAuth Information** section, for **OAuth Redirect URL**, add your ngrok URL. 6. In the navigation pane, choose **Local Test**. 7. In the **Add app** section, choose **Add app now**. 8. On the page that appears, choose **Allow**. The page will then redirect to a page that doesn't exist because we didn't set up OAuth on our ngrok URL. Now that the app is installed it's time to create a `.env` file. ## Step 5: Create .env file and add credentials Now that your app is created, we need to create our `.env` file and add the app's credentials so the server can communicate with the app. ### To create the .env file Run the command to create the `.env` file. ```shell touch .env ``` ### To add the credentials 1. Sign into the [Zoom App Marketplace](https://marketplace.zoom.us/). 2. To go to the app, in the upper-right of the screen, choose **Manage**. 3. Select your app from the list. 4. Open the `.env` file in your project and paste the following, replacing <Client ID> and <Client Secret> with the values from your app. ```plaintext ZOOM_CLIENT_ID = ZOOM_CLIENT_SECRET = ``` 5. Save the file. Now you're ready to test the server and app combination. ## Step 6: Start the app and join a meeting Now that your app is installed, your account may need to give permission to apps to access meeting content. Navigate to your [account settings page](https://www.zoom.us/account/setting) (as an account admin) and enable apps to [share realtime meeting content with apps](/docs/rtms/meetings/ux-host-admin-tools-ctrls/#admin-web-portal-controls). For this quickstart, we want to show how a user can auto-start an RTMS session whenever they join a meeting. Because you've installed the app for your user, you'll now be able to set the app to auto-start in your Zoom settings. ### To set RTMS to auto-start 1. Open you [Zoom settings](https://zoom.us/profile/setting). 2. Choose the **Zoom Apps** tab. 3. In the **Auto-start apps that access shared realtime meeting content** section, choose **+ Choose an app to auto-start**. 4. From the dropdown, select your RTMS app, and choose **Save**. ### To test your app 1. Run the command to set up the server to listen to incoming webhook events anytime the app is launched in a meeting. ```shell npm run start ``` ```shell python main.py ``` 2. Log into the Zoom desktop client as the user who has installed the app. 3. Launch a Zoom Meeting. ## Full code Here's the final code for our server. ```javascript import express from "express"; import dotenv from "dotenv"; import WebSocket from "ws"; import crypto from "crypto"; // Load environment variables from .env dotenv.config(); const app = express(); // Enable JSON body parsing app.use(express.json()); // Basic root route for testing app.get("/", (req, res) => { res.send("Zoom RTMS Server is up and running."); }); // Generate signature for RTMS authentication function generateSignature(meetingUuid, rtmsStreamId) { const clientID = process.env.ZOOM_CLIENT_ID; const clientSecret = process.env.ZOOM_CLIENT_SECRET; const message = `${clientID},${meetingUuid},${rtmsStreamId}`; const signature = crypto .createHmac("sha256", clientSecret) .update(message) .digest("hex"); console.log(`Generated signature: ${signature}`); return signature; } // Connect to media WebSocket for receiving transcript data function connectToMediaWebSocket( mediaUrl, meetingUuid, rtmsStreamId, signalingSocket, ) { const mediaWs = new WebSocket(mediaUrl); mediaWs.on("open", () => { console.log("Media WebSocket connected"); const handshakeMsg = { msg_type: 3, // DATA_HAND_SHAKE_REQ protocol_version: 1, sequence: 0, meeting_uuid: meetingUuid, rtms_stream_id: rtmsStreamId, signature: generateSignature(meetingUuid, rtmsStreamId), media_type: 8, // TRANSCRIPT only }; console.log("Sending transcript handshake:", handshakeMsg); mediaWs.send(JSON.stringify(handshakeMsg)); }); mediaWs.on("message", (data) => { const msg = JSON.parse(data); // Handle media handshake response if (msg.msg_type === 4 && msg.status_code === 0) { console.log("Media handshake successful, sending CLIENT_READY_ACK"); signalingSocket.send( JSON.stringify({ msg_type: 7, // CLIENT_READY_ACK rtms_stream_id: rtmsStreamId, }), ); } // Handle transcript data else if (msg.msg_type === 17) { // MEDIA_DATA_TRANSCRIPT console.log( `Transcript from ${msg.content.user_name}: ${msg.content.data}`, ); } // Handle keep-alive else if (msg.msg_type === 12) { // KEEP_ALIVE_REQ console.log( "Received KEEP_ALIVE_REQ, responding with KEEP_ALIVE_ACK", ); mediaWs.send( JSON.stringify({ msg_type: 13, // KEEP_ALIVE_ACK timestamp: msg.timestamp, }), ); } }); mediaWs.on("error", (error) => { console.error("Media WebSocket error:", error); }); mediaWs.on("close", (code, reason) => { console.log("Media WebSocket closed:", code, reason); }); } // Connect to signaling WebSocket function connectToSignalingWebSocket(meetingUuid, rtmsStreamId, serverUrls) { const signalingWs = new WebSocket(serverUrls); signalingWs.on("open", () => { console.log(`Signaling WebSocket opened for meeting ${meetingUuid}`); const signature = generateSignature(meetingUuid, rtmsStreamId); const handshakeMsg = { msg_type: 1, // SIGNALING_HAND_SHAKE_REQ meeting_uuid: meetingUuid, rtms_stream_id: rtmsStreamId, signature, }; console.log("Sending handshake message:", handshakeMsg); signalingWs.send(JSON.stringify(handshakeMsg)); }); signalingWs.on("message", (data) => { const msg = JSON.parse(data); // Handle signaling handshake response if (msg.msg_type === 2 && msg.status_code === 0) { console.log("Signaling handshake successful"); const transcriptUrl = msg.media_server.server_urls.transcript; connectToMediaWebSocket( transcriptUrl, meetingUuid, rtmsStreamId, signalingWs, ); } // Handle keep-alive requests else if (msg.msg_type === 12) { // KEEP_ALIVE_REQ console.log( "Received KEEP_ALIVE_REQ, responding with KEEP_ALIVE_RESP", ); signalingWs.send( JSON.stringify({ msg_type: 13, // KEEP_ALIVE_RESP timestamp: msg.timestamp, }), ); } }); signalingWs.on("error", (error) => { console.error("Signaling WebSocket error:", error); }); signalingWs.on("close", (code, reason) => { console.log("Signaling WebSocket closed:", code, reason); }); } // Webhook endpoint to receive RTMS events app.post("/webhook", (req, res) => { const { event, payload } = req.body; // Handle RTMS start event if (event === "meeting.rtms_started") { const { meeting_uuid, rtms_stream_id, server_urls } = payload; console.log(`Starting RTMS for meeting ${meeting_uuid}`); // Connect to signaling WebSocket to establish RTMS connection connectToSignalingWebSocket(meeting_uuid, rtms_stream_id, server_urls); // Handle RTMS stop event } else if (event === "meeting.rtms_stopped") { const { meeting_uuid } = payload; console.log(`Stopping RTMS for meeting ${meeting_uuid}`); } else { console.log("Unknown event:", event); } res.sendStatus(200); }); // Listen on localhost:3000 const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server is listening on http://localhost:${PORT}`); }); ``` ```python import os import json import hmac import hashlib import asyncio import websockets import uvicorn import ssl from fastapi import FastAPI, Request from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() app = FastAPI() port = int(os.getenv("PORT", 3000)) CLIENT_ID = os.getenv("ZOOM_CLIENT_ID") CLIENT_SECRET = os.getenv("ZOOM_CLIENT_SECRET") WEBHOOK_PATH = os.getenv("WEBHOOK_PATH", "/webhook") # Dictionary to keep track of active WebSocket connections active_connections = {} def generate_signature(client_id, meeting_uuid, stream_id, client_secret): """Generate signature for authentication.""" print('Generating signature with parameters:') print('meetingUuid:', meeting_uuid) print('streamId:', stream_id) # Create a message string and generate an HMAC SHA256 signature message = f"{client_id},{meeting_uuid},{stream_id}" return hmac.new( client_secret.encode(), message.encode(), hashlib.sha256 ).hexdigest() async def connect_to_signaling_websocket(meeting_uuid, stream_id, server_url): """Connect to the signaling WebSocket server.""" print(f"Connecting to signaling WebSocket for meeting {meeting_uuid}") try: async with websockets.connect(server_url) as ws: # Store connection for cleanup later if meeting_uuid not in active_connections: active_connections[meeting_uuid] = {} active_connections[meeting_uuid]["signaling"] = ws print(f"Signaling WebSocket connection opened for meeting {meeting_uuid}") signature = generate_signature(CLIENT_ID, meeting_uuid, stream_id, CLIENT_SECRET) # Send handshake message handshake = { "msg_type": 1, # SIGNALING_HAND_SHAKE_REQ "protocol_version": 1, "meeting_uuid": meeting_uuid, "rtms_stream_id": stream_id, "sequence": int(asyncio.get_event_loop().time() * 1e9), "signature": signature } await ws.send(json.dumps(handshake)) print("Sent handshake to signaling server") while True: try: data = await ws.recv() msg = json.loads(data) print("Signaling Message:", json.dumps(msg, indent=2)) # Handle successful handshake response if msg["msg_type"] == 2 and msg["status_code"] == 0: # SIGNALING_HAND_SHAKE_RESP media_url = msg.get("media_server", {}).get("server_urls", {}).get("all") if media_url: # Connect to the media WebSocket server asyncio.create_task( connect_to_media_websocket(media_url, meeting_uuid, stream_id, ws) ) # Respond to keep-alive requests if msg["msg_type"] == 12: # KEEP_ALIVE_REQ keep_alive_response = { "msg_type": 13, # KEEP_ALIVE_RESP "timestamp": msg["timestamp"] } print("Responding to Signaling KEEP_ALIVE_REQ:", keep_alive_response) await ws.send(json.dumps(keep_alive_response)) except websockets.exceptions.ConnectionClosed: break except Exception as e: print(f"Error processing message: {e}") break except Exception as e: print(f"Signaling socket error: {e}") finally: print("Signaling socket closed") if meeting_uuid in active_connections: active_connections[meeting_uuid].pop("signaling", None) async def connect_to_media_websocket(media_url, meeting_uuid, stream_id, signaling_socket): """Connect to the media WebSocket server.""" print(f"Connecting to media WebSocket at {media_url}") ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE try: async with websockets.connect(media_url, ssl=ssl_context) as media_ws: # Store connection for cleanup later if meeting_uuid in active_connections: active_connections[meeting_uuid]["media"] = media_ws signature = generate_signature(CLIENT_ID, meeting_uuid, stream_id, CLIENT_SECRET) handshake = { "msg_type": 3, # DATA_HAND_SHAKE_REQ "protocol_version": 1, "meeting_uuid": meeting_uuid, "rtms_stream_id": stream_id, "signature": signature, "media_type": 8, # MEDIA_DATA_TRANSCRIPT "payload_encryption": False } await media_ws.send(json.dumps(handshake)) while True: try: data = await media_ws.recv() try: # Try to parse as JSON first msg = json.loads(data) print("Media JSON Message:", json.dumps(msg, indent=2)) # Handle successful media handshake if msg["msg_type"] == 4 and msg["status_code"] == 0: # DATA_HAND_SHAKE_RESP await signaling_socket.send(json.dumps({ "msg_type": 7, # CLIENT_READY_ACK "rtms_stream_id": stream_id })) print("Media handshake successful, sent start streaming request") # Respond to keep-alive requests if msg["msg_type"] == 12: # KEEP_ALIVE_REQ await media_ws.send(json.dumps({ "msg_type": 13, # KEEP_ALIVE_RESP "timestamp": msg["timestamp"] })) print("Responded to Media KEEP_ALIVE_REQ") except json.JSONDecodeError: # If JSON parsing fails, it's binary audio data print("Raw data (base64):", data.hex()) except websockets.exceptions.ConnectionClosed: break except Exception as e: print(f"Error processing message: {e}") break except Exception as e: print(f"Media socket error: {e}") finally: print("Media socket closed") if meeting_uuid in active_connections: active_connections[meeting_uuid].pop("media", None) @app.post(WEBHOOK_PATH) async def webhook(request: Request): """Handle webhook requests.""" body = await request.json() print("RTMS Webhook received:", json.dumps(body, indent=2)) event = body.get("event") payload = body.get("payload", {}) # Handle RTMS started event if event == "meeting.rtms_started": print("RTMS Started event received") meeting_uuid = payload.get("meeting_uuid") rtms_stream_id = payload.get("rtms_stream_id") server_urls = payload.get("server_urls") if all([meeting_uuid, rtms_stream_id, server_urls]): asyncio.create_task( connect_to_signaling_websocket(meeting_uuid, rtms_stream_id, server_urls) ) # Handle RTMS stopped event if event == "meeting.rtms_stopped": print("RTMS Stopped event received") meeting_uuid = payload.get("meeting_uuid") if meeting_uuid in active_connections: connections = active_connections[meeting_uuid] for conn in connections.values(): if conn and hasattr(conn, "close"): await conn.close() active_connections.pop(meeting_uuid, None) return {"status": "ok"} if __name__ == "__main__": print(f"Server running at http://localhost:{port}") print(f"Webhook endpoint available at http://localhost:{port}{WEBHOOK_PATH}") uvicorn.run(app, host="0.0.0.0", port=port) ``` ### Start the app to receive transcript data You'll now be able to receive transcript data from the Zoom meeting. Install your Zoom app and [start a stream](/docs/rtms/meetings/work-with-streams/#step-1-rtms-is-started) in a meeting. You'll start seeing meeting transcripts in your console.