Real-time compliance monitoring for Zoom meetings

Build a real-time compliance advisor that lives inside Zoom meetings. Stream transcripts with the Zoom RTMS SDK, screen every segment against a versioned rule pack, and warn the representative before a disclosure is missed or a prohibited claim goes unanswered.

Built with

Anthropic
OpenAI

Updated August 2026

RTMS, Zoom Apps

Requires a paid Zoom Workplace plan with the RTMS entitlement

Node 22 · Express · @zoom/rtms · React · Postgres

A real-time compliance advisor streams meeting transcripts through the Zoom RTMS SDK , screens every segment against a versioned rule pack, and warns the licensed representative in a panel only they can see. The alert names the rule, quotes the phrase that triggered it, and offers approved language to correct the record.

Compliance review normally happens weeks after the call, when a surveillance team samples recordings. By then the client has acted on what they heard and the only remedy is remediation. Screening the transcript live gives the representative a chance to correct the record while the other party is still on the call.

What you'll need:

  • Transcript access via RTMS  (requires a paid Zoom Workplace plan with RTMS entitlement; request access )
  • The @zoom/rtms  SDK, which needs Node.js 22 or higher on darwin-arm64 or linux-x64 (Node 24 LTS recommended)
  • A backend to screen segments and persist audit records (Express in this guide; the SDK also ships Python bindings)
  • A Zoom Surface App  to render the advisor panel in-meeting
  • An LLM to adjudicate flagged phrases in context (OpenRouter, OpenAI, Anthropic, or self-hosted)
  • A rule pack for your jurisdiction, reviewed and signed off by your compliance team

Features:

  • Deterministic screening of every transcript segment against a versioned rule pack, under 5ms per segment
  • Prohibited-claim alerts with the triggering quote, the rule ID, and approved replacement language
  • Required-disclosure checklist that marks each item satisfied when the representative says it
  • Severity routing: high-severity alerts post to a supervisor webhook while the meeting is live
  • Hash-chained audit log covering every alert, verdict, disclosure, and coverage gap
  • Post-meeting compliance summary exportable as JSON for your surveillance system

If you'd rather buy than build, Zoom offers Zoom Compliance Manager  for archiving, eDiscovery, and risk detection across Zoom Workplace.

Follow along as we walk through the architecture.


Architecture

RTMS streams transcripts directly from Zoom's infrastructure. No bot joins the meeting, so the roster stays clean and the participant list in the audit record reflects only real attendees. The standard transcription notice displays to everyone.

The @zoom/rtms  SDK collapses what would otherwise be two services into one process. It exposes a webhook handler you mount on your existing Express app, and a Client that opens the media WebSocket to Zoom and delivers transcript segments to a callback. There is no separate RTMS microservice to run.

Screening runs in two stages. A deterministic pass matches every segment against the rule pack. Only segments that match go to an LLM, which decides whether the match is a real violation in context. This keeps cost proportional to risk and keeps the first stage reproducible, which matters when an auditor asks why an alert fired.

Components

ComponentResponsibilityOur stack (yours may differ)
Transcript accessReceive RTMS webhooks, join streams, deliver segments@zoom/rtms mounted on the backend process
Rule engineMatch segments against the rule pack, track disclosure stateNode module loading versioned YAML rule packs
BackendAdjudicate candidates with an LLM, broadcast alerts, write audit recordsExpress + Prisma (Postgres) + OpenRouter
FrontendRender alerts, disclosure checklist, suggested languageReact Surface App via Zoom Apps SDK
Audit storeAppend-only, hash-chained record of every alert, disclosure, and coverage gapPostgres table with a retention policy
graph LR
    Z[Zoom Meeting] -->|1. webhook| B[Backend with @zoom/rtms]
    Z -->|2. media WS| B
    B -->|3. segments| E[Rule Engine]
    E -->|4. candidates| L[LLM]
    L -->|5. verdicts| B
    B -->|6. WS broadcast| P[Advisor Panel]
    B --> D[(Audit Log)]

When transcription starts, Zoom sends meeting.rtms_started to the webhook handler. The handler verifies the signature, constructs an rtms.Client, registers callbacks, and calls join(payload). The SDK opens the media WebSocket and begins delivering transcript segments. Each segment hits the rule engine within 5ms. Clean segments stop there. Candidates go to the LLM, which returns a verdict in 800ms to 2s. An alert typically reaches the panel 2-3 seconds after the phrase is spoken.

Our stack vs. your options: We use Node/Express because @zoom/rtms ships Node bindings, but the same SDK ships Python bindings  and the compliance layer is stack-agnostic. Go bindings are planned. Whatever you pick, the requirements are a webhook endpoint Zoom can reach, an RTMS client, a rule matcher, an LLM, and a way to push updates to the frontend.

How screening works

Every feature in the compliance layer follows the same three-stage path:

  1. Match: Run the segment against the rule pack lexicon. Deterministic, no network call, under 5ms
  2. Adjudicate: Send matched candidates plus 30 seconds of surrounding context to the LLM for a violation verdict and suggested correction
  3. Record: Broadcast the verdict to the panel and append it to the audit log with the rule ID, model version, and quote

The two stages stay separate in storage. Each audit record keeps the deterministic rule ID and the model verdict in different fields, so a reviewer can tell which part of an alert was a pattern match and which part was a model judgment. A rule pack version is stamped on every record, so replaying an old meeting uses the rules that were in force at the time.

Disclosure tracking runs on a different cadence. Every 20 seconds the backend checks the full transcript against the pack's required disclosures and updates each item to satisfied or pending.

Agent integration map

If you're grafting this into an existing codebase, check what you already have before adding anything:

Required capabilityReuse when presentAdd when missing
HTTP serverExisting Express/Fastify apprtms.createWebhookHandler() on a new route
Signature verificationExisting HMAC middlewareverifySignature() over the raw body
RTMS clientNothing to reuse@zoom/rtms
WebSocket serverExisting real-time layerws or Socket.io server
Rule storageExisting policy or feature-flag storeVersioned YAML rule packs on disk
DatabaseExisting Postgres/MySQLPrisma schema for transcripts and audit records
Audit trailExisting append-only logHash-chained AuditRecord table
LLM clientExisting OpenAI/Anthropic setupOpenRouter client
Supervisor alertingExisting paging or chat webhookOutbound webhook on high severity

Implementation Guide

Three parts: the transcript pipeline, the compliance layer, and running the reference implementation.

Code examples are from rtms-compliance-sample-js , but the patterns transfer to any stack the SDK supports.

Part 1: Building the Transcript Pipeline

The @zoom/rtms  SDK handles webhook delivery and the media WebSocket. Install it and confirm your runtime first:

node --version    # must be >= 22.0.0; the SDK uses N-API 9/10
npm install @zoom/rtms

Prebuilt binaries exist for darwin-arm64 and linux-x64. A Node version below 22 segfaults on import rather than failing cleanly, so pin it in .nvmrc and check it in CI.

Mount the webhook route

The SDK offers two ways in. rtms.onWebhookEvent() runs its own HTTP server, which is the fastest path for a standalone listener. rtms.createWebhookHandler(callback, path) mounts on an Express app you already own, which is what a compliance app needs, since it also serves the Surface App and the audit API.

Take the raw body on that route rather than parsed JSON. Zoom signs the exact bytes it sent, and re-serializing a parsed object produces different bytes and a different HMAC:

import express from 'express';

const app = express();

// express.raw() guarantees req.body is the original Buffer. Do not mount
// express.json() ahead of this route.
app.post('/api/rtms/webhook', express.raw({ type: '*/*' }), (req, res) => {
  const { status, body, action } = handleWebhook({
    rawBody: req.body,
    headers: req.headers,
    secret: process.env.ZM_RTMS_WEBHOOK_SECRET,
  });

  // Acknowledge before doing any work. Zoom retries a slow response, and a
  // retry is a second join for the same stream.
  res.status(status).json(body);

  if (action.type === 'stream_start') sessions.startStream(action.payload);
  if (action.type === 'stream_stop') sessions.stopStream(action.payload);
});

app.use(express.json()); // every other route

Keeping verification in your own handler, rather than inside the SDK callback, means the security-critical path is a pure function you can test directly: bytes and headers in, a decision out.

Verify the webhook

Verify the request came from Zoom before acting on it: compute an HMAC-SHA256 signature over v0:{timestamp}:{rawBody} using your webhook secret token, enforce a five-minute replay window, and use timing-safe comparison.

function verifySignature(rawBody, timestamp, signature) {
  const secret = process.env.ZM_RTMS_WEBHOOK_SECRET;
  if (!signature || !timestamp) return false;

  const now = Math.floor(Date.now() / 1000);
  const reqTimestamp = parseInt(timestamp, 10);
  if (isNaN(reqTimestamp) || Math.abs(now - reqTimestamp) > 300) return false;

  const message = `v0:${timestamp}:${rawBody.toString('utf8')}`;
  const expected = 'v0=' + crypto
    .createHmac('sha256', secret)
    .update(message)
    .digest('hex');

  if (signature.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

Handle Zoom's endpoint.url_validation challenge before signature verification, since validation requests carry no signature. Respond with the plainToken and its HMAC-SHA256 under the same secret.

Other languages: In Python, use hmac.compare_digest; in Go, use crypto/subtle.ConstantTimeCompare. The SDK's Node  and Python  guides show the same pattern.

Stream session management

Input: Verified meeting.rtms_started payload with meeting_uuid, rtms_stream_id, server_urls, signature

Output: An rtms.Client joined to the stream with callbacks registered, tracked by stream ID

Invariants:

  • Keep a Map keyed by rtms_stream_id, not meeting_uuid; one meeting can produce several streams
  • Register every callback before calling join(); callbacks attached afterward miss early segments
  • Call setTranscriptParams() before join() (see below)
  • If the same meeting arrives with a new stream ID (media server failover), call leave() on the old client and join the new one
  • On meeting.rtms_stopped, call leave() and delete the map entry
  • Use onLeave to close the audit chain, not the webhook; the stream can end without a webhook
const clients = new Map();

function startSession(payload) {
  const client = new rtms.Client();
  clients.set(payload.rtms_stream_id, client);

  client.setTranscriptParams({ srcLanguage: rtms.TranscriptLanguage.ENGLISH });

  client.onJoinConfirm((reason) => openAuditChain(client.uuid(), reason));
  client.onTranscriptData(handleSegment(client));
  client.onParticipantEvent(recordAttendance(client));
  client.onMediaConnectionInterrupted((ts) => recordCoverageGap(client.uuid(), ts));
  client.onLeave((reason) => closeAuditChain(client.uuid(), reason));

  client.join(payload);
}

Set the transcript language. Without setTranscriptParams, the SDK auto-detects the spoken language, which takes roughly 30 seconds. For a general notetaker that is a minor delay. For compliance it is 30 seconds of a regulated conversation with no screening, at the exact point in the call where consent and disclosure language is usually spoken. Pass an explicit srcLanguage and transcription starts immediately.

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. Write a gap record with the timestamp and close it when segments resume.

See the RTMS session module  for the reference implementation.

Transcript segment handling

Input: onTranscriptData(buffer, size, timestamp, metadata) where buffer is UTF-8 text, timestamp is Unix milliseconds, and metadata carries userId and userName

Output: Normalized segment with speakerId, speakerLabel, text, tStartMs, seqNo

Invariants:

  • Decode with buffer.toString('utf8'); the callback hands you a Buffer, not a string
  • Timestamps are already Unix milliseconds; do not rescale them
  • Assign a monotonic sequence number per stream, since the SDK does not provide one
  • Screen synchronously before broadcasting; the rule pass is fast enough to stay inline
  • Persist asynchronously with upsert on (meetingId, seqNo) for idempotency
  • Handle missing userId/userName gracefully with defaults

Speaker attribution: transcript metadata identifies the speaker per segment. If you also process mixed audio, that stream carries no speaker identity; use onActiveSpeakerEvent for attribution there.

Other databases: Works with Postgres, MySQL, or a time-series database. Pick one that supports the retention window your policy requires.

WebSocket delivery

Input: Client connection with meeting_id and JWT token

Output: Real-time transcript segments, alerts, and disclosure updates pushed to connected clients

Invariants:

  • Require valid JWT on every connection
  • Client sends { type: 'subscribe', meetingId } after connecting
  • Server broadcasts { type: 'transcript.segment', data }, { type: 'compliance.alert', data }, and { type: 'disclosure.state', data }
  • Only the host and assigned representative receive compliance.alert messages
  • Clean up subscriptions on disconnect
  • Panel appends segments in seqNo order

Part 2: Building the Compliance Layer

With transcripts flowing, screen them. The rule pack is the contract between your compliance team and your code.

Rule pack format

Input: A YAML file per jurisdiction and meeting type, versioned in Git

Output: A loaded, compiled rule set with prohibited patterns and required disclosures

Invariants:

  • Every pack carries an id that includes a version suffix; packs are never edited in place
  • Every rule has a stable id, a severity, and author-supplied guidance
  • Patterns compile once at load, not per segment
  • Loading fails closed: an invalid pack stops the service rather than screening with partial rules
  • Compliance owns pack content; engineering owns the loader
id: fin-us-retail-v3
jurisdiction: US
applies_to: [retail-investment-review]
prohibited:
  - id: guaranteed-return
    severity: high
    match: ["guaranteed return", "risk[- ]free", "can.t lose"]
    guidance: "Past performance does not guarantee future results. Restate as a historical range."
  - id: unapproved-projection
    severity: medium
    match: ["you.ll definitely", "we always beat", "locked[- ]in gains?"]
    guidance: "Replace the projection with approved illustration language."
required_disclosures:
  - id: recording-consent
    severity: high
    satisfied_by: ["this meeting is being recorded", "consent to record"]
    prompt: "Confirm verbal consent to record before continuing."
  - id: fees-and-conflicts
    severity: high
    satisfied_by: ["advisory fee", "conflict of interest", "how we are compensated"]
    prompt: "State the advisory fee schedule and any conflicts of interest."

Deterministic screening pass

Input: Normalized transcript segment, compiled rule pack

Output: Zero or more candidates, each with ruleId, severity, matchedText, seqNo

Invariants:

  • Run on every segment, inline, before broadcast
  • Normalize case and collapse whitespace before matching; do not strip punctuation that changes meaning
  • A segment can produce multiple candidates; do not stop at the first match
  • Suppress a repeat candidate for the same ruleId within a 60-second window
  • Emit nothing when there is no match; clean segments never reach the LLM

See the rule engine  for the reference implementation.

LLM adjudication

Input: Candidate, the triggering segment, and the prior 30 seconds of transcript

Output: JSON with violation (boolean), confidence, rationale, and suggested_language

Invariants:

  • Only adjudicate candidates, never the full stream
  • Send the rule's guidance text in the prompt so suggestions match approved language
  • Parse JSON response, strip markdown fences if present
  • On a malformed response or timeout, keep the alert at reduced confidence rather than dropping it
  • Record the model identifier and prompt version on the audit record
  • A model verdict never clears the deterministic match from the record

Prompt structure (customize for your jurisdiction and product set):

You are a compliance reviewer for a regulated financial services firm.
A deterministic rule matched a phrase in a live client meeting.
Decide whether it is a real violation in context.

Rule: {{rule.id}} ({{rule.severity}})
Approved guidance: {{rule.guidance}}
Matched phrase: "{{candidate.matchedText}}"
Prior 30 seconds: {{context}}

Return JSON only:
{
  "violation": true | false,
  "confidence": 0.0 - 1.0,
  "rationale": "one sentence citing the transcript",
  "suggested_language": "what the representative should say now"
}

Mark violation false when the phrase is quoted, negated, or spoken by the client.

Other LLMs: Works with OpenAI, Anthropic, or self-hosted models. For reliability, use the provider's JSON mode or function calling.

Disclosure tracking

Input: Full transcript for the meeting, the pack's required_disclosures

Output: Per-disclosure state of satisfied or pending, with the satisfying quote and seqNo

Invariants:

  • Run on a 20-second interval, not per segment
  • Match only against speech from the representative, identified by metadata.userId on the segment
  • A disclosure moves from pending to satisfied and never back
  • Store the quote that satisfied it; a checkbox with no quote is not evidence
  • Surface still-pending high-severity disclosures in the panel as the meeting passes the 45-minute mark

Audit records

Input: An alert, disclosure state change, or coverage gap, plus the hash of the previous record for this meeting

Output: An append-only record whose hash covers its own content and the prior hash

Invariants:

  • Serialize fields in a fixed key order before hashing; JSON key order changes break verification
  • Genesis record for a meeting uses a prevHash of 64 zeros
  • Records are insert-only; corrections append a new record referencing the original
  • Verification replays the chain from genesis and compares each stored hash
function buildAuditRecord(meetingId, prevHash, record) {
  const canonical = JSON.stringify({
    meetingId,
    seqNo: record.seqNo,
    ruleId: record.ruleId,
    rulePackVersion: record.rulePackVersion,
    severity: record.severity,
    quote: record.quote,
    verdict: record.verdict,
    modelId: record.modelId,
    occurredAt: record.occurredAt,
    prevHash,
  });

  const hash = crypto.createHash('sha256').update(canonical).digest('hex');
  return { ...record, meetingId, prevHash, hash };
}

Other languages: Any SHA-256 implementation works. The requirement is a canonical serialization both the writer and the verifier agree on.

Frontend state management

Input: WebSocket stream of segments, alerts, and disclosure updates

Output: React state for transcript, active alerts, disclosure checklist

Invariants:

  • Append segments in seqNo order
  • Dedupe alerts by (ruleId, seqNo)
  • Sort active alerts by severity, then recency
  • An alert stays visible until the representative dismisses it; dismissal writes an audit record
  • Show existing alerts and disclosure state on reconnect, then apply incremental updates
  • Never render an alert to a participant who is not the assigned representative

Other frameworks: Works with Vue, Svelte, or vanilla JS. The Surface App runs inside the Zoom client via iframe; standard web tech applies.

Customizing rule packs

The rule pack is the main customization point. Different regulated conversations need different packs:

Meeting typePack adjustments
Retail investment reviewPerformance claims, suitability questions, fee and conflict disclosure
Insurance salesState licensing statements, replacement-policy warnings, free-look period
Telehealth intakePatient identity verification, consent to treat, PHI handling in shared screens
Debt collectionMini-Miranda notice, call-time restrictions, third-party disclosure limits

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.

Part 3: Running the Reference Implementation

The sample repo  implements everything above. Use it to see the patterns working, then fork or reference for your own build.

One-click deploy

PlatformWhat you get
Deploy to Render Backend, frontend, database
Deploy to Railway Backend, frontend, database

Both have free tiers. After deploying, create a Zoom App in the Marketplace , set ZM_RTMS_CLIENT and ZM_RTMS_SECRET to your app's OAuth credentials, and point the OAuth redirect URL at your backend.

Deploy anywhere: The repo includes a platform-agnostic Dockerfile  for any container platform. It uses node:24-trixie-slim; see the platform constraint below before changing the base image.

Local setup

RequirementPurpose
Node.js 22+ @zoom/rtms requires N-API 9/10; 24 LTS recommended
darwin-arm64 or linux-x64Platforms with prebuilt SDK binaries
Docker Desktop Postgres and services
ngrok Public URL for webhooks
Zoom account App configuration
RTMS accessRequest here 
Step-by-step local setup
  1. Clone and configure:

    git clone https://github.com/zoom/rtms-compliance-sample-js.git
    cd rtms-compliance-sample-js
    nvm use            # reads .nvmrc, pins Node 24
    cp .env.example .env
    
  2. Start ngrok with a free static domain :

    ngrok http 3000 --domain=your-subdomain.ngrok-free.app
    
  3. Create your Zoom App at marketplace.zoom.us :

    • OAuth Redirect URL: https://YOUR-NGROK-URL/api/auth/callback
    • Scopes: zoomapp:inmeeting only
    • Zoom App SDK: enable RTMS > Transcripts
    • Surface: Home URL https://YOUR-NGROK-URL
    • Event Subscriptions: endpoint https://YOUR-NGROK-URL/api/rtms/webhook, events meeting.rtms_started and meeting.rtms_stopped
  4. Fill .env. The SDK reads the ZM_RTMS_* variables directly from the environment; the rest are the app's:

    # Read by @zoom/rtms
    ZM_RTMS_CLIENT=your_client_id
    ZM_RTMS_SECRET=your_client_secret
    ZM_RTMS_WEBHOOK_SECRET=your_webhook_secret_token
    ZM_RTMS_LOG_LEVEL=info
    ZM_RTMS_LOG_FORMAT=json
    
    # Read by the app
    ZOOM_HOST=https://zoom.us          # https://zoomgov.com for Zoom for Government
    PUBLIC_URL=https://your-subdomain.ngrok-free.app
    DATABASE_URL=postgresql://compliance@localhost:5432/compliance
    RULE_PACK_ID=fin-us-retail-v3
    TRANSCRIPT_LANGUAGE=ENGLISH
    LLM_API_KEY=your_llm_api_key
    

    Generate SESSION_SECRET, JWT_SECRET, and TOKEN_ENCRYPTION_KEY with:

    node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
    

    Startup fails with every missing variable named at once, rather than at the first webhook.

  5. Start:

    docker compose up --build
    

Services: Postgres 5432, backend 3000 (Express plus the RTMS client in-process), frontend 3001. Rule packs load from rule-packs/ at startup; the sample ships fin-us-retail-v3 and health-us-intake-v1. Join a meeting, open Apps, select your app, choose a pack, and start transcription.

Set ZM_RTMS_LOG_FORMAT=json and ZM_RTMS_LOG_LEVEL=debug when a stream fails to join. The SDK logs the join handshake, which is usually where credential and entitlement problems surface.


App Manifest

The manifest.json pre-configures OAuth scopes, Surface App capabilities, RTMS transcript access, and event subscriptions. Upload it when creating your app to skip manual configuration.

Scopes

ScopeRequiredPurpose
zoomapp:inmeetingYesRender the advisor panel in meetings

One scope, and it is the one that makes the panel exist. Everything else this app needs arrives over the media stream: transcripts through RTMS, the attendee roster through onParticipantEvent, and the speaker on each segment through the transcript callback's metadata. Panel identity comes from the Zoom Apps SDK in-client, not from a REST call.

That means there is no server-side token exchange to implement, and no meeting:read:meeting or user:read to justify to a reviewer. Add them when you add the REST call that needs them, not before. A compliance app asking for scopes it never exercises is a poor advertisement for itself.

RTMS Configuration

In app settings: Features > Zoom App SDK > enable Real-Time Media Streams > select Transcripts. RTMS requires approval from Zoom. Request access here .

The SDK authenticates with the same OAuth Client ID and Secret, supplied as ZM_RTMS_CLIENT and ZM_RTMS_SECRET.

Event Subscriptions

EventTrigger
meeting.rtms_startedTranscription begins; payload has stream credentials
meeting.rtms_stoppedTranscription ends; call leave() and close the audit chain

Production Considerations
AreaDevelopmentProduction
Credentials.env fileSecrets manager
SessionsIn-memory MapRedis, so a restart does not orphan joined streams
WebSocketsSingle instanceRedis pub/sub for horizontal scaling
HTTPSngrokLoad balancer with TLS 1.2+
SDK logsprogressive formatZM_RTMS_LOG_FORMAT=json into your log pipeline
EgressDirectclient.setProxy('http', url) before join() for inspected egress
Audit storageSame Postgres as the appSeparate database with WORM or object-lock backups
Rule packsFiles on diskVersioned artifact with a compliance sign-off gate in CI

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 before use with real clients.

Platform constraint: @zoom/rtms ships prebuilt binaries for darwin-arm64 and linux-x64, and the Linux binary has two base-image requirements. It needs glibc, so Alpine's musl will not load it at all. It also needs libstdc++ providing GLIBCXX_3.4.31 or newer, which rules out Debian bookworm: bookworm ships gcc-12 and stops at GLIBCXX_3.4.30. Debian trixie (gcc-14, GLIBCXX_3.4.33) works.

Both failures happen when the container starts, not when it builds, and the second one reports as version GLIBCXX_3.4.31 not found (required by rtms.node). Boot the container in CI rather than trusting a successful docker build.

Data retention: Transcripts and audit records contain client information and evidence of supervision. These usually have different retention requirements from each other. Store them separately so you can expire transcripts without destroying the audit chain.

False positives: Every alert the representative dismisses is a signal about pack quality. Log dismissals with the rule ID and review them on the same cadence you review the packs themselves.

Notice: Participants see Zoom's standard transcription notice. Confirm with counsel whether your jurisdiction requires additional notice that live compliance screening is running.

Scaling: Each active meeting holds one RTMS client and one media WebSocket in your process. The deterministic pass is CPU-bound and cheap; LLM adjudication is the cost driver, so keep packs tight and the suppression window in place.


Acceptance Criteria

Use this checklist to verify the implementation:

  • Webhook signature verification rejects invalid or stale requests
  • endpoint.url_validation is answered before signature verification runs
  • Callbacks and setTranscriptParams() are registered before join()
  • An explicit srcLanguage is set, so screening starts without the auto-detect delay
  • Duplicate rtms_stream_id webhooks are ignored
  • Stream failover (new rtms_stream_id, same meeting) calls leave() on the old client
  • onMediaConnectionInterrupted writes a coverage gap to the audit log
  • An invalid rule pack fails the service at startup rather than screening with partial rules
  • The rule pack version is pinned at session start and stamped on every audit record
  • Clean segments never reach the LLM
  • Repeat candidates for the same rule are suppressed within the 60-second window
  • A malformed or timed-out LLM response still produces an alert at reduced confidence
  • Disclosures move from pending to satisfied only, and store the satisfying quote
  • Audit chain verifies from genesis; tampering with any record breaks verification
  • Alerts render only for the assigned representative, never for other participants
  • WebSocket connections require valid JWT and clean up on disconnect
  • No SDK credentials in client bundles or logs