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:
- REST API (
/api/*) — CRUD operations, fetching data - Socket.io — real-time messaging, presence, typing indicators, voice state
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
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
Body
{ "email": "alice@example.com", "password": "password123" }
Response — User Object
Sets the session cookie. All subsequent requests include it automatically.
Logout
Destroys the session. No body required.
Check Session
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);
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}):
| Event | Payload | Description |
|---|---|---|
presence_update | {userId, username, status} | User online/offline status change |
channel_notification | {channelId, serverId, messageId, username, content} | New message in any channel |
role_created | Role object | New role created |
role_updated | Role object | Role 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_created | Channel object | New channel in server |
channel_updated | Channel object | Channel name/topic changed |
channel_deleted | {channelId} | Channel removed |
voice_state_update | {userId, channelId, joined, muted, deafened} | Voice presence change |
REST — Auth
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/register | Create account |
| POST | /api/auth/login | Start session |
| POST | /api/auth/logout | Destroy session |
| GET | /api/auth/me | Current user |
REST — Servers
| Method | Path | Description |
|---|---|---|
| GET | /api/servers | List joined servers |
| POST | /api/servers | Create server {name} |
| GET | /api/servers/:id | Get server details |
| PATCH | /api/servers/:id | Update server (name, icon) |
| DELETE | /api/servers/:id | Delete server (owner only) |
| POST | /api/servers/join | Join via invite code {code} |
| POST | /api/servers/:id/leave | Leave server |
| GET | /api/servers/:id/members | List members + your permissions |
| GET | /api/servers/:id/invites | List active invites |
| POST | /api/servers/:id/invites | Create invite |
| DELETE | /api/servers/:id/members/:userId | Kick member (KICK_MEMBERS) |
| POST | /api/servers/:id/bans/:userId | Ban member (BAN_MEMBERS) |
| DELETE | /api/servers/:id/bans/:userId | Unban member |
| GET | /api/servers/:id/roles | List roles |
| POST | /api/servers/:id/roles | Create role |
| PATCH | /api/servers/:id/roles/:roleId | Update role |
| DELETE | /api/servers/:id/roles/:roleId | Delete role |
| PUT | /api/servers/:id/members/:userId/roles/:roleId | Assign role to member |
| DELETE | /api/servers/:id/members/:userId/roles/:roleId | Remove role from member |
REST — Channels
| Method | Path | Description |
|---|---|---|
| GET | /api/channels/:serverId | List channels in server |
| POST | /api/channels/:serverId | Create channel (MANAGE_CHANNELS) |
| PATCH | /api/channels/:channelId | Update channel |
| DELETE | /api/channels/:channelId | Delete channel |
| PATCH | /api/channels/:serverId/reorder | Reorder channels |
| PATCH | /api/channels/:channelId/read | Mark channel as read |
| GET | /api/messages/channels/:channelId/pins | Get pinned messages |
REST — Messages
| Method | Path | Description |
|---|---|---|
| GET | /api/messages/:channelId | Get messages (paginated) |
| POST | /api/messages/:channelId | Send message (multipart/form-data) |
| PATCH | /api/messages/:messageId | Edit message |
| DELETE | /api/messages/:messageId | Delete message (own or MANAGE_MESSAGES) |
| PUT | /api/messages/:messageId/pin | Pin message (MANAGE_MESSAGES) |
| DELETE | /api/messages/:messageId/pin | Unpin 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
| Method | Path | Description |
|---|---|---|
| GET | /api/reactions/:messageId | Get all reactions on a message |
| POST | /api/reactions/:messageId | Add reaction {emoji} |
| DELETE | /api/reactions/:messageId/:emoji | Remove your reaction |
| GET | /api/servers/:serverId/emojis | List server custom emojis |
| POST | /api/servers/:serverId/emojis | Upload custom emoji (MANAGE_GUILD_EXPRESSIONS) |
| DELETE | /api/servers/:serverId/emojis/:emojiId | Delete custom emoji |
REST — Users
| Method | Path | Description |
|---|---|---|
| POST | /api/users/me/avatar | Upload avatar (multipart, field: file) |
| PATCH | /api/users/me/status | Set custom status {status} |
| PATCH | /api/users/me/password | Change password {currentPassword, newPassword} |
| PATCH | /api/users/me/nickname/:serverId | Set nickname in server |
REST — Direct Messages
| Method | Path | Description |
|---|---|---|
| GET | /api/dm | List all DM conversations |
| POST | /api/dm | Open DM with user {recipientId} |
| GET | /api/dm/:dmId/messages | Get DM messages (paginated) |
| POST | /api/dm/:dmId/messages | Send DM (multipart/form-data) |
| PATCH | /api/dm/:dmId/messages/:msgId | Edit DM message |
| DELETE | /api/dm/:dmId/messages/:msgId | Delete DM message |
| POST | /api/group-dm | Create group DM {name, memberIds[]} |
REST — Voice
| Method | Path | Description |
|---|---|---|
| GET | /api/voice/token/:channelId | Get LiveKit token for a voice channel |
| GET | /api/voice/dm-token/:dmId | Get 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 string | Int | Description |
|---|---|---|
text | 0 | Standard text channel |
voice | 2 | Voice channel (LiveKit) |
category | 4 | Category (container, no messages) |
announcement | 5 | Announcement channel (text with 📢) |
forum / media | 15 | Forum / media channel (thread-based) |
thread / public_thread | 11 | Thread inside a forum |
private_thread | 12 | Private 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.
myPermissions value accounts for this automatically.