Client Development Guide

This guide covers everything needed to build a first-party NexusGuild client application — from authentication and session management through real-time socket events and the full REST API.

Overview

A NexusGuild client communicates with the server through two channels:

Authentication uses server-side sessions via HTTP cookies (express-session). There is no API key or token — log in once and the session cookie is sent automatically with every request.

Authentication

Register

POST /api/auth/register

Body

{ "username": "Alice", "email": "alice@example.com", "password": "password123" }

Constraints: username 2–32 chars, valid email, password 8+ chars.

Response — User Object

{ "id": "snowflake", "username": "Alice", "email": "alice@example.com", "avatar": null, "created_at": "..." }

Login

POST /api/auth/login

Body

{ "email": "alice@example.com", "password": "password123" }

Response — User Object

Sets the session cookie. All subsequent requests include it automatically.

Logout

POST /api/auth/logout

Destroys the session. No body required.

Check Session

GET /api/auth/me

Returns the current user if a valid session exists, otherwise 401. Call this on page load to restore the session.

Session Flow

// Recommended startup sequence
async function init() {
  // 1. Check existing session
  const res = await fetch('/api/auth/me');
  if (!res.ok) { showLoginScreen(); return; }

  const user = await res.json();

  // 2. Load servers
  const servers = await fetch('/api/servers').then(r => r.json());

  // 3. Connect socket
  const socket = io('/', { withCredentials: true });
  socket.on('connect', () => {
    // Join all servers
    for (const server of servers) {
      socket.emit('join_server', server.id);
    }
  });

  // 4. Render UI
  renderApp(user, servers, socket);
}

Socket.io Connection

Connect to the main Socket.io namespace with withCredentials: true so the session cookie is included:

import { io } from 'socket.io-client';

const socket = io('https://nexusguild.gg', {
  withCredentials: true,
  transports: ['websocket'],
});

On connection, the server automatically adds the user to their personal room (user:{userId}) for DM delivery.

Server Rooms

Join a server room to receive server-wide events (member joins, role changes, channel updates):

socket.emit('join_server', serverId);
socket.emit('leave_server', serverId);
ℹ️
Join all of the user's servers on connect, not just the currently viewed one. This ensures you receive notification pips for messages in other servers.

Channel Rooms

Join a channel room to receive messages and typing indicators for that channel. Leave the previous channel when switching:

socket.emit('leave_channel', prevChannelId);  // leave previous
socket.emit('join_channel', channelId);       // enter new

Receiving Messages

Listen for new, edited, and deleted messages while in a channel room:

socket.on('message_created', (message) => {
  appendMessage(message);
});

socket.on('message_updated', (message) => {
  updateMessage(message.id, message);
});

socket.on('message_deleted', ({ messageId, channelId }) => {
  removeMessage(messageId);
});

socket.on('message_pinned', ({ messageId, channelId }) => {
  markPinned(messageId, true);
});

socket.on('message_unpinned', ({ messageId, channelId }) => {
  markPinned(messageId, false);
});

Unread Notifications

The server emits channel_notification to the server room for every message sent. Use this to track unread pips across channels:

socket.on('channel_notification', ({ channelId, serverId, messageId, username, content }) => {
  if (channelId !== currentChannelId) {
    incrementUnread(channelId, serverId);
  }
});

Reactions

socket.on('reaction_updated', ({ messageId, reactions }) => {
  updateReactionDisplay(messageId, reactions);
  // reactions: [{ emoji: "👍", count: "3" }, ...]
});

Typing Indicators

// Send typing event (debounce to ~1 second)
socket.emit('start_typing', channelId);
socket.emit('stop_typing', channelId);

// Receive from others in same channel
socket.on('user_typing', ({ userId, username, channelId }) => {
  showTypingIndicator(username);
});

socket.on('user_stop_typing', ({ userId, channelId }) => {
  hideTypingIndicator(userId);
});

Voice State

// Join a voice channel
socket.emit('join_voice', { channelId, serverId });

// Leave a voice channel
socket.emit('leave_voice', { channelId, serverId });

// Update mute/deafen state
socket.emit('voice_state_change', { muted: true, deafened: false });

// Receive voice state updates from others in the server
socket.on('voice_state_update', ({ userId, username, channelId, muted, deafened, joined }) => {
  // joined=true: user entered; joined=false: user left
  updateVoiceState(userId, channelId, joined);
});

Actual audio is handled via LiveKit Cloud — the socket events track presence (who is in which voice channel), while LiveKit manages the WebRTC streams. Fetch a LiveKit token from GET /api/voice/token/:channelId and use the LiveKit client SDK to connect.

Direct Messages

// Join a DM conversation room
socket.emit('join_dm', dmId);
socket.emit('leave_dm', dmId);

// DM typing
socket.emit('dm_typing', dmId);
socket.emit('dm_stop_typing', dmId);

// Receive DM events via personal room (user:{userId})
socket.on('dm_message_created', (message) => { ... });
socket.on('dm_message_updated', (message) => { ... });
socket.on('dm_message_deleted', ({ messageId, dmId }) => { ... });
socket.on('dm_reaction_added',   ({ messageId, emoji, userId, dmId }) => { ... });
socket.on('dm_reaction_removed',  ({ messageId, emoji, userId, dmId }) => { ... });

Broadcast Events (Server Room)

These events are emitted to all clients in a server room (server:{serverId}):

EventPayloadDescription
presence_update{userId, username, status}User online/offline status change
channel_notification{channelId, serverId, messageId, username, content}New message in any channel
role_createdRole objectNew role created
role_updatedRole objectRole modified
role_deleted{roleId, serverId}Role deleted
member_role_updated{userId, serverId, roleIds}User's roles changed
member_banned{userId, serverId}User banned
server_emojis_updated{serverId}Custom emoji added/removed
channel_createdChannel objectNew channel in server
channel_updatedChannel objectChannel name/topic changed
channel_deleted{channelId}Channel removed
voice_state_update{userId, channelId, joined, muted, deafened}Voice presence change

REST — Auth

MethodPathDescription
POST/api/auth/registerCreate account
POST/api/auth/loginStart session
POST/api/auth/logoutDestroy session
GET/api/auth/meCurrent user

REST — Servers

MethodPathDescription
GET/api/serversList joined servers
POST/api/serversCreate server {name}
GET/api/servers/:idGet server details
PATCH/api/servers/:idUpdate server (name, icon)
DELETE/api/servers/:idDelete server (owner only)
POST/api/servers/joinJoin via invite code {code}
POST/api/servers/:id/leaveLeave server
GET/api/servers/:id/membersList members + your permissions
GET/api/servers/:id/invitesList active invites
POST/api/servers/:id/invitesCreate invite
DELETE/api/servers/:id/members/:userIdKick member (KICK_MEMBERS)
POST/api/servers/:id/bans/:userIdBan member (BAN_MEMBERS)
DELETE/api/servers/:id/bans/:userIdUnban member
GET/api/servers/:id/rolesList roles
POST/api/servers/:id/rolesCreate role
PATCH/api/servers/:id/roles/:roleIdUpdate role
DELETE/api/servers/:id/roles/:roleIdDelete role
PUT/api/servers/:id/members/:userId/roles/:roleIdAssign role to member
DELETE/api/servers/:id/members/:userId/roles/:roleIdRemove role from member

REST — Channels

MethodPathDescription
GET/api/channels/:serverIdList channels in server
POST/api/channels/:serverIdCreate channel (MANAGE_CHANNELS)
PATCH/api/channels/:channelIdUpdate channel
DELETE/api/channels/:channelIdDelete channel
PATCH/api/channels/:serverId/reorderReorder channels
PATCH/api/channels/:channelId/readMark channel as read
GET/api/messages/channels/:channelId/pinsGet pinned messages

REST — Messages

MethodPathDescription
GET/api/messages/:channelIdGet messages (paginated)
POST/api/messages/:channelIdSend message (multipart/form-data)
PATCH/api/messages/:messageIdEdit message
DELETE/api/messages/:messageIdDelete message (own or MANAGE_MESSAGES)
PUT/api/messages/:messageId/pinPin message (MANAGE_MESSAGES)
DELETE/api/messages/:messageId/pinUnpin message (MANAGE_MESSAGES)

Sending Messages (multipart/form-data)

Messages use multipart/form-data so file attachments can be included:

const form = new FormData();
form.append('content', 'Hello!');
form.append('replyTo', messageId);      // optional: reply thread
form.append('file', fileObject);        // optional: attachment

fetch(`/api/messages/${channelId}`, { method: 'POST', body: form });

Paginating Messages

// Initial load: get latest 50 messages
GET /api/messages/:channelId?limit=50

// Load older messages (scroll up)
GET /api/messages/:channelId?before={oldestMessageId}&limit=50

REST — Reactions

MethodPathDescription
GET/api/reactions/:messageIdGet all reactions on a message
POST/api/reactions/:messageIdAdd reaction {emoji}
DELETE/api/reactions/:messageId/:emojiRemove your reaction
GET/api/servers/:serverId/emojisList server custom emojis
POST/api/servers/:serverId/emojisUpload custom emoji (MANAGE_GUILD_EXPRESSIONS)
DELETE/api/servers/:serverId/emojis/:emojiIdDelete custom emoji

REST — Users

MethodPathDescription
POST/api/users/me/avatarUpload avatar (multipart, field: file)
PATCH/api/users/me/statusSet custom status {status}
PATCH/api/users/me/passwordChange password {currentPassword, newPassword}
PATCH/api/users/me/nickname/:serverIdSet nickname in server

REST — Direct Messages

MethodPathDescription
GET/api/dmList all DM conversations
POST/api/dmOpen DM with user {recipientId}
GET/api/dm/:dmId/messagesGet DM messages (paginated)
POST/api/dm/:dmId/messagesSend DM (multipart/form-data)
PATCH/api/dm/:dmId/messages/:msgIdEdit DM message
DELETE/api/dm/:dmId/messages/:msgIdDelete DM message
POST/api/group-dmCreate group DM {name, memberIds[]}

REST — Voice

MethodPathDescription
GET/api/voice/token/:channelIdGet LiveKit token for a voice channel
GET/api/voice/dm-token/:dmIdGet LiveKit token for a DM voice call

Message Object

The full message object returned by the REST API and emitted by socket events:

{
  "id":                "snowflake",
  "channel_id":        "snowflake",
  "user_id":           "snowflake",
  "username":          "Alice",
  "display_name":      null,            // server nickname override
  "avatar":            "/uploads/avatars/alice.png",
  "content":           "Hello, world!",
  "attachments":       [],               // [{url, filename, size}]
  "created_at":        "2026-03-07T12:00:00.000Z",
  "updated_at":        null,            // set when edited
  "is_pinned":         false,
  "reply_to_id":       null,            // ID of replied-to message
  "reply_to_content":  null,
  "reply_to_username": null,
  "thread_channel_id": null,            // thread created from this message
  "thread_reply_count": 0
}

Channel Types

Type stringIntDescription
text0Standard text channel
voice2Voice channel (LiveKit)
category4Category (container, no messages)
announcement5Announcement channel (text with 📢)
forum / media15Forum / media channel (thread-based)
thread / public_thread11Thread inside a forum
private_thread12Private thread

Permissions

Fetch the current user's effective permission bitmask for a server from GET /api/servers/:serverId/members — the response includes a myPermissions field (a BigInt-serialized string).

const { members, myPermissions } = await fetch(`/api/servers/${id}/members`)
  .then(r => r.json());

const perms = BigInt(myPermissions);

function hasPermission(perms, flag) {
  if (perms & BigInt(8)) return true;   // ADMINISTRATOR
  return (perms & flag) === flag;
}

const SEND_MESSAGES = BigInt(2048);
if (hasPermission(perms, SEND_MESSAGES)) {
  showMessageInput();
}

See the full permissions reference for all flag values.

Server owners have all permissions by default and bypass every permission check. The myPermissions value accounts for this automatically.