Real-time compliance screening for Zoom Meetings, built on the Zoom RTMS SDK.
Live transcript segments are screened against a versioned rule pack. Prohibited claims raise an alert in an in-meeting panel visible only to the assigned representative, required disclosures are tracked as a checklist, and every alert, verdict, and coverage gap is appended to a hash-chained audit log.
Reference implementation for the Real-Time Compliance Advisor in Meetings blueprint.
This is a sample application, not a compliance product. The shipped rule packs are illustrative examples and make no claim of FINRA, SEC, HIPAA, or FDCPA coverage. Read Not production ready before doing anything with it beyond evaluating the pattern.
| Screens | Every transcript segment against a compiled rule pack, under 5ms, no network call |
| Adjudicates | Only matched candidates go to an LLM, which judges context and drafts a correction |
| Tracks | Required disclosures, marked satisfied only when the representative says them, with the quote as evidence |
| Escalates | High-severity alerts POST to a supervisor webhook while the meeting is still live |
| Records | An append-only, hash-chained audit log including gaps in coverage |
A regex hit is reproducible. An auditor can re-run a pinned pack version over a stored transcript and get byte-identical matches. A model verdict cannot be reproduced that way, so the two are stored in separate fields on every audit record and a reviewer can always tell which half was which.
The practical benefit is cost: clean segments never reach the LLM, so spend tracks risk rather than call volume.
| Requirement | Notes |
|---|---|
| Node.js >= 22 | @zoom/rtms uses N-API 9/10. Node 24 LTS recommended. A lower version segfaults on import rather than failing cleanly. |
darwin-arm64 or linux-x64 |
The platforms with prebuilt SDK binaries. |
| Paid Zoom Workplace plan with RTMS | Request RTMS access. |
| Docker | Postgres locally, and the container build CI gates on. |
| ngrok or equivalent | A public HTTPS URL Zoom can deliver webhooks to. |
git clone https://github.com/zoom/rtms-compliance-sample-js.git
cd rtms-compliance-sample-js
nvm use # reads .nvmrc, pins Node 24
npm install
npm run env:init # writes .env with secrets generatedThen fill in the four values only Zoom can give you: ZM_RTMS_CLIENT, ZM_RTMS_SECRET, ZM_RTMS_WEBHOOK_SECRET, and your PUBLIC_URL.
Outside production the app generates what only it cares about and defaults the local URLs, so the shortest working environment is four variables:
| Variable | Why it cannot be generated |
|---|---|
ZM_RTMS_CLIENT, ZM_RTMS_SECRET |
Issued by Zoom. A fabricated value trades a clear startup failure for a confusing 401 later. |
ZM_RTMS_WEBHOOK_SECRET |
Zoom signs webhooks with it. Both sides must agree. |
RULE_PACK_ID |
Which rules are in force is a compliance decision. This one has no default in any environment, on purpose. |
Everything else has a development answer:
| Variable | Development behavior |
|---|---|
JWT_SECRET |
Generated per boot, with a warning. Required in production. |
DATABASE_URL |
Defaults to the compose Postgres. Required in production. |
PUBLIC_URL |
Defaults to http://localhost:$PORT. Required in production. |
ADJUDICATION_ENABLED, LLM_API_KEY |
Off by default: alerts come from the rule pack alone, with no model call and no verdict shown. Set ADJUDICATION_ENABLED=true and an LLM_API_KEY (from openrouter.ai) to have a model review each match in context. For a keyless local model server, set any placeholder key. |
NODE_ENV=production generates nothing and defaults nothing. It fails at boot with every missing variable named at once.
npm run env:secrets # print generated secrets, write nothing
npm run env:init # write .env at mode 0600; refuses to clobber an existing oneWhat JWT_SECRET actually protects. The panel gets meetingUUID and user context from the Zoom Apps SDK, posts them to /api/panel/token, and the server returns a short-lived token scoped to that one meeting. That signature is the only thing stopping one meeting's panel from subscribing to another meeting's compliance alerts. An ephemeral value is fine locally: after a restart the panel simply requests a new token.
Full local setup
-
Start ngrok with a free static domain:
ngrok http 3000 --domain=your-subdomain.ngrok-free.app
-
Create a Zoom App at marketplace.zoom.us: Develop > Build App > General App > User-managed. Replace
YOUR-NGROK-URLbelow with your ngrok domain, withouthttps://where the field asks for a domain.You can import
manifest.jsoninstead of filling in most of these screens, but it does not cover Real-Time Media Streams. Do step 2d by hand either way, then check every item below before testing.2a. Basic Information
- OAuth Redirect URL:
https://YOUR-NGROK-URL/api/auth/callback - OAuth Allow List:
https://YOUR-NGROK-URL
The redirect URL is required by the form, but this sample never completes a token exchange and implements no
/api/auth/callbackroute. It makes no server-side Zoom API calls: panel identity comes from the Zoom Apps SDK in-client, transcripts come over RTMS, and the attendee roster comes from the RTMSonParticipantEventcallback.2b. Features > Access
- Copy the Secret Token into
ZM_RTMS_WEBHOOK_SECRET. ZM_RTMS_CLIENT,ZM_RTMS_SECRET, andZM_RTMS_WEBHOOK_SECRETmust all come from this app, the one you install in step 2f. If they come from another app, Zoom's webhooks fail signature verification and the server logsrejected webhook: signature mismatch: delivery is from app …, naming both client IDs.- Turn on Event Subscription. Endpoint URL:
https://YOUR-NGROK-URL/api/rtms/webhook. Add the events RTMS Started (meeting.rtms_started) and RTMS Stopped (meeting.rtms_stopped). The app must be running for Validate to pass, so you can come back to this after step 4.
2c. Features > Surface
-
Select where to use your app: check Meetings.
-
Home URL:
https://YOUR-NGROK-URL -
Domain Allow List: add
YOUR-NGROK-URLandappssdk.zoom.us. -
In-client App Features > Zoom App SDK: click Add APIs and enable all six APIs the panel calls:
getMeetingContextgetMeetingUUIDgetRunningContextgetUserContextstartRTMS, which the panel calls when it opensonRTMSStatusChange
If any of these are missing, the panel fails with
No Permission for this API. [code:80004, reason:app_not_support].
2d. Features > Surface > Real-Time Media Streams
- Turn it on and select Transcripts.
2e. Scopes
- Add
zoomapp:inmeetingand nothing else. Addmeeting:read:meetingoruser:readwhen you add the REST call that needs them, not before.
2f. Local Test
- Click Add App and authorize it. This installs the app for your Zoom user.
- Every time you change APIs, scopes, or RTMS settings, remove the app from your Zoom client and add it again from Local Test. The client caches what the app is allowed to do at install time, so changes in Marketplace do not apply until you reinstall.
- OAuth Redirect URL:
-
Fill
.envwith the Client ID, Client Secret, Webhook Secret Token, and your ngrok URL.npm run env:initalready generatedJWT_SECRET; nothing else needs generating. -
Start everything:
docker compose up --build
Postgres on 5432 and the app on 3000: Express, the RTMS client, and the built panel, all in one process. On Apple Silicon the app image builds as
linux/amd64under emulation, because@zoom/rtmshas no linux-arm64 binary. Expect the first build to be slow. -
Try it. Join a meeting as the host, open Apps, and select your app. Screening starts automatically when the panel opens. The badge reads Live only once this server is receiving the transcript. If it stays on Starting and then times out, check the server log: a
rejected webhook:line says why, and no webhook at all means Zoom is not sending this app's events to your Event Subscription URL. Say something the pack prohibits, like "we can offer a guaranteed return."
| Platform | |
|---|---|
| Deploy to Render | Backend, frontend, database |
| Deploy to Railway | Backend, frontend, database |
Deploy anywhere: the Dockerfile is platform-agnostic, but the base image has three hard constraints from the RTMS native addon. It needs glibc, so Alpine is out. It needs libstdc++ with GLIBCXX_3.4.31 or newer, so Debian bookworm is out too. And it must be linux/amd64, since there is no linux-arm64 prebuild. The image uses node:24-trixie-slim pinned to linux/amd64. All three failures happen at container start rather than at build time, so verify with a real boot before changing that line.
One Node process. The RTMS client runs in-process, so there is no separate media microservice to operate.
Zoom Meeting ──1. webhook──▶ Express + @zoom/rtms
│ │
└──2. media WS────────────────▶│
├─3. segments──▶ Rule Engine (deterministic, <5ms)
│ │
│ └─4. candidates only──▶ LLM
│ │
│◀─────────────────5. verdicts─────────────────┘
├─6. WebSocket──▶ Advisor Panel (rep + host only)
└─7. append────▶ Audit Log (hash-chained)
| Path | What lives there |
|---|---|
src/config.js |
Environment parsing, fail-fast validation. The only file allowed to contain a Zoom hostname. |
src/rtms/webhook.js |
HMAC verification, URL validation challenge, event routing. Pure functions. |
src/rtms/session.js |
Client lifecycle, callback wiring, failover, coverage gaps |
src/rtms/segments.js |
Buffer decode, sequence numbering, speaker defaults |
src/compliance/packs.js |
YAML load, schema validation, pattern compilation. Fails closed. |
src/compliance/screen.js |
Deterministic pass and the suppression window |
src/compliance/adjudicate.js |
Prompt construction, verdict parsing, timeout degradation |
src/compliance/disclosures.js |
Checklist state machine |
src/compliance/audit.js |
Canonical serialization, hash chain, verification |
src/api/ws.js |
Authenticated fan-out and the alert authorization rule |
src/api/routes.js |
Audit export |
rule-packs/ |
Versioned YAML packs |
Register callbacks before join(). Anything attached afterward misses the segments that arrived in between. On a regulated call the opening seconds carry the consent and disclosure language, so this is not a cosmetic bug.
Set an explicit transcript language. Without setTranscriptParams({ srcLanguage }), the SDK auto-detects, which takes roughly 30 seconds. That is 30 seconds of an unscreened regulated conversation. TRANSCRIPT_LANGUAGE in .env controls it.
Record coverage gaps. onMediaConnectionInterrupted fires when the media connection drops. An audit log that silently omits the gap implies the call was screened end to end, which is a stronger claim than the data supports.
Transcript timestamps arrive in microseconds. src/rtms/segments.js converts them to milliseconds, which everything downstream assumes. Without the conversion, the 60-second suppression window and the model's 30-second context window both shrink by a factor of 1000.
Deduplicate by rtms_stream_id, not meeting_uuid. One meeting can produce several streams when a media server fails over.
A rule pack is the contract between your compliance team and this code. Compliance owns the YAML; engineering owns the loader.
id: fin-us-retail-v4 # version suffix required; never edit in place
jurisdiction: US
applies_to: [retail-investment-review]
prohibited:
- id: guaranteed-return
severity: high # high | medium | low
match: ['guaranteed returns?', 'risk[- ]free']
guidance: 'Past performance does not guarantee future results.'
required_disclosures:
- id: fees-and-conflicts
severity: high
satisfied_by: ['advisory fee', 'conflict of interest']
prompt: 'State the advisory fee schedule and any conflicts of interest.'match and satisfied_by entries are case-insensitive regular expressions, compiled once at load.
Two shipped examples: fin-us-retail-v4 and health-us-intake-v1. Both are illustrative.
Keep one pack per jurisdiction and meeting type rather than one pack with conditionals. Small packs are easier for a compliance reviewer to sign off on, and pinning a version per meeting stays meaningful.
npm run packs:validateAn invalid pack stops the service at startup rather than screening with partial rules.
Set one variable:
ZOOM_HOST=https://zoomgov.comAPI and Marketplace hosts derive from it (api.zoomgov.com, marketplace.zoomgov.com). No other change is required, and a test asserts no Zoom hostname is hardcoded anywhere outside src/config.js.
RTMS media endpoints need no mapping. They arrive in the webhook payload as server_urls, so they follow the account's environment automatically.
npm test # 208 tests
npm run test:unit
npm run test:integrationBuilt test-first from spec.md; every acceptance criterion there maps to at least one test.
@zoom/rtms is faked at the client-factory boundary rather than mocked, because it is a native addon. The docker job in CI is the only place the real addon is loaded, which is what makes it a required gate.
Not covered by automated tests, deliberately:
- A real RTMS join against live Zoom infrastructure. Needs an entitled account and a live meeting.
- LLM output quality. Adjudication tests use fixtures; prompt quality is judged by hand.
- Rule pack accuracy against actual regulation. That is a compliance review, not a unit test.
Everything below is simplified for a sample. None of it is hidden.
| Simplification here | What production needs |
|---|---|
.env file for credentials |
AWS Secrets Manager, HashiCorp Vault, or platform secret storage |
| Single-instance in-memory session map | Redis, so a restart does not orphan joined streams or lose sequence counters |
| Single-instance WebSocket broadcast | Redis pub/sub fan-out |
| Audit records in the same Postgres as app data | Separate database, distinct credential, WORM or object-lock backups |
| Hash chain with no external anchoring | Publish chain heads periodically to an append-only external store or timestamping authority |
| Rule packs from local disk | Versioned artifact from a registry, with a compliance sign-off gate in CI |
| Synchronous LLM adjudication in the request path | Job queue with a bounded worker pool and backpressure |
| One LLM call, fixed timeout, no retry | Exponential backoff with jitter, plus a circuit breaker |
| No LLM rate-limit handling | Detect 429, back off, degrade to deterministic-only screening and write a coverage-gap record |
| Supervisor webhook is fire-and-forget | Durable outbox with retries and a dead-letter path |
| No transcript retention enforcement | Scheduled deletion aligned to your retention schedule, separate from the audit chain |
| No PII or PHI redaction before LLM egress | Redact or tokenize before egress, or run inference inside your own boundary |
| Audit export authorized by the meeting JWT | Role-based access control. Auditors are not meeting participants. |
| Migrations applied at container start | A gated migration step in the deploy pipeline |
This is engineering guidance, not legal advice. Rule pack content, retention periods, and supervision workflows must be reviewed and approved by your compliance and legal teams.
Participant notice. Participants see Zoom's standard transcription notice. Confirm with counsel whether your jurisdiction requires additional notice that live compliance screening is running.
False positives are data. Every alert a representative dismisses says something about pack quality. Dismissals are logged with the rule id; review them on the same cadence you review the packs.
- Real-Time Compliance Advisor blueprint
- zoom/rtms - the SDK this is built on
- RTMS SDK API reference
- rtms-quickstart-js - smallest working RTMS app
- RTMS documentation
- Zoom Developer Forum
See CONTRIBUTING.md.
MIT. See LICENSE.md.