LevelChatLevelChatDocs
Rooms

Guides

Rooms

Lifecycle, capabilities, events.

A room is the unit of presence in LevelChat. Every call, every meeting, every broadcast is a room. Rooms are cheap to create (sub-100 ms p99) and idle rooms cost nothing — they auto-archive when the last participant leaves.

Lifecycle

text
created  →  active  →  ended  →  archived
  • A room is created when you POST /v1/rooms.
  • It enters active the moment the first participant joins.
  • It enters ended when the last participant leaves or when you DELETE /v1/rooms/{id}.
  • It enters archived 24 hours after ended (rooms then become read-only — recordings remain).

Create a room

A standalone @levelchat/node SDK is on the roadmap; until it ships, the public REST API is all you need:

TypeScriptserver-side
const res = await fetch(`${process.env.LEVELCHAT_API_URL}/v1/rooms`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.LEVELCHAT_API_KEY}`, // lc_pk_xxx.yyy
  },
  body: JSON.stringify({
    external_id: 'team-standup-2026-04-25',
    type: 'meeting', // meeting | live | webinar | 1to1
    config: {
      max_participants: 50,
      preferred_codec: 'av1',
      record: { enabled: false },
      e2ee: false,
      region_hint: 'eu-fsn',
    },
  }),
});
const room = await res.json(); // { id, external_id, type, config, created_at }

In most apps you don't pre-create rooms — call POST /v1/auth/tokens/room directly with any deterministic roomId your app picks (r_team-standup-2026-04-25) and the room is created on the fly with sane defaults.

Pick the right type — canonical values are defined in services/shared-go/capabilities/capabilities.go::RoomType:

  • meeting — everybody can publish. The default if type is omitted.
  • live — one (or few) publishers, many viewers. Cascade SFU + LL-HLS/CMAF fallback at scale. Up to 25,000 concurrent viewers on the self-serve Broadcast Scale tier; Enterprise reserves edge capacity for larger drops. (Legacy aliases broadcast, one-to-many normalize to live at ingress for backwards compatibility.)
  • webinarlive with a Q&A queue + registration gating + host/panelist/viewer roles.
  • 1to1 — peer-to-peer fast path for two-participant calls. Lower glue-code per room. Aliases one-to-one and p2p normalize here.

Pre-promotion (hybrid) — starting as meeting and promoting to live mid-session without dropping participants — is on the roadmap as a Preview API behind the W9.4 milestone. Until then the supported path is: end the meeting, then have viewers joinLive() against a fresh live room.

Token capabilities

Tokens are scoped, not roles. A publisher token with no subscribe:* cap will not see anyone. A subscriber token with chat:send can talk in chat without ever appearing on camera.

TypeScript
const cap = ['publish:camera', 'publish:screen', 'subscribe:all', 'chat:send'];

Common combinations:

Use caseCapabilities
Standard meeting participantpublish:camera, publish:screen, subscribe:all, chat:send
Read-only viewersubscribe:all, chat:send, reactions:send
Stage participant in a broadcastpublish:camera, subscribe:all
Classroom studentpublish:camera, subscribe:all, chat:send, hand:raise
Moderatormoderate:participants, chat:send, subscribe:all

Listening for events

TypeScriptclient-side, after lc.joinLive()
import { LevelChat } from '@levelchat/web';

const lc = new LevelChat();
const live = await lc.joinLive({ token, role: 'broadcaster', roomType: 'meeting' });

live.room.on('participant-joined', (p) => console.log(p.identity, 'joined'));
live.room.on('participant-left', (p) => console.log(p.identity, 'left'));
live.room.on('track-subscribed', (t) => console.log(t.participantId, 'published', t.kind));
live.room.on('connection-quality', (id, q) => console.log('rtt', q.rtt, 'loss', q.fractionLost));
live.room.on('error', (err) => console.error(err.code, err.message));

Ending a room

TypeScript
await lc.rooms.end(room.id); // or DELETE /v1/rooms/{id}

This kicks every participant with reason: "host_ended" and triggers room.ended webhook.

Idle-room auto-end (no phantom usage)

A room can also end on its own. If your host's tab closes — or the last participant's connection drops — LevelChat detects the silence and ends the room within roughly one minute:

  1. WebSocket watchdog (≤45 s) — every participant ping/pong-checks the signaling server. A dead client (laptop sleep, network drop, browser quit without a clean close) is dropped from the room.
  2. Host grace period (15 s after the last host leaves) — covers page refreshes; if no host reconnects, the room is force-closed.
  3. Reaper safety net (≤80 s) — if a leave event was dropped by the message bus, the admin-api reaper marks the room ended on its next fast-lane sweep (≤20 s ticks) and closes any stranded participant rows.

Why it matters: usage is metered on participant minutes / viewer hours, so a stranded "still in the room" record on the server side would inflate the bill long after everybody actually left. With the auto-end you can trust that

  • a scheduled room with nobody in it accrues zero usage (it stays state: pending until someone joins);
  • a room that was joined and then abandoned stops accruing usage within ~60 s of the last tab closing;
  • the Console + Meet dashboards reflect that timing in their real-time meters.

Where rooms appear

Every room you create — via the REST API, an SDK, or the Meet dashboard — lands in the same Postgres row scoped to the authenticated org + project. That single source of truth is what each surface reads:

SurfaceLists rooms?Joins by code?
Console → RoomsYesn/a
Meet dashboard (meet.levelchat.io)YesYes (/r/<code>)
SDK (@levelchat/web and others)ProgrammaticYes (joinLive/joinMeeting)

So a room your customer's React Native app creates with lc.rooms.create() is immediately visible in your Console workspace AND joinable from meet.levelchat.io/r/<short_code> — the workspace owner can hop into a customer-created room from the browser without any extra plumbing. The opposite also holds: a room your team scheduled in the Meet dashboard can be joined from a phone running your SDK.

Tenant isolation is enforced at the row level: an API key only sees its own org's rooms, a logged-in dashboard user only sees their workspace's rooms, and a /r/<code> lookup that misses the caller's org returns 404 — never a side-channel that probes existence.