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
- Join links (no frontend code)
- Join tokens: Node, PHP, Python, Go, C#, Java, Ruby
- Put the class on a page: HTML/JS, React, Next.js, Vue, Angular, Svelte, WordPress, server-rendered pages, React Native, Flutter
- Store what happened in your database: webhooks, signature checks, a schema
- Recordings in your storage: S3, Azure Blob, local disk, presigned uploads, playback
- Slides in your storage
- Controlling classes from your backend 6b. Live streaming to large audiences 6c. Interactive lessons
- Several regions
- Installing the JavaScript packages
- Checklist and troubleshooting
- Developer keys, sandbox and versions
0. Join links (no frontend code)
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:
- Embed (iframe): the complete classroom (video, chat, hands, polls, Q&A, whiteboard and slides, breakout rooms, captions, recording) with one script tag. Works on any website. Theme it with your colour and logo.
- SDK: build your own UI with
@trovesuite/rtc-client(any framework), or use the ready-made React components in@trovesuite/rtc-react.
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)
- Token route as in step 1 (
app/api/classes/[id]/token/route.ts). - 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} />;
}
next.config.ts:transpilePackages: ["@trovesuite/rtc-client", "@trovesuite/rtc-react", "@trovesuite/rtc-whiteboard", "@trovesuite/rtc-protocol", "@trovesuite/rtc-player"](they ship TypeScript sources).- Allow the camera:
Permissions-Policy: camera=(self), microphone=(self), display-capture=(self)(or omit the header; the default allows them on your own origin). - 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.
- Moodle: Site administration → Plugins → Activity modules → External tool → Manage tools → Configure a tool manually. LTI version 1.3, public key type "Keyset URL", tick "Supports Deep Linking", and under Services allow "IMS LTI Assignment and Grade Services" for attendance grades. Set Default launch container to "New window" if your theme blocks the camera in iframes.
- Canvas: Admin → Developer Keys → + LTI Key → Enter URL
{APP_URL}/lti/config?format=canvas, turn the key on, then add the app to an account or course by its client id. - Blackboard: register the tool on developer.blackboard.com with the URLs above, then add it in System Admin → LTI Tool Providers.
- Brightspace: Admin Tools → Manage Extensibility → LTI Advantage → Register Tool, then create a deployment and link.
- Schoology: install the app from the App Center (or as a developer app) with the URLs above.
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).
- React:
<LivePlayer src={live.playbackUrl} />from@trovesuite/rtc-player(hls.js, or native HLS on Safari/iOS). - Anything else: any HLS player (hls.js, Video.js, the
<video>tag on Safari, ExoPlayer/AVPlayer in apps). - The demo app has a ready page at
/live/<class code>; teachers find its link in Class details.
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
- HTTPS everywhere. Browsers only allow camera and microphone on
https://(orlocalhost). - Iframes need permission.
embed.jssetsallow="camera; microphone; display-capture; fullscreen; autoplay". If you write the<iframe>yourself, add the same. A parent page whosePermissions-Policyheader blocks the camera blocks it for the iframe too. - Framing. Your operator lists the sites allowed to frame
/embed(EMBED_ALLOWED_ORIGINS). A blank iframe with a console error aboutframe-ancestorsmeans your site isn't on the list. - "unauthorized" on join. Check the token is signed with the right secret,
issis your API key, andexphasn't passed (watch out for clocks set wrongly). - "roomEnded". A teacher ended the class. Students can't reopen it for a few minutes; a teacher's token reopens it straight away.
- Students can't see or hear anyone on school Wi-Fi. The network blocks UDP and the media ports; ask your operator for a TURN server on port 443.
- No sound at first on some phones. Browsers block autoplay until the user taps. The embed and the React components resume audio on the first tap; with the plain SDK, call
audioElement.play()from a click handler (e.g. a "Join" button). - Webhooks not arriving. Your endpoint must be public HTTPS and answer
2xxwithin 10 seconds. Failed deliveries are retried 5 times and then dropped, so store raw events first and process them afterwards. - Recording links expired. Copy files when
recording_readyarrives (or use presigned uploads); the server deletes them afterRTC_RECORDING_TTL. - Captions privacy. Captions use the browser's speech recognition; Chrome and Edge send the audio to Google or Microsoft. Check this against your data-protection rules before turning captions on.
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:
- Changing the webhook URL issues a new key for the same tenant: rooms, recordings and reports carry over. Switch your backend to the new key; the old one keeps working until the operator lists it in
RTC_REVOKED_KEYS(keys or whole tenants, comma separated). - The secret is shown once. Lost it? Issue a new key for the integration and revoke the old one.
- Keys an operator configures by hand (
RTC_KEYS) work as before, and a developer key can never act as one of those tenants.
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.