RTMS on AWS: Capture Participant Photos in Real Time

What I love about RTMS most is how dead simple it is to get data out of your Zoom applications and then react on that data in real time. You can create a lot of really meaningful integrations this way with minimal coding. That couldn't be more true when looking at the sample application I cover in this article. Grab your credentials, plug them into this sample and start on your MVP today.

Let's jump right in.

The Problem

When you join a meeting how do you know everyone in the meeting is who they say they are? In the past, you could rely on a video stream or a familiar voice on the the other end. With the advent of deepfakes and AI scams it's hard to be so sure these days. What we need, is a service to validate our users that lives right in our meeting. Where, just like a physical ID badge, a meeting host can verify participants identity and confirm that they are not a deepfake. All from a single photo.

That's where RTMS comes in. Using a super simple API you can stream real time media out of a meeting. Allowing you to automate things like attendance verification or identity checks against a known photo.

Need a real world example? Tools For Humanity uses RTMS to enable "Proof of Human" in Zoom meetings. That means, with their Orb technology, you can trust who is on the other side. For more information on that see our press release

Why This Is Hard

Using existing tools, this would be an exceptionally daunting task. We would need to create, debug and maintain an entire media pipeline. That means tons of planning, overhead and complex troubleshooting of C++ system libraries. Now, we can let Zoom do all the heavy lifting for us. With the RTMS SDK it's as simple as writing rtms.VideoCodec.JPG and you have video frames encoded as JPEG images in real time.

You can see this just by looking at the worker/requirements.txt in the reference. We have three dependencies rtms, boto3, python-dotenv. No Pillow, no numpy, no ffmpeg, no OpenCV.

RTMS Terraform Sample

Let's start with the RTMS Terraform Sample for AWS which spins up a full-fledged application using our RTMS SDK for Python. You can use this as a jumping-off point to not only host RTMS on AWS but to also capture each participant. All of the code and examples from today will be from that sample app.

If you were to look at the git history of this repo then you'll see that when I originally wrote this sample it used transcripts. You'd also notice that converting it to capture single images required zero infrastructure change and minimal code change. Without RTMS that would be nearly impossible.

Zoom ──webhook──▶  ALB (HTTPS, ACM cert)  ──▶  ECS Fargate Service
                                                ├── Task 1  (worker/main.py)
                                                ├── Task 2  (worker/main.py)
                                                └── Task N  (worker/main.py)
                                                            │
                                              ┌─────────────┼──────────────┐
                                              ▼             ▼              ▼
                                          S3 (JPEG)   CloudWatch     Secrets Manager

How It Works

Zoom POSTs meeting.rtms_started to a public HTTPS endpoint you own. In the sample that means, an ALB in front of ECS Fargate. After you complete the handshake media comes over an outbound WebSocket that the SDK opens to Zoom.

RTMS sessions are long-lived WebSocket connections so we're using Fargate instead of Lambdas which have a 15-minute ceiling.

Configuring the Capture

Before we join our meeting, we'll need to make sure that we are configuring our Video Params so that we get a JPEG image from RTMS.

params = rtms.VideoParams()
params.codec      = rtms.VideoCodec.JPG                              # finished JPEGs, not H.264
params.resolution = rtms.VideoResolution.HD
params.fps        = 1                                                # SDK caps JPG at 5
params.data_opt   = rtms.DataOption.VIDEO_SINGLE_INDIVIDUAL_STREAM   # per person, not composite
client.set_video_params(params)

Of the four parameters, data_opt is the one that matters most. The default is VIDEO_SINGLE_ACTIVE_STREAM, which gives you one composite stream of whoever is talking. For most things you'd build that's exactly right.

For this it's useless. You'd get the same picture of the active speaker over and over and never see the quiet person at all. VIDEO_SINGLE_INDIVIDUAL_STREAM switches you over to per-participant streams that you subscribe to one at a time.

We set fps to 1 because we're keeping one frame per person.

Subscribing to Participants

Once you're on individual streams the video doesn't just show up. You have to subscribe each participant using the on_user_update callback.

@client.on_user_update
def _(op, participant):
    if op == rtms.USER_JOIN:
        client.subscribe_video(participant.id, True)
    elif op == rtms.USER_LEAVE:
        client.subscribe_video(participant.id, False)

In the sample there are a couple of other functions that you might see. Let's go over them briefly.

The first is on_participant_video(user_ids, is_on), which fires when someone's camera turns on or off. Register this one as well. USER_JOIN only fires for people who show up after you connect, and it doesn't tell you anything about whether their camera is on. on_participant_video catches everyone who was already sitting in the meeting when your worker joined, plus anyone who joins with video off and switches it on later. Between the two you've got the whole room and you can react to users turning their camera on or off.

The second is on_video_subscribed(user_id, status, error), and this is the one that'll save you an afternoon. When images aren't showing up, "Zoom rejected the subscription" and "nobody has their camera on" look exactly the same. You can use this callback to ensure that the calls to subscribe or unsubscribe video work without issue.

Grabbing One Frame and Stopping

Use the on_video_data callback to receive your frame.

@client.on_video_data
def _(data, _size, ts, metadata):
    user_id = metadata.userId
    if user_id in captured:            # one frame per participant
        return
    captured.add(user_id)
    executor.submit(save_frame, meeting_uuid, user_id, metadata.userName, data, ts)
    client.subscribe_video(user_id, False)     # we're done with this stream

As soon as you've got the image, cancel. You've paid for about a second of at least one person's video and then you're done. Leave that line out and you'll stream every participant for the entire meeting just to throw nearly all of it away.

Where the work happens matters too. The Zoom C SDK ties its handles to whichever thread allocated them, so subscribe_video() has to run on the SDK's event loop thread. An S3 upload is slow and can't block that loop. So we build rtms.Client() with no executor, which keeps callbacks inline on the loop thread, and hand only the upload off to a ThreadPoolExecutor. Decide on the SDK thread, upload on a worker thread. Nice side effect: the captured check only ever runs on one thread, so you don't need a lock.

The snippets above are trimmed so they read cleanly. No logging, no error handling, and captured is a flat set instead of being keyed per meeting. The real version is in worker/main.py if you want to copy something.

Where the Images Land

frames/<meeting_uuid>/<user_id>.jpg

The key is deterministic, so you get one object per person per meeting. If a webhook gets retried or a task restarts, the second write just overwrites the first instead of leaving you with duplicates.

The display name goes into S3 object metadata rather than the key. That keeps participant names out of bucket listings, S3 access logs and CloudTrail data events, which are all places people forget about when they're working out who can see what.

Deploying It

Clone the sample, run ./deploy.sh, answer the prompts, then paste the webhook URL it gives you into your Marketplace app. It builds the VPC, load balancer, ACM cert, Fargate service, autoscaling policy, S3 bucket and CloudWatch alarms.

aws s3 ls s3://<frame_bucket>/frames/ --recursive     # one .jpg per participant
aws s3 cp s3://<frame_bucket>/frames/<uuid>/<user_id>.jpg .

Idle cost sits around $20 a month and almost all of that is the load balancer. scripts/teardown.sh clears everything out between sessions.

You don't need an AWS account to try this either. Set FRAME_BACKEND=local, point ngrok at port 8080, and the frames land on your own disk instead.

Before You Ship This

A few things worth thinking about before you point this at a real meeting.

You're storing pictures of people's faces. Zoom tells participants that RTMS is running. That is not the same as telling them you're keeping still images of them, how long you're keeping them, or what for. What you owe people depends on where you operate and what you do with the images. That means thinking about biometric laws like Illinois BIPA and Texas CUBI, GDPR Article 9, CCPA and CPRA.

Set a retention period. The sample gives you a private, encrypted, versioned bucket that moves to Glacier IR after 30 days and then keeps everything forever. Adding an expiry rule is one line of Terraform and for a bucket full of faces it's usually what you want.

First frame wins, whatever it looks like. You'll catch someone mid-blink now and then. If image quality matters for what you're building, buffer a few seconds and pick the best one.

Capture state lives in worker memory. If a task dies mid-meeting the replacement doesn't know who has already been photographed. The deterministic key keeps the worst case harmless, you get an overwrite rather than a pile of duplicates, but it's worth knowing about.

Resources

Summary

Capturing an image of every participant in a Zoom meeting turns out to be about 40 lines of Python. Ask RTMS for JPEG, subscribe to each participant as they arrive, keep the first frame you get and unsubscribe. No media pipeline, no ffmpeg, no image library. The Terraform sample handles the AWS side so you can go from clone to a bucket full of frames in an afternoon.

From here you could:

  • Run the frames through Rekognition or your own model to match against a reference photo
  • Trigger on a mismatch instead of storing every image
  • Swap the S3 sink for whatever you already use
  • Combine it with the transcript callback if you want both

Need help? Join the conversation on the Zoom Developer Forum!