API reference

REST for room lifecycle, Socket.IO for everything that happens in real time inside a meeting. There's no authentication layer; see security notes for the trust model. Embedding a meeting in your own page instead of using this API directly is covered in the embedding guide.

REST endpoints

Base URL is whatever VITE_API_URL/CORS_ORIGIN point at (http://localhost:5000 by default).

POST /api/rooms: create a room

FieldTypeRequiredNotes
titlestringno1-255 chars, defaults to "Quick Meeting"
maxParticipantsintegerno2-100, default 50
passwordstringno4-50 chars. A PIN participants must enter before joining (bcrypt-hashed at rest) - see POST /api/rooms/:id/verify-pin below
scheduledAtstringnoISO 8601 timestamp. Sets status to "scheduled"; purely informational, the room is joinable immediately either way
{
  "success": true,
  "data": {
    "id": "b6b1e2b0-...",
    "roomCode": "ABC-123-XYZ",
    "title": "Quick Meeting",
    "hostId": "b6b1e2b0-...",
    "maxParticipants": 50,
    "hasPassword": false,
    "scheduledAt": null,
    "status": "active",
    "createdAt": "2026-01-01T00:00:00.000Z"
  }
}

Use data.id as the room identifier everywhere else: URLs, the other endpoints, Socket.IO's roomId.

GET /api/rooms/:id: room info + live participant count

{
  "success": true,
  "data": {
    "id": "b6b1e2b0-...",
    "roomCode": "ABC-123-XYZ",
    "title": "Quick Meeting",
    "hostId": "b6b1e2b0-...",
    "hostName": "Ann",
    "maxParticipants": 50,
    "status": "active",
    "participantCount": 2,
    "participants": [
      { "id": "...", "name": "Ann", "isHost": true, "joinedAt": "..." }
    ],
    "createdAt": "...",
    "startedAt": null
  }
}

404 if the room doesn't exist. hostName reflects whoever currently holds host status (it can change via host transfer), not the original creator.

EndpointPurpose
GET /api/rooms/code/:roomCodeSame shape as above, looked up by the human-readable room code.
POST /api/rooms/:id/verify-pinBody { "pin": "..." }. 200 if correct (or the room has no PIN), 401 otherwise. A courtesy check for your own pre-join UI - the same PIN is re-checked when the socket actually joins, so this alone can't be used to bypass it.
GET /api/roomsPaginated list of active rooms. Query params: page (default 1), limit (default 10).
PATCH /api/rooms/:id/statusBody: { "status": "active" | "ended" | "scheduled" }.
DELETE /api/rooms/:idEnds a room (sets status to ended); doesn't delete history.
GET /api/rooms/:id/analyticsAggregate stats (participant/message counts, duration) from the room_analytics view.
GET /healthHealth check: DB connectivity, memory/CPU, uptime. Used by Docker's healthcheck and load balancers.

Branding settings

What the /admin panel reads and writes. Base URL is the same as above.

EndpointAuthPurpose
GET /api/settings/brandingNone (public)Current saved branding, or { "configured": false } if nothing's been saved yet.
PUT /api/settings/brandingx-admin-token headerBody: any of appName, tagline, description, logoIcon, logoFull, primaryColor (hex), features (object, merged onto what's stored), adminPageEnabled (boolean). Only the fields you send are changed.
POST /api/settings/branding/logox-admin-token headerMultipart upload, field name logo. SVG, PNG, JPEG, or WebP, up to 2MB. Returns { "url": "/uploads/branding/..." } to use as logoIcon/logoFull above.

x-admin-token must match ADMIN_SETUP_TOKEN from the backend's environment: 401 if it doesn't, 403 if that variable isn't set at all.

Socket.IO

Connect to the same origin as the REST API. roomId below is always the UUID from POST /api/rooms. There are no accounts: every participant is identified by whatever userName they send when joining, plus the socket's own socket.id for the life of that connection.

Host status is server-computed on join; never trust a client-asserted isHost for anything security-sensitive.

Joining

Event (client to server)PayloadBehavior
request-join{ roomId, userName, audioEnabled, videoEnabled, pin? }Normal join path. pin is required if the room has one set; wrong or missing gets error: { message: 'Incorrect PIN' }. Otherwise joins immediately if the room is empty or not private; otherwise the caller enters the waiting room.
host-rejoin{ roomId, userId, userName, audioEnabled, videoEnabled, hostToken }Reclaims host status after a reconnect, skipping the waiting room. hostToken is a short-lived token the server issued this client when it first became host (see host-status below) - a bare isHost claim with no valid token for this room is not trusted and is treated as a normal join instead.
leave-room(none)Leaves the current room.

On a successful join, the server emits to the joining socket only:

Event (server to client)Payload
room-usersarray of other current participants
room-settings{ isPrivate, allMuted, allCamerasOff, allScreenSharesOff, chatEnabled }
host-status{ isHost: boolean, hostToken: string|null }: the authoritative answer to "am I host." hostToken is set only when isHost is true - store it (keyed by room) and send it back on host-rejoin.

...and to everyone else already in the room: user-joined (the new participant's info) and participant-list-updated (full current list).

Waiting room (private rooms only)

EventDirectionPayload
waiting-for-approvalto joining client{ message }
join-requestto host/co-host sockets{ socketId, userName, profilePicture, timestamp }
approve-joinclient to server{ targetSocketId } (host/co-host only)
reject-joinclient to server{ targetSocketId }
join-approved / join-rejectedto waiting client{ by, message? }

WebRTC signaling

Plain relay: the server never inspects SDP/ICE contents, just forwards by target socket id.

EventPayload
offer{ target, offer, metadata }, relayed as { sender, offer, metadata, timestamp }
answer{ target, answer, metadata }, relayed as { sender, answer, metadata, timestamp }
ice-candidate{ target, candidate }, relayed as { sender, candidate, timestamp }
connection-state-change{ target, state }, relayed as peer-connection-state: { sender, state, timestamp }

On failure the sender gets back webrtc-error: { type, error }.

Media state

Event (client to server)PayloadBroadcast to room as
toggle-audio{ roomId, enabled }user-audio-toggle: { userId, socketId, enabled }
toggle-video{ roomId, enabled }user-video-toggle: { userId, socketId, enabled }
toggle-screen-share{ roomId, enabled, quality }user-screen-share: { userId, socketId, enabled, quality, timestamp }
camera-flipped{ roomId, facingMode, trackId }camera-flipped: { userId, socketId, facingMode, trackId, timestamp }

Chat

EventDirectionPayload
send-messageclient to server{ roomId, message, userName, type?, fileUrl?, fileName?, fileSize? }, rate-limited to 30/min per socket (over the limit gets rate-limit-exceeded: { type: 'chat' })
new-messageto roomstored { id, userId, userName, message, type, timestamp, fileUrl?, fileName?, fileSize? }
delete-messageclient to server (host only){ roomId, messageId }
message-deletedto room{ messageId }

For a file message, upload it first via POST /api/rooms/:roomId/files, then send send-message with type: 'file'. The server only accepts a fileUrl pointing at that same upload directory - anything else is silently dropped to a plain-text message.

Host controls

All of these require the caller to be host (or co-host, where noted); otherwise the server replies with error: { message } and does nothing.

EventWhoPayloadEffect
mute-participanthost/co-host{ roomId, targetSocketId }target gets force-mute, room gets user-audio-toggle
disable-videohost/co-host{ roomId, targetSocketId }target gets force-video-off, room gets user-video-toggle
stop-screensharehost/co-host{ roomId, targetSocketId }target gets force-stop-screenshare, room gets user-screen-share
kick-participanthost/co-host{ roomId, targetSocketId }target gets kicked-from-room, then is disconnected
mute-allhost/co-host{ roomId, enabled }forces mute on everyone except host/co-hosts; room gets all-participants-muted
toggle-chathost/co-host{ roomId, enabled }room gets chat-status-changed
disable-all-camerashost/co-host{ roomId, enabled }room gets all-cameras-disabled
disable-all-screenshareshost/co-host{ roomId, enabled }room gets all-screenshares-disabled
lock-meetinghost/co-host{ roomId, locked }room gets meeting-lock-changed; when locked, new joins are rejected outright, no waiting room offered
toggle-self-unmutehost/co-host{ roomId, enabled }room gets self-unmute-permission-changed; when off, a participant's own toggle-audio: { enabled: true } is rejected
toggle-participant-screensharehost/co-host{ roomId, enabled }room gets participant-screenshare-permission-changed
make-cohosthost only{ roomId, targetSocketId }target gets role-changed, room gets participant-role-updated
remove-cohosthost only{ roomId, targetSocketId }same, with role: 'participant'
set-room-typehost only{ roomId, isPrivate }room gets room-type-changed; controls whether new joiners hit the waiting room
set-cohost-permissionshost only{ roomId, canManageParticipants?, canChangeSettings? }room gets co-host-permissions-changed; a co-host can never grant itself more power

Legacy/simpler variants kept for compatibility: mute-user / remove-user (by targetUserId), mute-all-users.

Raise hand / reactions

Event (client to server)PayloadBroadcast to room as
raise-hand{ roomId }hand-raised: { socketId, userName, timestamp }
lower-hand{ roomId, targetSocketId? }hand-lowered: { socketId, timestamp } - anyone can lower their own hand; lowering someone else's needs the host-controls tier above
send-reaction{ roomId, emoji } - must be one of ๐Ÿ‘ ๐Ÿ‘ โค๏ธ ๐Ÿ˜‚ ๐ŸŽ‰ ๐Ÿ‘‹, anything else is droppedreaction-received: { socketId, userName, emoji, timestamp }

Polls

Single-choice, one per room at a time. Vote counts are broadcast to everyone; who voted for which option never is.

Event (client to server)WhoPayloadBroadcast to room as
create-pollhost/co-host{ roomId, question, options } (2-10 options)poll-created: { id, question, options: [{ text, votes }], isOpen, totalVoters, createdBy }
vote-pollanyone{ roomId, optionIndex } - re-voting replaces your previous choicepoll-updated, same shape
close-pollhost/co-host{ roomId }poll-closed, same shape, isOpen: false

A late joiner gets the currently active poll, if any, privately as poll-created right after joining.

Whiteboard

A shared drawing surface. Anyone can draw; clearing it needs the host-controls tier.

Event (client to server)PayloadBroadcast to room as
whiteboard-draw{ roomId, x0, y0, x1, y1, color, width } - coordinates are fractions (0-1) of the sender's own canvas, not pixelswhiteboard-draw, same payload, to everyone else
whiteboard-clearhost/co-host - { roomId }whiteboard-cleared: { by }

A late joiner gets the whole stroke history so far, privately, as whiteboard-state: [stroke, ...]. The server caps stored strokes at 5000 per room, dropping the oldest ones past that.

Breakout rooms

Host-only (or co-host, if permitted). Each breakout room is a real room created the same way POST /api/rooms does - assigning someone just navigates their client to it, reusing the normal join flow rather than a separate mesh-management system. That also means a breakout room inherits the same "anyone with the link can join" trust model as any other room.

Event (client to server)PayloadEffect
create-breakout-rooms{ roomId, count } (2-20)room gets breakout-rooms-created: { rooms: [{ index, id, title }] }
assign-breakout{ roomId, targetSocketId, breakoutIndex }that socket gets breakout-assigned: { breakoutRoomId, breakoutTitle, mainRoomId }
auto-assign-breakouts{ roomId }round-robins everyone else across the existing breakout rooms, each getting breakout-assigned
close-breakout-rooms{ roomId }everyone in a breakout room gets breakout-closed: { mainRoomId }; room gets breakout-rooms-closed - doesn't move anyone back automatically

Live captions

Entirely client-side speech-to-text (the browser's own Web Speech API); the server only relays the resulting text to the room, it never sees or transcribes audio itself. Ephemeral - unlike chat, never stored.

Event (client to server)PayloadBroadcast to room as
send-caption{ roomId, text }caption-received: { socketId, userName, text, timestamp }

Quality monitoring

EventDirectionPayload
connection-qualityclient to serverarbitrary data, relayed as user-connection-quality: { userId, socketId, quality }
network-qualityclient to server{ roomId, stats }, relayed as user-network-quality: { userId, socketId, stats }

Leaving / host transfer

When a host disconnects or leaves, the server picks the next participant (join order) as the new host and emits new-host: { hostId, socketId, hostName } to the room. Everyone else gets user-left: { userId, socketId, reason } when any participant leaves.

Errors

Most handlers emit error: { message } back to the caller on failure (permission denied, room not found, etc). WebRTC-specific failures use webrtc-error: { type, error } instead.

Webhooks

Optional, off by default. Set WEBHOOK_URL (and WEBHOOK_SECRET, to have requests signed) in the backend's environment to get a JSON POST for these events.

EventFires whenData
room.createda room is created via POST /api/rooms{ roomId, roomCode, title }
room.endeda room's status is set to "ended"{ roomId, roomCode }
participant.joinedanyone joins a room{ roomId, participantId, name, isHost }
participant.leftanyone leaves{ roomId, participantId, name, reason }

By default (WEBHOOK_FORMAT=generic) each request body looks like { "event": "...", "data": {...}, "timestamp": "..." }. Set WEBHOOK_FORMAT=slack or discord to send a plain-text summary shaped for that platform's incoming-webhook API instead ({ "text": "..." } / { "content": "..." }) - WEBHOOK_URL can then point straight at a Slack/Discord incoming webhook URL, no adapter needed. If WEBHOOK_SECRET is set, requests carry an X-Rumo-Signature header (hex HMAC-SHA256 of the raw body) regardless of format, so a generic receiver can verify they came from your instance. Delivery is fire-and-forget - a slow or failing endpoint is logged and otherwise ignored.