REST endpoints
Base URL is whatever VITE_API_URL/CORS_ORIGIN point at (http://localhost:5000 by default).
POST /api/rooms: create a room
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | no | 1-255 chars, defaults to "Quick Meeting" |
maxParticipants | integer | no | 2-100, default 50 |
password | string | no | 4-50 chars. A PIN participants must enter before joining (bcrypt-hashed at rest) - see POST /api/rooms/:id/verify-pin below |
scheduledAt | string | no | ISO 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.
| Endpoint | Purpose |
|---|---|
GET /api/rooms/code/:roomCode | Same shape as above, looked up by the human-readable room code. |
POST /api/rooms/:id/verify-pin | Body { "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/rooms | Paginated list of active rooms. Query params: page (default 1), limit (default 10). |
PATCH /api/rooms/:id/status | Body: { "status": "active" | "ended" | "scheduled" }. |
DELETE /api/rooms/:id | Ends a room (sets status to ended); doesn't delete history. |
GET /api/rooms/:id/analytics | Aggregate stats (participant/message counts, duration) from the room_analytics view. |
GET /health | Health 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.
| Endpoint | Auth | Purpose |
|---|---|---|
GET /api/settings/branding | None (public) | Current saved branding, or { "configured": false } if nothing's been saved yet. |
PUT /api/settings/branding | x-admin-token header | Body: 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/logo | x-admin-token header | Multipart 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.
isHost for anything security-sensitive.Joining
| Event (client to server) | Payload | Behavior |
|---|---|---|
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-users | array 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)
| Event | Direction | Payload |
|---|---|---|
waiting-for-approval | to joining client | { message } |
join-request | to host/co-host sockets | { socketId, userName, profilePicture, timestamp } |
approve-join | client to server | { targetSocketId } (host/co-host only) |
reject-join | client to server | { targetSocketId } |
join-approved / join-rejected | to waiting client | { by, message? } |
WebRTC signaling
Plain relay: the server never inspects SDP/ICE contents, just forwards by target socket id.
| Event | Payload |
|---|---|
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) | Payload | Broadcast 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
| Event | Direction | Payload |
|---|---|---|
send-message | client 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-message | to room | stored { id, userId, userName, message, type, timestamp, fileUrl?, fileName?, fileSize? } |
delete-message | client to server (host only) | { roomId, messageId } |
message-deleted | to 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.
| Event | Who | Payload | Effect |
|---|---|---|---|
mute-participant | host/co-host | { roomId, targetSocketId } | target gets force-mute, room gets user-audio-toggle |
disable-video | host/co-host | { roomId, targetSocketId } | target gets force-video-off, room gets user-video-toggle |
stop-screenshare | host/co-host | { roomId, targetSocketId } | target gets force-stop-screenshare, room gets user-screen-share |
kick-participant | host/co-host | { roomId, targetSocketId } | target gets kicked-from-room, then is disconnected |
mute-all | host/co-host | { roomId, enabled } | forces mute on everyone except host/co-hosts; room gets all-participants-muted |
toggle-chat | host/co-host | { roomId, enabled } | room gets chat-status-changed |
disable-all-cameras | host/co-host | { roomId, enabled } | room gets all-cameras-disabled |
disable-all-screenshares | host/co-host | { roomId, enabled } | room gets all-screenshares-disabled |
lock-meeting | host/co-host | { roomId, locked } | room gets meeting-lock-changed; when locked, new joins are rejected outright, no waiting room offered |
toggle-self-unmute | host/co-host | { roomId, enabled } | room gets self-unmute-permission-changed; when off, a participant's own toggle-audio: { enabled: true } is rejected |
toggle-participant-screenshare | host/co-host | { roomId, enabled } | room gets participant-screenshare-permission-changed |
make-cohost | host only | { roomId, targetSocketId } | target gets role-changed, room gets participant-role-updated |
remove-cohost | host only | { roomId, targetSocketId } | same, with role: 'participant' |
set-room-type | host only | { roomId, isPrivate } | room gets room-type-changed; controls whether new joiners hit the waiting room |
set-cohost-permissions | host 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) | Payload | Broadcast 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 dropped | reaction-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) | Who | Payload | Broadcast to room as |
|---|---|---|---|
create-poll | host/co-host | { roomId, question, options } (2-10 options) | poll-created: { id, question, options: [{ text, votes }], isOpen, totalVoters, createdBy } |
vote-poll | anyone | { roomId, optionIndex } - re-voting replaces your previous choice | poll-updated, same shape |
close-poll | host/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) | Payload | Broadcast to room as |
|---|---|---|
whiteboard-draw | { roomId, x0, y0, x1, y1, color, width } - coordinates are fractions (0-1) of the sender's own canvas, not pixels | whiteboard-draw, same payload, to everyone else |
whiteboard-clear | host/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) | Payload | Effect |
|---|---|---|
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) | Payload | Broadcast to room as |
|---|---|---|
send-caption | { roomId, text } | caption-received: { socketId, userName, text, timestamp } |
Quality monitoring
| Event | Direction | Payload |
|---|---|---|
connection-quality | client to server | arbitrary data, relayed as user-connection-quality: { userId, socketId, quality } |
network-quality | client 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.
| Event | Fires when | Data |
|---|---|---|
room.created | a room is created via POST /api/rooms | { roomId, roomCode, title } |
room.ended | a room's status is set to "ended" | { roomId, roomCode } |
participant.joined | anyone joins a room | { roomId, participantId, name, isHost } |
participant.left | anyone 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.