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_startedwithwebinar.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 if you want the full code. You can also find it on GitHub, Node.js and Python.
The server will:
- Listen for incoming webhook events
meeting.rtms_startedandmeeting.rtms_stopped - Generate a signature for handshake requests
- Connect to the WebSocket endpoint for the meeting
- Receive meeting transcripts in real time
Step 1: Create project
First, create a new Node.js project and install express, dotenv, and ws as dependencies.
npm init -y
npm install express dotenv ws
Create a new file named index.js and add the following code to it:
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, uvicorn, python-dotenv, and websockets as dependencies.
Create a requirements.txt file and add the following.
fastapi
uvicorn
python-dotenv
websockets
Install the dependencies
pip install -r requirements.txt
Create a new file named main.py and add the following code to it.
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. You can also use Cloudflare Tunnel, localtunnel, or other alternatives. 1. Install ngrok and setup your account. 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 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
meeting.rtms_started sample
{
"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
meeting.rtms_stopped sample
{
"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.
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.
// 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
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:
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.
@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.
# 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)
)
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:
@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.
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.
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 passes in the meeting_uuid and rtms_stream_id from the meeting.rtms_started event and includes fields like msg_type, protocol_version and sequence as required.
Add the following code to index.js.
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.
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 to check if the client is still connected. The client needs to respond promptly with a keep-alive response message, including the timestamp received in the request, to maintain the WebSocket connection. Add this if-statement:
Add the following code to index.js.
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.
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 with media server URLs in media_server.server_urls:
Signaling handshake response sample
{
"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 with our meeting details and signature:
Add the following code to index.js.
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.
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:
Client ready acknowledgement (ACK) sample
{
"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.
// 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.
# 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.
// 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.
# 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.
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.
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 .
Now that you've created the app, we need to add the ngrok URL from Step 2 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
- Sign into the Zoom App Marketplace.
- To go to the app, in the upper-right of the screen, choose Manage.
- Select your app from the list.
- In the navigation pane, choose Basic Information.
- In the OAuth Information section, for OAuth Redirect URL, add your ngrok URL.
- In the navigation pane, choose Local Test.
- In the Add app section, choose Add app now.
- 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.
touch .env
To add the credentials
- Sign into the Zoom App Marketplace.
- To go to the app, in the upper-right of the screen, choose Manage.
- Select your app from the list.
- Open the
.envfile in your project and paste the following, replacing <Client ID> and <Client Secret> with the values from your app.
ZOOM_CLIENT_ID = <Client ID>
ZOOM_CLIENT_SECRET = <Client Secret>
- 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 (as an account admin) and enable apps to share realtime meeting content with apps.
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
- Open you Zoom settings.
- Choose the Zoom Apps tab.
- In the Auto-start apps that access shared realtime meeting content section, choose + Choose an app to auto-start.
- From the dropdown, select your RTMS app, and choose Save.
To test your app
-
Run the command to set up the server to listen to incoming webhook events anytime the app is launched in a meeting.
npm run startpython main.py -
Log into the Zoom desktop client as the user who has installed the app.
-
Launch a Zoom Meeting.
Full code
Here's the final code for our server.
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}`);
});
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 in a meeting. You'll start seeing meeting transcripts in your console.