Integrating live classes into your platform

This guide is for developers adding live classes to an LMS, school system or any website: React, Next.js, plain HTML/JS, Vue, Angular, Svelte, WordPress, PHP/Laravel, Python/Django, .NET, Java, Go, Ruby, React Native and Flutter.

The video service is a library and a media server, not an app with its own database. You keep your users, classes, attendance, reports and recordings in your database and storage. The media server only runs the live class and tells your backend what happened.

 Your frontend                      Your backend (any language)              Media server (no database)
 ─────────────                      ───────────────────────────              ──────────────────────────
 1. user opens a lesson  ─────────► 2. check they're enrolled
                                       sign a join token (JWT) ────────────► verifies every token
 3. embed.js / SDK joins with it ──────────────────── wss + WebRTC ────────► runs the class
                                    4. webhook receiver ◄─────────────────── participant_joined/left,
                                       save to YOUR database                  room_finished (attendance,
                                    5. recording_ready ◄──────────────────── engagement, polls), recordings…
                                       copy the MP4 into YOUR storage

What you build:

# Piece Effort
1 An endpoint that returns a join token for a logged-in user ~20 lines
2 The class on a page: one script tag (iframe), or the SDK for a custom UI 5–50 lines
3 A webhook receiver that writes events into your database ~50 lines
4 Recording storage: copy the MP4 when recording_ready arrives, or give presigned upload URLs ~30 lines
5 (optional) Slide storage: an upload endpoint for the whiteboard's slides ~20 lines

Pick the easiest path that fits. Most platforms need only the first one or two:

Path You write Good for
Join links One backend function that returns a URL Any site, email invitations, mobile apps, "Join class" buttons
LTI 1.3 Nothing: register the tool in the LMS Moodle, Canvas, Blackboard, Brightspace, Schoology
Embed Token endpoint + one script tag The class inside your own page
SDK Token endpoint + your own UI A fully custom classroom

Get a key in a minute: open /console on the web app (e.g. https://meet.trovesuite.com/console). A sandbox key is free and self-service (up to 4 people, 30-minute classes, no recording); production keys need the console password from your operator. The console also sets your webhook URL and sends a signed test event to it. Starter projects for Next.js, Laravel, Django, WordPress and plain PHP are in examples/, and every server call is in the Postman collection at /postman/trovemeet.postman_collection.json.

What you get from your operator: a media server URL (e.g. https://media.trovesuite.com), an API key and an API secret (32+ characters; keep it on the server), and the web app URL that serves /embed.js and /sdk/*.js (e.g. https://meet.trovesuite.com). Your webhook URL is configured against your API key.


Contents

  1. Join links (no frontend code)
  2. Join tokens: Node, PHP, Python, Go, C#, Java, Ruby
  3. Put the class on a page: HTML/JS, React, Next.js, Vue, Angular, Svelte, WordPress, server-rendered pages, React Native, Flutter
  4. Store what happened in your database: webhooks, signature checks, a schema
  5. Recordings in your storage: S3, Azure Blob, local disk, presigned uploads, playback
  6. Slides in your storage
  7. Controlling classes from your backend 6b. Live streaming to large audiences 6c. Interactive lessons
  8. Several regions
  9. Installing the JavaScript packages
  10. Checklist and troubleshooting
  11. Developer keys, sandbox and versions

A join link opens the hosted classroom for one person. Your backend checks who they are and what they may do, then sends them to the link (redirect, button, email, push notification, a WebView in your app). Nothing to install on the frontend.

import { RtcServer } from "@trovesuite/rtc-server";

const rtc = new RtcServer({
  url: process.env.RTC_URL!,            // media server
  appUrl: process.env.RTC_APP_URL,      // hosted classroom, e.g. https://meet.trovesuite.com
  apiKey: process.env.RTC_API_KEY!,
  apiSecret: process.env.RTC_API_SECRET!,
});

// After checking enrollment:
const link = rtc.joinUrl({
  roomId: "biology-101-week-3",
  userId: user.id,
  name: user.fullName,
  role: user.isTeacher ? "host" : "student",
  room: { name: "Biology 101", waitingRoom: true, durationMinutes: 60 },
  primary: "#0f766e",                   // optional branding: appName, logo, primary
});
return Response.redirect(link, 303);

Any other language: a join link is just

{APP_URL}/join#token={join token}&url={media server URL}&name={display name}

with the values URL-encoded. Make the token as in section 1. Optional hash parameters: roomName, prejoin=0 (skip the camera check), appName, logo, primary (brand colour). The token is in the # part, which browsers never send to servers or put in referrers, and the page removes it from the address bar on load.

Links are personal: each carries one user's identity and role. Make them at the moment someone clicks "Join", not days ahead (the token lifetime is 6 hours by default).


1. Join tokens

A join token is an HS256 JWT signed with your API secret. Your backend issues it only after checking the user may join (enrolled in the class, teacher of it, parent with access…). The token decides the user's role; nothing in the browser can change it.

Claim Required Meaning
iss yes Your API key
sub yes Your user id (stable: rejoining with the same id replaces the old connection)
room yes Room id: letters, digits, _ . : -. Use your lesson or timetable-slot id, e.g. lesson-2026-11-02-jhs2-maths
exp yes Expiry (Unix seconds). Make it outlast the class plus reconnects: 6 hours is typical
name Display name
role host (teacher), cohost (co-teacher/TA), student (default), observer (watch only: parents, inspectors)
metadata Any string shown to others (e.g. JSON with an avatar URL)
roomConfig Settings applied when this token opens the room: { name, waitingRoom, permissions, maxParticipants, scheduledAt, durationMinutes, metadata }. Put your class's settings from your database into every token, so no setup call is needed
region Region to open the room in, when there are several media servers

Return { token, url } to the browser, where url is the media server URL.

Node.js / Next.js / Express / NestJS

import { RtcServer } from "@trovesuite/rtc-server";

const rtc = new RtcServer({ url: process.env.RTC_URL!, apiKey: process.env.RTC_API_KEY!, apiSecret: process.env.RTC_API_SECRET! });

// Next.js App Router: app/api/classes/[id]/token/route.ts
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const user = await getCurrentUser();                       // your auth
  const lesson = await db.lesson.findUnique({ where: { id } });
  if (!user || !lesson || !(await canJoin(user, lesson))) return new Response("Forbidden", { status: 403 });

  const { token, url } = rtc.createJoinToken({
    roomId: `lesson-${lesson.id}`,
    userId: user.id,
    name: user.fullName,
    role: lesson.teacherId === user.id ? "host" : "student",
    room: { name: lesson.title, waitingRoom: true, scheduledAt: lesson.startsAt, durationMinutes: lesson.minutes },
  });
  return Response.json({ token, url });
}

Express is the same body inside app.post("/api/classes/:id/token", …) with res.json({ token, url }).

PHP (Laravel, Symfony, WordPress, plain PHP)

composer require firebase/php-jwt

use Firebase\JWT\JWT;

function rtc_join_token(string $roomId, string $userId, string $name, string $role, array $roomConfig = []): array {
    $now = time();
    $claims = [
        'iss' => getenv('RTC_API_KEY'), 'sub' => $userId, 'room' => $roomId,
        'name' => $name, 'role' => $role, 'iat' => $now, 'exp' => $now + 6 * 3600,
    ];
    if ($roomConfig) $claims['roomConfig'] = $roomConfig;
    return ['token' => JWT::encode($claims, getenv('RTC_API_SECRET'), 'HS256'), 'url' => getenv('RTC_URL')];
}

// Laravel: routes/api.php
Route::post('/classes/{lesson}/token', function (App\Models\Lesson $lesson) {
    $user = auth()->user();
    abort_unless($user && $lesson->canJoin($user), 403);
    return rtc_join_token("lesson-{$lesson->id}", (string) $user->id, $user->name,
        $lesson->teacher_id === $user->id ? 'host' : 'student',
        ['name' => $lesson->title, 'waitingRoom' => true]);
})->middleware('auth:sanctum');

Python (Django, Flask, FastAPI)

pip install pyjwt

import os, time, jwt

def rtc_join_token(room_id, user_id, name, role="student", room_config=None):
    now = int(time.time())
    claims = {"iss": os.environ["RTC_API_KEY"], "sub": str(user_id), "room": room_id,
              "name": name, "role": role, "iat": now, "exp": now + 6 * 3600}
    if room_config:
        claims["roomConfig"] = room_config
    return {"token": jwt.encode(claims, os.environ["RTC_API_SECRET"], algorithm="HS256"), "url": os.environ["RTC_URL"]}

# FastAPI
@app.post("/classes/{lesson_id}/token")
def class_token(lesson_id: int, user=Depends(current_user)):
    lesson = get_lesson(lesson_id)
    if not lesson.can_join(user):
        raise HTTPException(403)
    return rtc_join_token(f"lesson-{lesson.id}", user.id, user.full_name,
                          "host" if lesson.teacher_id == user.id else "student",
                          {"name": lesson.title, "waitingRoom": True})

# Django: same function, returned with JsonResponse(...) from a @login_required view.

Go

import "github.com/golang-jwt/jwt/v5"

func rtcJoinToken(roomID, userID, name, role string) (map[string]string, error) {
	now := time.Now()
	claims := jwt.MapClaims{"iss": os.Getenv("RTC_API_KEY"), "sub": userID, "room": roomID, "name": name, "role": role,
		"iat": now.Unix(), "exp": now.Add(6 * time.Hour).Unix(),
		"roomConfig": map[string]any{"waitingRoom": true}}
	tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(os.Getenv("RTC_API_SECRET")))
	return map[string]string{"token": tok, "url": os.Getenv("RTC_URL")}, err
}

C# / .NET

dotnet add package System.IdentityModel.Tokens.Jwt

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;

static object RtcJoinToken(string roomId, string userId, string name, string role)
{
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("RTC_API_SECRET")!));
    var token = new JwtSecurityToken(
        issuer: Environment.GetEnvironmentVariable("RTC_API_KEY"),
        claims: new[] { new Claim("sub", userId), new Claim("room", roomId), new Claim("name", name), new Claim("role", role) },
        expires: DateTime.UtcNow.AddHours(6),
        signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
    return new { token = new JwtSecurityTokenHandler().WriteToken(token), url = Environment.GetEnvironmentVariable("RTC_URL") };
}

// Minimal API
app.MapPost("/classes/{id}/token", [Authorize] (int id, ClaimsPrincipal user, AppDb db) => { /* check, then */ return Results.Ok(RtcJoinToken($"lesson-{id}", userId, name, role)); });

For roomConfig with .NET, add new Claim("roomConfig", JsonSerializer.Serialize(config), JsonClaimValueTypes.Json).

Java (Spring Boot)

io.jsonwebtoken:jjwt-api + jjwt-impl + jjwt-jackson

SecretKey key = Keys.hmacShaKeyFor(System.getenv("RTC_API_SECRET").getBytes(StandardCharsets.UTF_8));
String token = Jwts.builder()
    .issuer(System.getenv("RTC_API_KEY")).subject(userId)
    .claim("room", roomId).claim("name", name).claim("role", role)
    .claim("roomConfig", Map.of("name", lessonTitle, "waitingRoom", true))
    .issuedAt(new Date()).expiration(Date.from(Instant.now().plus(6, ChronoUnit.HOURS)))
    .signWith(key, Jwts.SIG.HS256).compact();
return Map.of("token", token, "url", System.getenv("RTC_URL"));

Ruby (Rails)

gem "jwt"

def rtc_join_token(room_id, user, role)
  now = Time.now.to_i
  claims = { iss: ENV["RTC_API_KEY"], sub: user.id.to_s, room: room_id, name: user.name, role: role, iat: now, exp: now + 6 * 3600 }
  { token: JWT.encode(claims, ENV["RTC_API_SECRET"], "HS256"), url: ENV["RTC_URL"] }
end

2. Put the class on a page

Two ways:

In every example, /api/classes/:id/token is your endpoint from step 1, returning { token, url }.

Plain HTML / JavaScript: embed

<div id="live-class" style="height: 640px"></div>
<script src="https://meet.trovesuite.com/embed.js"></script>
<script>
  const call = TroveMeet.mount("#live-class", {
    getToken: () => fetch("/api/classes/42/token", { method: "POST", credentials: "include" }).then((r) => r.json()),
    name: "Ama Mensah",          // shown on the join screen
    primary: "#0f766e",           // your colour
    logo: "https://school.example/logo.svg",
    // prejoin: false,            // skip the camera check screen
    onEvent: (type, data) => {
      // ready, waiting, joined, left, participantJoined, participantLeft, handRaised, chatMessage, error
      if (type === "left") location.href = "/lessons/42";
    },
  });
  // call.leave(); call.setMicrophone(false); call.setCamera(true); call.raiseHand(); call.destroy();
</script>

The token goes to the iframe by postMessage, never in the URL. The iframe is created with allow="camera; microphone; display-capture; fullscreen; autoplay".

Plain HTML / JavaScript: SDK, your own UI (no build step)

<div id="videos"></div>
<button id="mic">Mute</button> <button id="hand">Raise hand</button>

<script type="module">
  import { Room, attachTrack } from "https://meet.trovesuite.com/sdk/rtc-client.js";

  const { token, url } = await fetch("/api/classes/42/token", { method: "POST" }).then((r) => r.json());
  const room = new Room();

  room.on("trackSubscribed", (track, source, participant) => {
    const el = document.createElement(track.kind === "video" ? "video" : "audio");
    el.id = `${participant.id}-${source}`;
    document.getElementById("videos").append(el);
    attachTrack(track, el);
  });
  room.on("trackUnsubscribed", (_t, source, p) => document.getElementById(`${p.id}-${source}`)?.remove());
  room.on("participantJoined", (p) => console.log(`${p.name} joined`));
  room.on("chatMessage", (m) => console.log(`${m.fromName}: ${m.text}`));
  room.on("disconnected", (reason) => console.log("left:", reason));

  await room.connect(url, token, { audio: true, video: true });

  document.getElementById("mic").onclick = () => room.localParticipant.setMicrophoneEnabled(!room.localParticipant.isMicrophoneEnabled);
  document.getElementById("hand").onclick = () => room.localParticipant.raiseHand();
</script>

Background effects load MediaPipe on demand. Add an import map if you use them from the plain bundle:

<script type="importmap">{ "imports": { "@mediapipe/tasks-vision": "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/+esm" } }</script>

Most-used SDK calls:

Task Call
Camera / mic / screen room.localParticipant.setCameraEnabled(true), setMicrophoneEnabled(false), startScreenShare({ audio: true })
Chat room.chat.send("Hi"), room.chat.send("Psst", { to: teacherId }), event chatMessage
Hands raiseHand(), lowerHand(), room.handQueue, events handRaised, handQueueChanged
Polls / Q&A room.polls.create(q, options), room.polls.vote(id, i), room.qa.ask(text)
Teacher controls room.host.mute(id), muteAll(), remove(id), endRoom(), admit(id), lock(), setPermissions({ chat: false }), startRecording()
Breakout rooms room.host.breakouts.create({ count: 4, assign: "random", durationMinutes: 10 }), .start(), .close()
Captions room.captions.enable() (teacher), event captionReceived
Effects room.localParticipant.setVideoProcessor(BackgroundBlur())
Data saver room.setSubscriptionPolicy("none" | "hostOnly" | "all")
Everything changed event change (re-render)

React (Vite, Create React App, Remix)

Embed (any React app, no Tailwind needed):

import { useEffect, useRef } from "react";

declare global { interface Window { TroveMeet: any } }

function loadEmbed(src: string) {
  return new Promise<void>((resolve) => {
    if (window.TroveMeet) return resolve();
    const s = document.createElement("script");
    s.src = src;
    s.onload = () => resolve();
    document.head.append(s);
  });
}

export function LiveClass({ lessonId, onLeave }: { lessonId: string; onLeave?: () => void }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    let call: any;
    loadEmbed("https://meet.trovesuite.com/embed.js").then(() => {
      call = window.TroveMeet.mount(ref.current, {
        getToken: () => fetch(`/api/classes/${lessonId}/token`, { method: "POST" }).then((r) => r.json()),
        onEvent: (type: string) => type === "left" && onLeave?.(),
      });
    });
    return () => call?.destroy();
  }, [lessonId]);
  return <div ref={ref} style={{ height: "80vh" }} />;
}

Components (full control, Tailwind v4):

import { useEffect, useState } from "react";
import { Room } from "@trovesuite/rtc-client";
import { Classroom, PreJoin, RtcThemeProvider, type PreJoinResult } from "@trovesuite/rtc-react";

export function LiveClass({ lessonId }: { lessonId: string }) {
  const [room, setRoom] = useState<Room>();
  const join = async ({ tracks, audioOnly }: PreJoinResult) => {
    const { token, url } = await fetch(`/api/classes/${lessonId}/token`, { method: "POST" }).then((r) => r.json());
    const r = new Room();
    await r.connect(url, token, { tracks, audioOnly });
    setRoom(r);
  };
  useEffect(() => () => room?.disconnect(), [room]);
  return (
    <RtcThemeProvider appName="My School" primaryColor="#0f766e" logo="/logo.svg">
      <div style={{ height: "100vh" }}>
        {room ? <Classroom room={room} onLeave={() => setRoom(undefined)} /> : <PreJoin nameEditable={false} onJoin={join} />}
      </div>
    </RtcThemeProvider>
  );
}

Add the components to Tailwind's sources (in your CSS): @source "../node_modules/@trovesuite/rtc-react/src"; (and rtc-player/src if you use the player). The components depend on react-icons only. Lower-level hooks for your own design: useParticipants, useLocalParticipant, useChat, useHandQueue, usePolls, useQuestions, useActiveSpeakers, useRoomEvent, with VideoTile, GridLayout, SpeakerLayout, ControlBar.

Next.js (App Router)

  1. Token route as in step 1 (app/api/classes/[id]/token/route.ts).
  2. A client component (the React example above) on the lesson page:
// app/lessons/[id]/live/page.tsx
import LiveClass from "./LiveClass"; // "use client" component from the React example
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return <LiveClass lessonId={id} />;
}
  1. next.config.ts: transpilePackages: ["@trovesuite/rtc-client", "@trovesuite/rtc-react", "@trovesuite/rtc-whiteboard", "@trovesuite/rtc-protocol", "@trovesuite/rtc-player"] (they ship TypeScript sources).
  2. Allow the camera: Permissions-Policy: camera=(self), microphone=(self), display-capture=(self) (or omit the header; the default allows them on your own origin).
  3. Webhook receiver at app/api/rtc/webhook/route.ts: see step 3.

apps/web in this repo is a complete Next.js example: token route, embed page, class report and recording pages.

Vue 3 / Nuxt

<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from "vue";
const props = defineProps<{ lessonId: string }>();
const el = ref<HTMLDivElement>();
let call: any;

onMounted(async () => {
  await new Promise((ok) => { const s = document.createElement("script"); s.src = "https://meet.trovesuite.com/embed.js"; s.onload = ok; document.head.append(s); });
  call = (window as any).TroveMeet.mount(el.value, {
    getToken: () => fetch(`/api/classes/${props.lessonId}/token`, { method: "POST" }).then((r) => r.json()),
  });
});
onBeforeUnmount(() => call?.destroy());
</script>

<template><div ref="el" style="height: 80vh" /></template>

For a custom UI, use @trovesuite/rtc-client in a composable: create the Room, keep a shallowRef, and bump a counter on room.on("change", …) so the template re-renders. In Nuxt, mount it client-only (<ClientOnly>).

Angular

@Component({ selector: "app-live-class", standalone: true, template: `<div #host style="height:80vh"></div>` })
export class LiveClassComponent implements AfterViewInit, OnDestroy {
  @Input() lessonId!: string;
  @ViewChild("host") host!: ElementRef<HTMLDivElement>;
  private call: any;
  constructor(private http: HttpClient) {}

  async ngAfterViewInit() {
    await new Promise((ok) => { const s = document.createElement("script"); s.src = "https://meet.trovesuite.com/embed.js"; s.onload = ok; document.head.append(s); });
    this.call = (window as any).TroveMeet.mount(this.host.nativeElement, {
      getToken: () => firstValueFrom(this.http.post<{ token: string; url: string }>(`/api/classes/${this.lessonId}/token`, {})),
    });
  }
  ngOnDestroy() { this.call?.destroy(); }
}

For a custom UI, wrap Room from @trovesuite/rtc-client in a service and call ChangeDetectorRef.markForCheck() (or update a signal) on room.on("change").

Svelte / SvelteKit

<script lang="ts">
  import { onMount } from "svelte";
  export let lessonId: string;
  let el: HTMLDivElement;
  onMount(() => {
    let call: any;
    const s = document.createElement("script");
    s.src = "https://meet.trovesuite.com/embed.js";
    s.onload = () => (call = (window as any).TroveMeet.mount(el, {
      getToken: () => fetch(`/api/classes/${lessonId}/token`, { method: "POST" }).then((r) => r.json()),
    }));
    document.head.append(s);
    return () => call?.destroy();
  });
</script>
<div bind:this={el} style="height: 80vh" />

WordPress

A tiny plugin: a shortcode for the page and a REST route for the token.

<?php
/* Plugin Name: Live Classes */
require __DIR__ . '/vendor/autoload.php';   // firebase/php-jwt
use Firebase\JWT\JWT;

add_action('rest_api_init', function () {
    register_rest_route('live/v1', '/token/(?P<room>[A-Za-z0-9_.:-]+)', [
        'methods' => 'POST',
        'permission_callback' => fn() => is_user_logged_in(),   // add your course-access check
        'callback' => function ($req) {
            $u = wp_get_current_user();
            $now = time();
            return [
                'token' => JWT::encode(['iss' => RTC_API_KEY, 'sub' => (string) $u->ID, 'room' => $req['room'], 'name' => $u->display_name,
                    'role' => user_can($u, 'edit_posts') ? 'host' : 'student', 'iat' => $now, 'exp' => $now + 21600], RTC_API_SECRET, 'HS256'),
                'url' => RTC_URL,
            ];
        },
    ]);
});

add_shortcode('live_class', function ($atts) {
    $room = esc_attr($atts['room'] ?? '');
    $nonce = wp_create_nonce('wp_rest');
    return "<div id='lc-$room' style='height:640px'></div>
      <script src='https://meet.trovesuite.com/embed.js'></script>
      <script>TroveMeet.mount('#lc-$room', { getToken: () => fetch('/wp-json/live/v1/token/$room', { method: 'POST', headers: { 'X-WP-Nonce': '$nonce' } }).then(r => r.json()) });</script>";
});

Put define('RTC_API_KEY', …), RTC_API_SECRET and RTC_URL in wp-config.php, then use [live_class room="lesson-42"] in any page.

Server-rendered pages (Laravel Blade, Django templates, Rails ERB, ASP.NET Razor)

Render the token into the page from the controller (it's short-lived and for this user only):

<div id="live-class" style="height:640px"></div>
<script src="https://meet.trovesuite.com/embed.js"></script>
<script>
  TroveMeet.mount("#live-class", { getToken: async () => (@json($join)) });  {{-- $join = rtc_join_token(...) --}}
</script>

Django: {{ join|json_script:"join" }} then getToken: async () => JSON.parse(document.getElementById("join").textContent). Rails: <%= raw @join.to_json %>. Razor: @Html.Raw(Json.Serialize(Model.Join)).

React Native / Expo (and other mobile WebViews)

Show the hosted embed page in a WebView, passing the token in the URL hash:

import { WebView } from "react-native-webview";

<WebView
  source={{ uri: `https://meet.trovesuite.com/embed#token=${token}&url=${encodeURIComponent(url)}&prejoin=0` }}
  mediaPlaybackRequiresUserAction={false}
  allowsInlineMediaPlayback
  mediaCapturePermissionGrantType="grant"
  onMessage={(e) => console.log(e.nativeEvent.data)}
/>

Android: request CAMERA and RECORD_AUDIO at runtime (expo-camera/PermissionsAndroid) before opening it. iOS: add NSCameraUsageDescription and NSMicrophoneUsageDescription.

Flutter (Android and iOS)

Use the native SDK in packages/rtc_flutter:

final res = await http.post(Uri.parse('$api/classes/42/token'), headers: auth);
final j = jsonDecode(res.body);
final room = Room();
await room.connect(j['url'], j['token']);
Navigator.push(context, MaterialPageRoute(builder: (_) => ClassroomView(room: room)));

See packages/rtc_flutter/README.md for permissions and what's available on mobile.

LTI: Moodle, Canvas, Blackboard, Brightspace, Schoology

The web app is an LTI 1.3 tool, so any LMS that supports LTI adds live classes with no code. Each course activity gets its own room; the LMS's roles decide the classroom role:

LMS role Classroom role
Instructor, Administrator, Content Developer host
Teaching Assistant cohost
Mentor (parents, observers) observer
Learner and everyone else student

Tool URLs (also at {APP_URL}/lti/config):

Field in the LMS Value
Tool URL / Target link URI / Redirect URI {APP_URL}/lti/launch
OpenID Connect login / Initiate login URL {APP_URL}/lti/login
Public keyset (JWKS) URL {APP_URL}/lti/jwks
Deep linking URL {APP_URL}/lti/launch
Custom parameters (optional) duration_minutes=60, waiting_room=true

Platform values the operator puts in the web app's LTI_PLATFORMS (a JSON array, one entry per LMS):

[
  {
    "name": "Greenfield Moodle",
    "issuer": "https://moodle.greenfield.edu",
    "clientId": "Ab12Cd34",
    "deploymentIds": ["3"],
    "authLoginUrl": "https://moodle.greenfield.edu/mod/lti/auth.php",
    "authTokenUrl": "https://moodle.greenfield.edu/mod/lti/token.php",
    "jwksUrl": "https://moodle.greenfield.edu/mod/lti/certs.php",
    "gradeAttendance": true
  }
]
LMS issuer authLoginUrl authTokenUrl jwksUrl
Moodle your site URL {site}/mod/lti/auth.php {site}/mod/lti/token.php {site}/mod/lti/certs.php
Canvas (cloud) https://canvas.instructure.com https://sso.canvaslms.com/api/lti/authorize_redirect https://sso.canvaslms.com/login/oauth2/token https://sso.canvaslms.com/api/lti/security/jwks
Blackboard Learn https://blackboard.com https://developer.blackboard.com/api/v1/gateway/oidcauth https://developer.blackboard.com/api/v1/gateway/oauth2/jwttoken https://developer.blackboard.com/api/v1/management/applications/{appId}/jwks.json
D2L Brightspace https://{your}.brightspace.com {site}/d2l/lti/authenticate https://auth.brightspace.com/core/connect/token {site}/d2l/.well-known/jwks
Schoology https://schoology.schoology.com https://lti-service.svc.schoology.com/lti-service/authorize-redirect https://lti-service.svc.schoology.com/lti-service/access-token https://lti-service.svc.schoology.com/lti-service/.well-known/jwks

The LMS's tool registration screen shows the exact values for your site (and the client id and deployment id): copy them from there.

Attendance grades. With "gradeAttendance": true and the LMS's grade service turned on, each student's attendance (percent of the class attended) is posted to the activity's gradebook column when the class ends. This uses the room_finished webhook, so point the web app's own API key webhook at {APP_URL}/api/webhooks/rtc. Nothing is stored in between: the gradebook address rides in the room's metadata.

Keys and settings for the operator: LTI_PRIVATE_KEY (RSA private key, PKCS#8 PEM: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048), optional LTI_KEY_ID, APP_URL (must match what is registered), and EMBED_ALLOWED_ORIGINS must allow the LMS sites (default *). A platform entry may carry its own apiKey and apiSecret to keep that school's classes in its own tenant.


3. Store what happened in your database

The media server forgets a class shortly after it ends (RTC_FINISHED_RETENTION, 1 hour by default). Everything worth keeping arrives at your webhook URL as signed POST requests:

{ "id": "evt_…", "event": "participant_left", "createdAt": 1793260000000, "roomId": "lesson-42",
  "participant": { "id": "stu-17", "name": "Kofi", "role": "student" },
  "data": { "sessionId": "ses_…", "joinedAt": "…", "leftAt": "…", "durationSeconds": 2580, "engagement": { … } } }
Event data Typical use
room_started mark the lesson live
participant_waiting notify the teacher
participant_joined sessionId, runId, joinedAt, parentRoomId? attendance row
participant_left + leftAt, durationSeconds, reason, engagement close the attendance row, store engagement
hand_raised participation points
chat_message messageId, text, to?, private, sentAt, viewer? chat archive, moderation (viewer: {userId, name} and no participant for live-stream viewers)
question_asked questionId, text, viewer? Q&A archive
poll_answered pollId, option quiz marks
poll_closed pollId, question, results quiz results
room_finished runId, startedAt, endedAt, attendance[], sessions[], engagement[], talkTime, summary{polls, topQuestions} the class record: attendance with late/left-early flags, per-student engagement, teacher-vs-student talk time
recording_started recordingId
recording_ready recordingId, durationSeconds, sizeBytes, mediaUrl, timelineUrl, expiresAt (or uploaded: true) copy the files into your storage (step 4)
recording_failed recordingId, error alert
live_started liveStreamId, playbackUrl, feedUrl, startedBy show "Watch live" on the course page
live_ended liveStreamId, reason (stopped / failed), durationSeconds hide it again

parentRoomId is set for events from breakout rooms (lesson-42~br-1); count them toward the main class.

Rules for a reliable receiver: verify the signature, answer 2xx quickly (do slow work in a queue), and make it idempotent on id: failed deliveries are retried up to 5 times.

Verifying the signature

Header X-RTC-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>. Use the raw body, and reject timestamps older than 5 minutes.

Node / Next.js

import { RtcServer, type TypedWebhookEvent } from "@trovesuite/rtc-server";

export async function POST(req: Request) {
  const raw = await req.text();
  let event: TypedWebhookEvent;
  try { event = rtc.webhooks.verify(raw, req.headers.get("x-rtc-signature")); }
  catch { return new Response("bad signature", { status: 401 }); }
  await queue.add("rtc-event", event);   // or handle inline if quick
  return new Response("ok");
}

PHP

$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_RTC_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $raw, getenv('RTC_API_SECRET'));
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) $sig['t']) > 300) { http_response_code(401); exit; }
$event = json_decode($raw, true);

Python

import hmac, hashlib, time, json

def verify(raw: bytes, header: str, secret: str) -> dict:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts["t"])) > 300:
        raise ValueError("bad signature")
    return json.loads(raw)
# Django: verify(request.body, request.headers["X-RTC-Signature"], secret)   FastAPI: await request.body()

Go

func verify(raw []byte, header, secret string) error {
	parts := map[string]string{}
	for _, kv := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(kv, "="); ok { parts[k] = v }
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(parts["t"] + "."))
	mac.Write(raw)
	t, _ := strconv.ParseInt(parts["t"], 10, 64)
	if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"])) || math.Abs(float64(time.Now().Unix()-t)) > 300 {
		return errors.New("bad signature")
	}
	return nil
}

C#

var parts = header.Split(',').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1]);
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(mac.ComputeHash(Encoding.UTF8.GetBytes(parts["t"] + "." + rawBody))).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(parts["v1"]))) return Results.Unauthorized();

A schema to start from (PostgreSQL; adapt to MySQL/SQL Server)

CREATE TABLE live_events (            -- raw log; also your idempotency check
  id          text PRIMARY KEY,       -- webhook "id"
  event       text NOT NULL,
  room_id     text NOT NULL,
  payload     jsonb NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE live_sessions (          -- from participant_joined / participant_left
  session_id  text PRIMARY KEY,
  lesson_id   bigint NOT NULL,        -- yours, parsed from room_id
  user_id     text NOT NULL,
  role        text NOT NULL,
  joined_at   timestamptz NOT NULL,
  left_at     timestamptz,
  engagement  jsonb                   -- chat, hands, polls, questions, talk/camera/mic seconds
);

CREATE TABLE live_class_runs (        -- from room_finished
  run_id      text PRIMARY KEY,
  lesson_id   bigint NOT NULL,
  started_at  timestamptz NOT NULL,
  ended_at    timestamptz NOT NULL,
  attendance  jsonb NOT NULL,         -- one row per student with late / leftEarly
  engagement  jsonb NOT NULL,
  talk_time   jsonb NOT NULL,
  summary     jsonb NOT NULL          -- polls with results, top questions
);

CREATE TABLE live_recordings (        -- from recording_ready, after you copied the file
  recording_id text PRIMARY KEY,
  lesson_id    bigint NOT NULL,
  storage_key  text NOT NULL,         -- where YOU put the MP4
  timeline_key text,
  duration_s   integer NOT NULL,
  size_bytes   bigint NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);
// Handling the events (any ORM; shown with a generic db)
switch (event.event) {
  case "participant_joined":
    await db.liveSessions.insert({ sessionId: event.data.sessionId, lessonId: lessonFrom(event.roomId), userId: event.participant!.id, role: event.participant!.role, joinedAt: event.data.joinedAt });
    break;
  case "participant_left":
    await db.liveSessions.update(event.data.sessionId, { leftAt: event.data.leftAt, engagement: event.data.engagement });
    break;
  case "room_finished":
    if (!event.data.parentRoomId) await db.liveClassRuns.upsert({ runId: event.data.runId, lessonId: lessonFrom(event.roomId), ...event.data });
    break;
  case "recording_ready":
    await copyRecordingToStorage(event);   // step 4
    break;
}

lessonFrom("lesson-42~br-1") should strip the ~br-N suffix.

If you'd rather compute attendance yourself from live_sessions (e.g. across a term), summarizeAttendance(rows, { scheduledAt, durationMinutes }) from @trovesuite/rtc-server gives one row per student with total time and late / left-early flags. calendarInvite({ uid, title, start, durationMinutes, joinUrl }) builds an .ics file for reminders.


4. Recordings in your storage

The teacher starts recording from the classroom (or you call rtc.recordings.start(roomId)). When it stops, the server composes an MP4 (shared screen with the teacher's camera picture-in-picture, everyone's audio; students' cameras aren't recorded by default) and a timeline.json (chat, whiteboard and slides, polls, captions, chapters).

Option A: copy it when recording_ready arrives (default)

The webhook has signed mediaUrl and timelineUrl links, valid until expiresAt (RTC_RECORDING_TTL, 72 hours by default). After that the files are deleted from the media server.

Node to S3

import { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { downloadRecording } from "@trovesuite/rtc-server";
import { Readable } from "node:stream";

async function copyRecordingToStorage(e) {
  const key = `recordings/${e.roomId}/${e.data.recordingId}`;
  for (const [file, url] of [["recording.mp4", e.data.mediaUrl], ["timeline.json", e.data.timelineUrl]]) {
    const { body, contentType } = await downloadRecording(url);
    await new Upload({ client: s3, params: { Bucket: "school-media", Key: `${key}/${file}`, Body: Readable.fromWeb(body), ContentType: contentType } }).done();
  }
  await db.liveRecordings.insert({ recordingId: e.data.recordingId, lessonId: lessonFrom(e.roomId), storageKey: `${key}/recording.mp4`, timelineKey: `${key}/timeline.json`, durationS: e.data.durationSeconds, sizeBytes: e.data.sizeBytes });
}

Node to Azure Blob: await containerClient.getBlockBlobClient(${key}/${file}).uploadStream(Readable.fromWeb(body), 4 << 20, 4, { blobHTTPHeaders: { blobContentType: contentType } }).

Python to local disk or any storage

import requests, shutil
def copy_recording(e):
    for name, url in (("recording.mp4", e["data"]["mediaUrl"]), ("timeline.json", e["data"]["timelineUrl"])):
        with requests.get(url, stream=True, timeout=60) as r, open(f"/srv/media/{e['data']['recordingId']}-{name}", "wb") as f:
            r.raise_for_status(); shutil.copyfileobj(r.raw, f)

PHP: copy($e['data']['mediaUrl'], storage_path("app/recordings/{$id}.mp4")); or stream it to S3 with the AWS SDK's upload().

Do the copy in a background job: a long lesson's MP4 can be several hundred MB.

Option B: the server uploads straight to you

Start the recording from your backend with presigned upload URLs; the server PUTs the files there and recording_ready arrives with uploaded: true.

// S3
const put = (Key: string) => getSignedUrl(s3, new PutObjectCommand({ Bucket: "school-media", Key }), { expiresIn: 6 * 3600 });
await rtc.recordings.start("lesson-42", { upload: { mediaUrl: await put("rec/l42.mp4"), timelineUrl: await put("rec/l42.json") } });

// Azure Blob (SAS with create + write)
await rtc.recordings.start("lesson-42", {
  upload: { mediaUrl: blobSasUrl("rec/l42.mp4"), timelineUrl: blobSasUrl("rec/l42.json"), headers: { "x-ms-blob-type": "BlockBlob" } },
});

Make the URLs last longer than the class plus composing time (a few hours).

Playback for students

Serve the files from your storage with your own access checks (signed URLs, your CDN), and play them with the player component:

import { RecordingPlayer } from "@trovesuite/rtc-player";
<RecordingPlayer src={signedMp4Url} timelineUrl={signedTimelineUrl} watermark={student.name} />

Without the player, the MP4 plays in any <video> tag; the timeline JSON is documented by the Timeline type in rtc-player.


5. Slides in your storage

When a teacher presents a PDF, each page is rendered in the browser and uploaded. By default slides go to the media server's temporary storage (kept 7 days). To keep them in your storage, pass an upload function when connecting:

await room.connect(url, token, {
  uploadFile: async (file) => {
    const form = new FormData();
    form.append("file", file);
    const r = await fetch(`/api/lessons/42/slides`, { method: "POST", body: form });   // your endpoint saves to S3/Azure/LMS
    return (await r.json()).url;   // a URL every student's browser can load (public or signed, long enough for the class)
  },
});

The URL must be https:// and its server must allow cross-origin image loads (Access-Control-Allow-Origin covering your site), because the whiteboard draws it on a canvas. With the embed, slides use the server's temporary storage.


6. Controlling classes from your backend

The server API takes Authorization: Bearer <admin token>, an HS256 JWT { "iss": apiKey, "admin": true, "exp": now + 300 } signed with your secret. rtc-server makes it for you; in other languages sign it like the join token.

Do Call (Node) HTTP
End a class rtc.rooms.end(roomId) POST /v1/rooms/{id}/end
Who's in now rtc.rooms.participants(roomId) GET /v1/rooms/{id}/participants
Remove / mute rtc.rooms.removeParticipant(id, uid), muteParticipant(id, uid) POST /v1/rooms/{id}/participants/{uid}/remove / mute
Start / stop recording rtc.recordings.start(id, { upload? }), stop(id) POST /v1/rooms/{id}/recordings, …/recordings/stop
Start / stop / read a live stream rtc.liveStreams.start(id), stop(id), get(id) POST /v1/rooms/{id}/livestream, …/livestream/stop, GET /v1/rooms/{id}/livestream
Live report rtc.reports.get(roomId) GET /v1/rooms/{id}/report
Live engagement rtc.analytics.participant(roomId, uid) GET /v1/rooms/{id}/analytics?userId=
Attendance so far rtc.attendance.list(roomId) GET /v1/rooms/{id}/attendance (?format=csv)
Preset settings rtc.rooms.create({ id, name, waitingRoom, permissions }) POST /v1/rooms (kept in memory; prefer roomConfig in tokens)

These read the server's memory, so they work while a class runs and for an hour after. For history, use your database (step 3).

# curl, with an admin token made by your backend
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" https://media.trovesuite.com/v1/rooms/lesson-42/end

6b. Live streaming to large audiences

An interactive class holds a few hundred people (RTC_MAX_PARTICIPANTS, 500 is fine on a 4 vCPU server). For an assembly, an open day or a lecture to a whole school, go live: the media server turns the class into an ordinary HLS video stream (the screen share, else the spotlight or whoever is talking; the teachers' and speakers' voices mixed) and a CDN delivers it, so 10,000 or 100,000 viewers cost the media server almost nothing. Viewers are a few seconds behind, can chat and ask questions, and can't unmute. The people in the class itself are unaffected.

Start it from the class (More → Go live, teachers only), or from your backend:

const live = await rtc.liveStreams.start("lesson-42");
// { id, playbackUrl: "https://live.example.com/live/live_…/index.m3u8", feedUrl: ".../feed.json", postUrl: "https://media…/live/live_…" }
await rtc.liveStreams.stop("lesson-42"); // also stops when the class ends

The live_started webhook carries the same links, so your course page can switch to "Watch live".

Show it. The stream's id is unguessable: whoever has playbackUrl can watch, so hand it to the people allowed to (or put your CDN's token authentication in front).

Chat and questions for viewers. Poll feedUrl every few seconds (JSON with the stream's status, the class chat and the questions, cached for 2 s, so it's cheap through the CDN too). Post with an ordinary join token for the class, which your backend issues after its usual checks (role: "observer" is a good choice, so the token can't unmute if used to join):

await fetch(`${live.postUrl}/chat`, { method: "POST", headers: { Authorization: `Bearer ${viewerToken}`, "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
await fetch(`${live.postUrl}/questions`, { method: "POST", headers: { Authorization: `Bearer ${viewerToken}`, "Content-Type": "application/json" }, body: JSON.stringify({ text }) });

Their messages and questions reach the class marked Watching live. Each viewer can post about one message every 3 seconds; chat follows the class's chat setting.

The CDN (Azure Front Door, Cloudflare, CloudFront…): add an endpoint whose origin is the media server, cache /live/* honouring the origin's Cache-Control (the playlist 1 s, feed.json 2 s, segments a day), and set RTC_LIVE_CDN_URL on the media server to the endpoint's /live URL, e.g. https://live.example.com/live. Without it, viewers fetch from the media server directly, which is fine for a few hundred. POSTs go to the media server (postUrl), not the CDN.

Setting Default
RTC_LIVE_CDN_URL CDN URL for /live/ (one per node with several nodes)
RTC_LIVE_KBPS 2500 Stream video bitrate (720p)
RTC_LIVE_TTL 15m How long a finished stream's last segments stay

A live stream encodes one 720p H.264 stream with FFmpeg (about one CPU core) on the node that runs the class. The stream shows cameras and screens sent as VP8 (Chrome, Edge and Firefox); video from a browser that negotiated H.264 (Safari may) shows as a plain dark frame, so present from Chrome or Edge. It doesn't keep a recording: record the class as well if you want one.


6c. Interactive lessons

Facilitators run an interactive lesson on the class stage from Interactive lessons in the controls. Only facilitators move between screens, drag words, flip cards and reveal answers; students answer aloud and see every change as it happens (including a word being dragged). Facilitators also see private notes for each screen: its purpose, what to say and the answers. Someone who joins late lands on the same screen with the same changes.

A demo lesson, Suggesting an activity (beginner, 15 screens), is built in. To run your own, open a lesson file (JSON) from the same panel, or pass lessons to the classroom:

import { Classroom, demoLesson, type Lesson } from "@trovesuite/rtc-react";
<Classroom room={room} lessons={[demoLesson, myLesson]} />

A lesson is a title and a list of screens. Every screen has kind, title, section (the small label under the title) and optional notes (purpose, instructions, answers), plus:

kind Fields What the facilitator does
cover heading, subtitle, eyebrow Title screen with the facilitator's camera
images items: [{ caption, image or emoji }] Click a photo (or Show phrases) to reveal its caption
objectives heading, items Static
board chips Drag words anywhere; add text boxes
sort prompt, zones: [{ id, label }], chips: [{ text, zone }] Drag words from the tray into zones; Show answers
flip cards: [{ front, hint, back }] Click a card (or Flip cards) to turn it
match pairs: [{ prompt, reply, emoji }], order Drag each reply beside its prompt; a full row swaps
order prompt, rows: [{ emoji, words, start }] Reorder words within each row (words is the answer, start the scrambled order)
gapfill lines: [{ speaker, text }] with ___ gaps, bank, answers, image or emoji Hint opens the word bank; drag phrases into gaps
dialogue lines: [{ a, b }], order Drag B's lines into place between A's
steps steps Static, with a large camera frame
collage items: [{ label, image or emoji }] Click a photo to show it large to everyone
planner days: [{ day, am, pm, sample }] Type the student's ideas; Sample answers fills the models

On every screen the facilitator can also draw with a pen or highlighter and erase (the bar under the slide); students see each stroke as it is drawn. Dragging a word opens a gap where it will land, for everyone. On photo screens (images, collage, gapfill) the facilitator can put in a photo during the class (the photo button on each one); Save lesson file in the lessons panel downloads the lesson with those photos, to open next time. Uploaded files are kept for RTC_FILES_TTL (a year in the Oracle deployment); for photos you keep for good, put https links from your own storage in the lesson file.

On the planner screen students can record a voice note (up to 3 minutes), listen back and send it to the facilitator. It arrives as a private chat message with a player, and reaches your backend through the chat_message webhook (the text is 🎙️ Voice note: <url>), so you can save it to the learner's record. Students may upload voice notes only (audio, 10 MB at most); observers can't upload.

src/lesson/demoLesson.ts in @trovesuite/rtc-react is a complete example. Images are URLs (https). The media server keeps the lesson (up to 200 KB) and each screen's state with the class, so nothing has to be stored on your side; from the SDK, room.lesson has show(lesson), goTo(index), setScreen(index, state) and hide(), and the lessonChanged event.


7. Several regions

When your operator runs media servers in more than one region, choose the region per class (e.g. from the school's country) and put it in the token (region claim), or let the SDK route it:

const { token, url } = await rtc.createJoinTokenRouted({ roomId, userId, name, role, region: "southafricanorth" });

Every participant of a room ends up on the same server; a join that reaches another server is redirected automatically. Use the same region for every token of a given room.


8. Installing the JavaScript packages

Package For
@trovesuite/rtc-server Node backends: tokens, webhooks, server API, helpers
@trovesuite/rtc-client Browser SDK (any framework)
@trovesuite/rtc-react React hooks and the ready-made classroom (Tailwind v4, react-icons)
@trovesuite/rtc-whiteboard Whiteboard and slides (used by rtc-react)
@trovesuite/rtc-player Recording playback
npm i @trovesuite/rtc-server          # backend
npm i @trovesuite/rtc-react           # React UI (pulls in rtc-client)

All packages share one version and follow semver; see CHANGELOG.md. A tag vX.Y.Z publishes them to npm (.github/workflows/release.yml) and tags the Go module server/vX.Y.Z.

No npm at all? Use the embed or the browser bundles from the web app, pinned to a major version:

<script src="https://meet.trovesuite.com/sdk/v1/embed.js"></script>
<script type="module">
  import { Room } from "https://meet.trovesuite.com/sdk/v1/rtc-client.js";
</script>

/sdk/v1/ gets fixes and new features but no breaking changes; a breaking release appears at /sdk/v2/ alongside it. /sdk/rtc-client.js (no version) always serves the newest and suits only quick experiments; /sdk/versions.json says what is live. Backends in other languages need no package: a JWT library and HTTPS are enough.


9. Checklist and troubleshooting


10. Developer keys, sandbox and versions

Developer keys. Keys from /console look like tvk_…. They need no database anywhere: the key carries its tenant, webhook URL and sandbox flag, and the secret is HMAC-SHA256(RTC_MASTER_SECRET, key), so every media server holding the master secret accepts it. Consequences worth knowing:

Sandbox keys are for trying things out: up to 4 people per class, classes end after 30 minutes, recording is off. Everything else (webhooks, attendance, breakouts, whiteboard, LTI) works the same, so moving to a production key is a change of .env only.

Test events. The console's Send test event posts { "event": "test", "roomId": "console-test", … } signed with your secret, exactly as the media server would. Answer 2xx. In your own tests, rtc.webhooks.sign(body) makes a valid X-RTC-Signature header.

Operator settings: RTC_MASTER_SECRET (32+ characters, the same on the web app and every media server), CONSOLE_PASSWORD (for production keys), CONSOLE_SANDBOX_KEYS=false to turn self-service sandbox keys off.