Zipp-Chat — Client Technical Design Document¶
Reference
Rendered from Server/docs/client-technical-design.md on main at build time. Kept current with the code it describes, but not a protocol specification.
Version: 1.0 Last Updated: 2026-06-13
This document is the authoritative reference for building any Zipp-Chat client (web, desktop, mobile). It covers every API endpoint, the real-time WebSocket protocol, end-to-end encryption, key management, voice integration, media upload, and all data types the server produces and consumes.
Table of Contents¶
- Architecture Overview
- Base URLs & Service Map
- Authentication
- Permissions
- HTTP API Reference
- 5.1 Auth
- 5.2 Users
- 5.3 Servers
- 5.4 Channels
- 5.5 Roles
- 5.6 Members
- 5.7 Bans
- 5.8 Invites
- 5.9 Messages
- 5.10 Voice
- 5.11 Media
- 5.12 Key Bundles
- 5.13 Key Backup (PIN)
- 5.14 Device Tokens
- 5.15 CSAM Reporting
- 5.16 Direct Messages (DM)
- 5.17 Friends
- WebSocket Gateway
- End-to-End Encryption
- Voice (LiveKit)
- Media Upload Flow
- Data Types Reference
- Error Handling
- Rate Limiting
- Known Gaps / Not Yet Built
1. Architecture Overview¶
Zipp-Chat is a microservices backend. Each service is independently deployed and scaled on Kubernetes. From a client perspective:
┌─────────────────────────────────────────────────┐
│ Client App │
└────┬──────────┬──────────┬──────────────────────┘
│ REST │ WS │ REST
▼ ▼ ▼
api.domain api.domain media.domain voice.domain
(API service) (Gateway) (Media service) (Voice service)
│ │ │
├─ auth ├─ MinIO ├─ LiveKit SFU
├─ servers / channels └─ Vault └─ coturn
├─ roles / members
├─ messages (server-side decrypt)
└─ /api/keys/* (proxied to keyserver)
DM encryption: True E2EE — the server stores ciphertext only and never decrypts DM messages. The client must manage keys locally.
Server message encryption: Envelope encryption with KEKs stored in Vault.
The API service fetches the KEK from Vault and decrypts on the fly when serving
messages. From the client's perspective, content arrives as plaintext in the
response.
2. Base URLs & Service Map¶
| Service | Subdomain | Internal port | Purpose |
|---|---|---|---|
| API | api.yourdomain.com |
8080 | REST API + WebSocket gateway |
| Media | media.yourdomain.com |
8082 | File upload/download |
| Voice | voice.yourdomain.com |
8081 | LiveKit token + state |
| Vault (optional) | vault.yourdomain.com |
8200 | Not client-facing |
All HTTPS (Cloudflare Tunnel terminates TLS). Replace yourdomain.com with
your actual domain at deploy time (configured in k8s/tunnel/config.yaml).
For development (docker-compose up):
- API: http://localhost:8080
- Media: http://localhost:8082
- Voice: http://localhost:8081
3. Authentication¶
3.1 Tokens¶
The API uses short-lived JWT access tokens. There is currently no refresh
token endpoint (the DB table exists; the HTTP handler is a planned addition).
Clients must re-authenticate via POST /api/auth/login when the token expires.
Token lifetime: 15 minutes (access token)
JWT Claims:
{
"uid": "uuid",
"did": "device-id-string",
"bot": false,
"iss": "zipp-chat",
"iat": 1234567890,
"exp": 1234568790
}
3.2 Sending the Token¶
REST requests:
WebSocket connection:
(Browser WebSocket API cannot set custom headers; the query param is the only supported method.)3.3 Device Identity¶
Each client device has a unique device_id (UUID, generated on first launch and
persisted locally). This is embedded in the JWT (did claim) and is required
for the E2EE multi-device key system.
Generate once, store in local secure storage (Keychain on iOS/macOS, EncryptedSharedPreferences on Android, OS credential store on desktop).
3.4 Register¶
POST /api/auth/register
Content-Type: application/json
{
"username": "alice",
"email": "[email protected]",
"password": "hunter2!!"
}
Password rules (enforced server-side): minimum 8 characters.
Response 201:
{
"access_token": "eyJ...",
"user": {
"id": "uuid",
"username": "alice",
"email": "[email protected]"
}
}
Errors: 400 validation, 409 email already in use.
Post-registration client actions (required for E2EE):
1. Generate identity key pair (Ed25519)
2. Generate signed prekey (X25519)
3. Generate one-time prekeys (X25519, batch of 100)
4. Upload key bundle to POST /api/keys/bundle
5. Upload prekeys to POST /api/keys/prekeys
3.5 Login¶
POST /api/auth/login
Content-Type: application/json
{
"email": "[email protected]",
"password": "hunter2!!"
}
Response 200:
{
"access_token": "eyJ...",
"user": {
"id": "uuid",
"username": "alice",
"email": "[email protected]"
}
}
Post-login client actions:
- Parse the did claim from the JWT. If this device has no local key material,
trigger the key restore flow (PIN backup) or generate fresh keys.
- Begin prekey replenishment: call GET /api/keys/prekeys/count; if count < 20,
generate and upload more.
4. Permissions¶
Zipp-Chat uses a Discord-style bitfield permission system. Permissions are stored
as int64 and resolved per-channel from role inheritance and channel overwrites.
4.1 Permission Bits¶
| Constant | Value | Description |
|---|---|---|
PermViewChannel |
1 << 0 |
See channel in list & read history |
PermSendMessages |
1 << 1 |
Post new messages |
PermSendTTSMessages |
1 << 2 |
Use /tts command |
PermManageMessages |
1 << 3 |
Delete/pin others' messages |
PermEmbedLinks |
1 << 4 |
Auto-embed URLs |
PermAttachFiles |
1 << 5 |
Upload attachments |
PermReadMessageHistory |
1 << 6 |
Load old messages |
PermMentionEveryone |
1 << 7 |
@everyone / @here mentions |
PermAddReactions |
1 << 8 |
React to messages |
PermConnect |
1 << 10 |
Join voice channel |
PermSpeak |
1 << 11 |
Speak in voice |
PermMuteMembers |
1 << 12 |
Server-mute others |
PermDeafenMembers |
1 << 13 |
Server-deafen others |
PermMoveMembers |
1 << 14 |
Move users between voice channels |
PermUseVAD |
1 << 15 |
Voice Activity Detection |
PermStream |
1 << 16 |
Screen share / stream |
PermChangeNickname |
1 << 17 |
Change own nickname |
PermManageNicknames |
1 << 18 |
Change others' nicknames |
PermManageRoles |
1 << 19 |
Create/edit/delete roles |
PermManageChannels |
1 << 20 |
Create/edit/delete channels |
PermManageServer |
1 << 21 |
Change server settings |
PermKickMembers |
1 << 22 |
Remove members from server |
PermBanMembers |
1 << 23 |
Ban members from server |
PermManageWebhooks |
1 << 24 |
Manage webhooks |
PermManageEmoji |
1 << 25 |
Add/remove custom emoji |
PermViewAuditLog |
1 << 26 |
View audit log |
PermViewServerInsights |
1 << 27 |
Server analytics |
PermModerateMembers |
1 << 28 |
Timeout members |
PermAdministrator |
1 << 29 |
Bypasses all permission checks |
4.2 Resolution Order¶
1. Start with @everyone role permissions
(@everyone role has id == server_id)
2. OR in permissions from every role the user holds
3. Apply channel overwrites in order:
a. Role deny bits
b. Role allow bits
c. Member deny bits
d. Member allow bits
4. If Administrator bit is set at any point → grant all permissions
The server enforces this on every protected endpoint. Clients should replicate this locally to show/hide UI elements, but must not rely on client checks for security — the server always re-validates.
5. HTTP API Reference¶
Conventions:
- All bodies and responses are application/json
- Auth: Required means Authorization: Bearer <token> header is mandatory
- UUIDs are lowercase hyphenated strings
- Timestamps are ISO 8601 (RFC 3339): "2024-01-15T10:30:00Z"
- Nullable fields may be omitted from responses when null
5.1 Auth¶
POST /api/auth/register¶
Register a new account.
Request:
{
"username": "string (required)",
"email": "string (required)",
"password": "string (required, min 8 chars)"
}
Response 201:
Errors: 400 (validation), 409 (email taken)
POST /api/auth/login¶
Authenticate an existing account.
Request:
Response 200:
Errors: 401 (invalid credentials)
5.2 Users¶
GET /api/users/@me¶
Get the authenticated user's full profile (includes email and
onboarded_at). onboarded_at is null until the first-run wizard is
finished or skipped; the client shows the wizard while it is null.
Auth: Required Response 200: User object
POST /api/users/@me/onboarding¶
Mark the first-run wizard done for this account, on every device. No body; idempotent (keeps the first timestamp).
Auth: Required
Response 200: User object, with onboarded_at set
PATCH /api/users/@me¶
Update own profile.
Auth: Required Request (all fields optional):
{
"username": "string",
"display_name": "string",
"avatar_url": "string",
"banner_url": "string",
"bio": "string",
"status": 1,
"status_text": "string"
}
Status values: 1 ONLINE, 2 IDLE, 3 DND, 4 OFFLINE, 5 INVISIBLE
Response 200: User object
GET /api/users/{userID}¶
Fetch a user's public profile. The email field is omitted for other users.
Auth: Required Response 200: User object (no email)
GET /api/servers/{serverID}/members/search?q={query}¶
Search server members by username or nickname.
Auth: Required Response 200: Array of ServerMember
5.3 Servers¶
POST /api/servers¶
Create a new server. Calling user becomes the owner and first member.
Auth: Required Request:
Response 201: Server object
GET /api/servers¶
List all servers the authenticated user is a member of.
Auth: Required Response 200: Array of Server
GET /api/servers/{serverID}¶
Fetch a single server.
Auth: Required (must be member) Response 200: Server object
PATCH /api/servers/{serverID}¶
Update server settings.
Auth: Required, PermAdministrator
Request (all optional):
{
"name": "string",
"description": "string",
"icon_url": "string",
"banner_url": "string",
"verification_level": 0,
"explicit_filter": 0,
"afk_timeout_seconds": 300,
"system_channel_id": "uuid",
"rules_channel_id": "uuid",
"afk_channel_id": "uuid"
}
Response 200: Server object
DELETE /api/servers/{serverID}¶
Permanently delete a server. Only the owner can call this.
Auth: Required (owner only) Response 204
5.4 Channels¶
GET /api/servers/{serverID}/channels¶
List all channels in a server.
Auth: Required, PermViewChannel
Response 200: Array of Channel
POST /api/servers/{serverID}/channels¶
Create a channel.
Auth: Required, PermManageChannels
Request:
{
"name": "string (required)",
"type": "text|voice|announcement|stage|category",
"topic": "string",
"position": 0,
"nsfw": false,
"parent_id": "uuid",
"slowmode_seconds": 0,
"bitrate": 64000,
"user_limit": 0
}
Response 201: Channel object
GET /api/channels/{channelID}¶
Fetch a single channel.
Auth: Required, PermViewChannel
Response 200: Channel object
PATCH /api/channels/{channelID}¶
Edit a channel.
Auth: Required, PermManageChannels
Request (all optional):
{
"name": "string",
"topic": "string",
"position": 0,
"nsfw": false,
"slowmode_seconds": 0,
"bitrate": 64000,
"user_limit": 0,
"parent_id": "uuid"
}
Response 200: Channel object
DELETE /api/channels/{channelID}¶
Delete a channel.
Auth: Required, PermManageChannels
Response 204
5.5 Roles¶
GET /api/servers/{serverID}/roles¶
List all roles in a server (includes the @everyone role, whose id == server_id).
Auth: Required Response 200: Array of Role
POST /api/servers/{serverID}/roles¶
Create a role.
Auth: Required, PermManageRoles
Request:
color is an RGB integer: 0xFF5733 for a red-orange tint.
Response 201: Role object
PATCH /api/servers/{serverID}/roles/{roleID}¶
Update a role.
Auth: Required, PermManageRoles
Request (all optional): same fields as create
Response 200: Role object
DELETE /api/servers/{serverID}/roles/{roleID}¶
Delete a role.
Auth: Required, PermManageRoles
Response 204
5.6 Members¶
GET /api/servers/{serverID}/members?limit=100&offset=0¶
Paginated member list.
Auth: Required
Query params: limit (max 100, default 100), offset (default 0)
Response 200: Array of ServerMember
DELETE /api/servers/{serverID}/members/{userID}¶
Kick a member (they can re-join via invite).
Auth: Required, PermKickMembers
Response 204
PUT /api/servers/{serverID}/members/{userID}/roles/{roleID}¶
Add a role to a member.
Auth: Required, PermManageRoles
Response 204
DELETE /api/servers/{serverID}/members/{userID}/roles/{roleID}¶
Remove a role from a member.
Auth: Required, PermManageRoles
Response 204
5.7 Bans¶
GET /api/servers/{serverID}/bans¶
List all bans.
Auth: Required, PermBanMembers
Response 200: Array of Ban
PUT /api/servers/{serverID}/bans/{userID}¶
Ban a user.
Auth: Required, PermBanMembers
Request:
Response 204
DELETE /api/servers/{serverID}/bans/{userID}¶
Unban a user.
Auth: Required, PermBanMembers
Response 204
5.8 Invites¶
POST /api/servers/{serverID}/invites¶
Create an invite link.
Auth: Required Request:
max_uses = 0 means unlimited. expires_in = 0 means never expires (seconds).
Response 201: Invite object
GET /api/invites/{code}¶
Fetch invite details (no auth required — public preview).
Response 200: Invite object
Errors: 404 (not found or expired)
POST /api/invites/{code}¶
Accept/use an invite. Adds the authenticated user to the server.
Auth: Required
Response 200: Server object (the server they joined)
Errors: 404 (not found), 409 (already a member), 403 (banned)
5.9 Messages¶
Note on encryption: Server channel messages are encrypted at rest with envelope encryption. The API service decrypts transparently on read and encrypts on write. Clients send and receive plaintext for server messages. DM messages (channel type
dm/group_dm) are true E2EE — see Section 7.
POST /api/channels/{channelID}/messages¶
Send a message to a server channel.
Auth: Required, PermViewChannel + PermSendMessages
Request:
Response 201: MessageResponse
GET /api/channels/{channelID}/messages?limit=50&before={messageID}¶
Fetch message history (cursor-based pagination).
Auth: Required, PermViewChannel + PermReadMessageHistory
Query params:
- limit: 1–100, default 50
- before: UUID of the oldest message from the previous page (omit for latest)
Response 200: Array of MessageResponse, newest first.
PATCH /api/channels/{channelID}/messages/{messageID}¶
Edit a message. Only the author can edit (unless they have PermManageMessages).
Auth: Required (author or PermManageMessages)
Request:
Response 200: MessageResponse
DELETE /api/channels/{channelID}/messages/{messageID}¶
Delete a message (soft delete).
Auth: Required (author or PermManageMessages)
Response 204
5.10 Voice¶
Voice uses LiveKit as the SFU. The workflow is: 1. Client calls the token endpoint to get a signed LiveKit JWT. 2. Client connects to the LiveKit server directly using the LiveKit SDK. 3. Voice state changes (mute, deafen, etc.) are broadcast via NATS.
POST /api/voice/channels/{channelID}/token¶
Mint a LiveKit token to join the voice channel.
Auth: Required, PermConnect
Response 200:
{
"token": "livekit-jwt-string",
"livekit_url": "wss://voice.yourdomain.com",
"room_key": "base64 32 bytes — absent for a DM",
"key_source": "server | client"
}
Use the LiveKit client SDK with token and livekit_url to connect, and
room_key as the frame cryptor's raw key. When key_source is client (a DM
call) there is no room_key: take the key from the call's E2EE ring message,
and refuse to connect without one. See docs/api.md § DM calls and
ProjectInfo/dm-calls.md.
GET /api/voice/channels/{channelID}/states¶
Fetch who is currently in a voice channel.
Auth: Required Response 200: Array of VoiceState
PATCH /api/voice/channels/{channelID}/state¶
Report your own mute/deafen so it shows beside your name for everyone.
Auth: Required
Request: { "self_mute": bool, "self_deaf": bool }
Response 204. 404 until the join webhook has created your row: send it
on every toggle, and again whenever GET .../states shows your row
disagreeing with your local state. That one rule covers the join race and any
dropped request.
5.11 Media¶
Media is served from a separate subdomain (media.yourdomain.com). The upload
process is two-step to allow the server to pre-authorize the upload and perform
CSAM scanning.
All endpoints on this service require the same Authorization: Bearer header.
POST /media/upload/request¶
Step 1: Request an upload token.
Auth: Required Request:
Response 200:
The upload token is a short-lived JWT (5 minutes) scoped to one upload.
POST /media/upload/finalize¶
Step 2: Upload the actual file.
Auth: Required
Request: multipart/form-data
- upload_token (text field): the token from step 1
- file (file field): the binary file data
Response 201: Attachment
The server will: 1. Validate the upload token 2. Run CSAM hash checks (SHA-256 exact match + pHash for images) 3. Encrypt the file with a per-file DEK wrapped by the media KEK from Vault 4. Store the encrypted blob in MinIO 5. Generate a 256px thumbnail for images
GET /media/attachments/{attachmentID}¶
Download a file. The server decrypts on the fly.
Auth: Required
Response: Binary file data with appropriate Content-Type
DELETE /media/attachments/{attachmentID}¶
Delete an attachment (soft delete).
Auth: Required (uploader only) Response 204
GET /media/csam/manifest¶
Get metadata about the downloadable CSAM hash database for local client-side scanning.
Auth: Required Response:
{
"version": "string",
"size": 12345,
"download_token": "jwt-string",
"client_key": "base64-encoded-key"
}
GET /media/csam/hashdb?token={download_token}¶
Download the encrypted CSAM hash database.
Auth: Required
Response: Encrypted binary database. Decrypt with client_key from manifest.
Clients MAY implement local CSAM scanning before upload as a defence-in-depth measure. This is optional — the server always scans server-side.
5.12 Key Bundles (Keyserver)¶
These endpoints are served by the keyserver service but are accessible via the
API base URL at /api/keys/. They manage the public-key material needed for
the X3DH E2EE handshake.
POST /api/keys/bundle¶
Upload or update this device's key bundle. Call after registration and after rotating the signed prekey (recommended every 7 days).
Auth: Required Request:
{
"identity_key": "base64",
"signed_prekey": "base64",
"signed_prekey_id": 1,
"signed_prekey_signature": "base64"
}
identity_key: Ed25519 public key (32 bytes, base64)signed_prekey: X25519 public key (32 bytes, base64)signed_prekey_signature: Ed25519 signature over the signed prekey (64 bytes, base64)signed_prekey_id: monotonically increasing integer, increment on each rotation
Response 204
GET /api/keys/bundle/{userID}¶
Fetch the key bundle for another user (to initiate an E2EE session). Returns one bundle per device. This endpoint consumes one one-time prekey per device and returns it in the response.
Auth: Not required Response 200:
[
{
"identity_key": "base64",
"signed_prekey": "base64",
"signed_prekey_signature": "base64",
"signed_prekey_id": 1,
"one_time_prekey": {
"id": 42,
"key": "base64"
}
}
]
One entry per device belonging to the target user. one_time_prekey is
null if all OTPs have been consumed (X3DH must proceed without it).
POST /api/keys/prekeys¶
Upload a batch of one-time prekeys.
Auth: Required Request:
Call this whenever GET /api/keys/prekeys/count returns < 20 remaining.
Upload batches of 100 at a time.
Response 204
GET /api/keys/prekeys/count¶
Check how many one-time prekeys remain for this device.
Auth: Required Response 200:
5.13 Key Backup (PIN)¶
Allows users to back up E2EE private keys encrypted with an 8-digit PIN. On new device setup, users can restore their key material using the PIN.
PUT /api/keys/backup¶
Create or overwrite the PIN-encrypted key backup.
Auth: Required Request:
{
"salt": "base64 (16 bytes)",
"encrypted_blob": "base64",
"pin_verifier": "base64",
"argon2_memory": 65536,
"argon2_iterations": 3,
"argon2_parallelism": 4,
"version": 1
}
See Section 7.3 for the full encryption procedure.
Response 204
GET /api/keys/backup/params, POST /api/keys/backup/unlock¶
The restore path to use. params returns the salt and KDF parameters only
(no blob, no verifier). Derive pin_verifier = GeneratePINVerifier(pin, salt)
— the same value sent on PUT — and POST /unlock {"pin_verifier": "<base64>"}.
The server compares it in constant time under the 10-attempt / 24-hour lockout
and only then returns the blob. 401 carries remaining; 429 carries
locked_until. The PIN itself never leaves the device.
GET /api/keys/backup (blob with no check) is deprecated: refused while locked
and limited to 20 fetches a day. See docs/api.md.
POST /api/keys/backup/restore¶
Restore a key backup by providing the PIN.
Auth: Required Request:
Response 200:
{
"encrypted_blob": "base64",
"salt": "base64",
"argon2_memory": 65536,
"argon2_iterations": 3,
"argon2_parallelism": 4
}
Derive the key from the PIN using Argon2id with the returned parameters and salt, then decrypt the blob with AES-256-GCM.
Errors:
- 401 with "too many attempts" — 10 failed attempts triggers a 24-hour lockout
- 401 — wrong PIN
DELETE /api/keys/backup¶
Delete the key backup.
Auth: Required Response 204
5.14 Device Tokens (Push Notifications)¶
POST /api/device-tokens¶
Register a push notification token for this device.
Auth: Required Request:
platform: "fcm" (Android/Firebase) or "apns" (iOS/Apple).
Response 204
DELETE /api/device-tokens/{token}¶
Unregister a push token (call on logout or when the token is refreshed by the platform SDK).
Auth: Required Response 204
5.15 CSAM Reporting¶
POST /api/reports/csam¶
Report a suspected CSAM attachment.
Auth: Required Request:
Duplicate reports (same reporter + same attachment, not yet dismissed) return
409 Conflict.
Response 201:
5.16 Direct Messages (DM)¶
DM channels have type "dm" (1:1) or "group_dm". Messages are true E2EE —
the server stores ciphertext only and never decrypts. See
Section 7 for the encryption protocol.
All DM endpoints require Authorization: Bearer auth.
POST /api/dm¶
Open or find an existing 1:1 DM channel with another user. Idempotent — returns the existing channel if one already exists.
Request:
Response 201 (new channel) or 200 (existing): DMChannel
Errors: 400 (self-DM attempted)
GET /api/dm¶
List all DM channels the authenticated user belongs to, ordered by most recently active first.
Response 200: Array of DMChannel
GET /api/dm/{channelID}¶
Fetch a single DM channel. Returns 403 if the user is not a member.
Response 200: DMChannel
DELETE /api/dm/{channelID}¶
Leave a DM channel (removes user from member list). The channel and all messages are retained for the other participant.
Response 204
POST /api/dm/{channelID}/messages¶
Send an E2EE DM message. The server stores ciphertext blobs only.
Request:
{
"ciphertext": "base64",
"ratchet_header": "base64",
"x3dh_header": "base64 (only on first message in a session, omit otherwise)",
"device_ciphertexts": [
{ "device_id": "string", "ciphertext": "base64" }
],
"reply_to_id": "uuid (optional)"
}
ciphertext: the Double Ratchet encrypted message payloadratchet_header: current ratchet public key + chain/message indicesx3dh_header: only present on the first message to a recipient (includesephemeral_key,signed_prekey_id,one_time_prekey_id,sender_device_id)device_ciphertexts: one entry per recipient device AND per sender device (so all own devices can decrypt)
Response 201: DMMessage
Errors: 400 (empty ciphertext/header), 403 (not a member)
Real-time: fires MESSAGE_CREATE on channel.<channelID>.MESSAGE_CREATE via
the WebSocket gateway.
GET /api/dm/{channelID}/messages?limit=50&before={messageID}¶
Fetch message history. Each message includes all device_ciphertexts;
the client locates its own device_id to decrypt.
Query params:
- limit: 1–100, default 50
- before: cursor UUID (oldest message from previous page)
Response 200: Array of DMMessage, newest first.
DELETE /api/dm/{channelID}/messages/{messageID}¶
Soft-delete a DM message. Only the original sender may delete.
Response 204
Errors: 403 (not sender), 404 (not found)
Real-time: fires MESSAGE_DELETE on the channel subject.
5.17 Friends¶
Friend requests are directional: one user sends, the other accepts or declines. A friendship is mutual once accepted.
All endpoints require Authorization: Bearer auth.
POST /api/users/@me/friends¶
Send a friend request. Supply either username or user_id — at least one is required.
Request:
orResponse 204
Errors: 400 (self-request or neither field provided), 404 (username/user not found), 409 (already friends or request pending), 403 (blocked)
GET /api/users/@me/friends¶
List accepted friends.
Response 200: Array of User
GET /api/users/@me/friends/pending¶
List incoming and outgoing pending friend requests.
Response 200:
{
"incoming": [ { "user_id": "uuid", "username": "string", "created_at": "ISO8601" } ],
"outgoing": [ { "user_id": "uuid", "username": "string", "created_at": "ISO8601" } ]
}
PUT /api/users/@me/friends/{userID}¶
Accept an incoming friend request.
Response 204
Errors: 404 (no pending request from that user)
DELETE /api/users/@me/friends/{userID}¶
Decline an incoming request, cancel an outgoing request, or remove an existing friend.
Response 204
5.x Blocks¶
A block is symmetric and enforced server-side: neither party can open a DM with the other, and neither can send into a DM they already shared. The blocked user is not notified and no event is published — being told you were blocked is a message from the person who blocked you.
Blocking replaces any friendship row between the two. Unblocking does not restore it.
GET /api/users/@me/blocks¶
List the users the caller has blocked. There is no endpoint for "who has blocked me", deliberately.
Response 200 — an array of the same user shape as GET /friends.
PUT /api/users/@me/blocks/{userID}¶
Block a user.
Response 204
Errors: 400 (blocking yourself)
DELETE /api/users/@me/blocks/{userID}¶
Lift a block the caller placed.
Response 204
Errors: 404 (no such block)
Both POST /api/dm and POST /api/dm/{channelID}/messages return 403 with
{"error": "cannot message this user"} when a block exists in either
direction. The message does not say which side blocked whom.
6. WebSocket Gateway¶
The real-time event gateway is a persistent WebSocket connection.
6.1 Connecting¶
Reconnect on disconnect with exponential backoff starting at 1 second, cap at
30 seconds. On reconnect, re-send the subscribe operation with the full list
of channel and server IDs the client cares about.
6.2 Message Format¶
All WebSocket messages are JSON text frames.
Client → Server (InboundMessage):
interface InboundMessage {
op: "subscribe" | "unsubscribe" | "typing" | "heartbeat";
channel_ids?: string[]; // for subscribe/unsubscribe
server_ids?: string[]; // for subscribe/unsubscribe
channel_id?: string; // for typing
}
Server → Client (OutboundMessage):
interface OutboundMessage {
op: "event" | "heartbeat_ack" | "hello" | "error";
type?: EventType; // when op == "event"
data?: any; // event payload
message?: string; // when op == "error"
}
6.3 Connection Lifecycle¶
Client Server
│ │
│── GET /gateway?token=xxx ─────────►│
│ │ (validates JWT)
│◄── {"op": "hello"} ───────────────│
│ │
│── {"op": "subscribe", │
│ "channel_ids": ["..."], │
│ "server_ids": ["..."]} ────────►│
│ │ (subscribes to NATS)
│◄── events as they occur ──────────│
│ │
│── {"op": "heartbeat"} ────────────►│ every 30 seconds
│◄── {"op": "heartbeat_ack"} ───────│
│ │
│── {"op": "typing", │
│ "channel_id": "..."} ──────────►│
│ │
Clients that fail to send a heartbeat within 60 seconds will be disconnected.
6.4 Subscribe / Unsubscribe¶
Subscribe to receive events for specific channels and servers:
Only ids the user may see are subscribed: a channel needs VIEW_CHANNEL
(membership included) or DM participation, a server needs membership. Refused
ids are dropped and the client receives one
{"op":"error","message":"subscription refused for ids you cannot view"}; the
rest of the frame still applies. typing needs VIEW_CHANNEL + SEND_MESSAGES.
Unsubscribe:
Subscribe to all channels you can read — not just the currently-open one. This ensures unread count badges update correctly across the UI.
6.5 Events¶
All events arrive as:
MESSAGE_CREATE¶
{
"op": "event",
"type": "MESSAGE_CREATE",
"data": {
"id": "uuid",
"channel_id": "uuid",
"author_id": "uuid",
"author_name": "string",
"author_avatar": "string",
"content": "string",
"type": 0,
"reply_to_id": "uuid",
"pinned": false,
"edited": false,
"created_at": "ISO8601"
}
}
MESSAGE_UPDATE¶
Same shape as MESSAGE_CREATE data, with edited: true and edited_at set.
MESSAGE_DELETE¶
TYPING_START¶
Typing indicators should expire client-side after 8 seconds if no further
TYPING_START arrives for the same user+channel.
MEMBER_JOIN¶
{
"op": "event",
"type": "MEMBER_JOIN",
"data": {
"server_id": "uuid",
"user_id": "uuid",
"username": "string"
}
}
MEMBER_UPDATE¶
{
"op": "event",
"type": "MEMBER_UPDATE",
"data": {
"server_id": "uuid",
"user_id": "uuid",
"nickname": "string | null",
"server_avatar": "string | null"
}
}
A member's per-server identity changed. Apply it to that server's member list
only — the values are scoped to server_id and say nothing about how the same
user appears anywhere else. Unlike USER_UPDATE, this is published on one
server's subject and is not fanned out to the other servers you share with them.
null means the field was cleared and the account-wide display_name /
avatar_url applies again.
ROLE_UPDATE¶
{
"op": "event",
"type": "ROLE_UPDATE",
"data": {
"server_id": "uuid",
"role_id": "uuid",
"permissions": 12345
}
}
On receipt, re-fetch the full role list for that server and recompute visible channels.
CHANNEL_CREATE / CHANNEL_UPDATE / CHANNEL_DELETE¶
IDs only, on purpose: these go to every member of the server, so including the name would hand a channel's name to members denied View channel. On receipt, refetch the channel list for that server — the server filters it per caller — rather than patching from the payload.
PRESENCE_UPDATE¶
{
"op": "event",
"type": "PRESENCE_UPDATE",
"data": {
"user_id": "uuid",
"status": 1,
"status_text": "string"
}
}
6.6 Buffering¶
The server buffers up to 256 outbound messages per client. If the client falls behind (e.g. slow network), older messages are dropped silently. On reconnect, clients should re-fetch recent messages via the REST API to fill any gaps.
7. End-to-End Encryption¶
7.1 Overview¶
DM messages are true E2EE. The server stores only ciphertext; no key material ever leaves the client device.
Server channel messages use envelope encryption (server-side) — the server holds KEKs in Vault and decrypts transparently. Clients send and receive plaintext for server messages.
7.2 Key Types¶
| Key type | Algorithm | Size | Purpose |
|---|---|---|---|
| Identity key | Ed25519 | 32 bytes | Long-term signing identity |
| Identity key | X25519 | 32 bytes | Derived from Ed25519 for DH (via conversion) |
| Signed prekey | X25519 | 32 bytes | Medium-term DH key (rotate weekly) |
| One-time prekey | X25519 | 32 bytes | Single-use forward secrecy key |
| Ephemeral key | X25519 | 32 bytes | Per-session DH key (generated by sender) |
| Message key | AES-256 | 32 bytes | Per-message encryption (Double Ratchet) |
7.3 X3DH Session Initiation¶
X3DH is used to establish a shared secret when a user sends the first DM to a recipient they have never messaged before.
Sender flow:
1. Fetch recipient's PreKeyBundle: GET /api/keys/bundle/{recipientID}
Bundle contains:
IK_B = recipient's identity key (X25519)
SPK_B = recipient's signed prekey
sig_B = signature over SPK_B (verify with IK_B as Ed25519)
OPK_B = one-time prekey (may be absent)
spk_id, opk_id = IDs for the header
2. Verify signature: Ed25519.Verify(IK_B, SPK_B, sig_B)
If invalid, abort. Never proceed with an unverified bundle.
3. Generate ephemeral key pair: EK = X25519.GenerateKeyPair()
4. Perform Diffie-Hellman operations:
DH1 = X25519(IK_A_private, SPK_B)
DH2 = X25519(EK_private, IK_B)
DH3 = X25519(EK_private, SPK_B)
if OPK_B present:
DH4 = X25519(EK_private, OPK_B)
KM = DH1 || DH2 || DH3 || DH4
else:
KM = DH1 || DH2 || DH3
5. Derive shared secret:
shared_secret = HKDF-SHA256(
ikm = KM,
salt = 0x00...00 (32 zero bytes),
info = "zipp-chat-x3dh-v1",
len = 32
)
6. Initialise Double Ratchet with shared_secret
7. Encrypt first message. Include X3DHHeader in the DM message:
{
ephemeral_key: EK_public (base64),
signed_prekey_id: spk_id,
one_time_prekey_id: opk_id (or 0 if none used),
sender_device_id: this device's ID
}
Recipient flow (first message):
1. Read X3DHHeader from the incoming dm_message
2. Look up your own keys:
SPK_B = signed prekey matching signed_prekey_id
OPK_B = one-time prekey matching one_time_prekey_id (if present)
IK_B = your identity key (private half)
IK_A = sender's identity key (fetch from keyserver if not cached)
3. DH1 = X25519(SPK_B_private, IK_A_public)
DH2 = X25519(IK_B_private, EK_A)
DH3 = X25519(SPK_B_private, EK_A)
if OPK_B present:
DH4 = X25519(OPK_B_private, EK_A)
KM = DH1 || DH2 || DH3 || DH4
else:
KM = DH1 || DH2 || DH3
4. shared_secret = HKDF-SHA256(KM, zeroes, "zipp-chat-x3dh-v1", 32)
5. Initialise Double Ratchet → decrypt message
7.4 Double Ratchet¶
After X3DH, all subsequent messages in a DM conversation use the Double Ratchet Algorithm. Implement using a standard Double Ratchet library or Signal Protocol SDK for your target platform.
Key properties:
- Forward secrecy: Compromising current keys does not decrypt past messages
- Break-in recovery: After some message exchanges, future messages become
secure even if keys were previously compromised
- Each message includes a ratchet_header (the new ratchet public key and
message number) for the recipient to step the ratchet
7.5 Multi-Device DM Delivery¶
When sending a DM from device A, the message must be encrypted once per recipient device AND once per sender device (so all your own devices can decrypt it).
Flow:
1. Fetch all devices for the recipient: GET /api/keys/bundle/{recipientID} (returns array)
2. Fetch all devices for yourself: GET /api/keys/bundle/{myUserID}
3. For each device (excluding the current device), check if an X3DH session
exists locally. If not, perform X3DH. If yes, use existing Double Ratchet session.
4. Encrypt the message key (or the plaintext, if symmetric) separately for
each device. Store in dm_device_ciphertexts.
5. Submit the DM message with all per-device ciphertexts.
7.6 AES-256-GCM Primitives¶
Used directly for PIN backup encryption and internally for server messages. Use this same scheme when you need client-side symmetric encryption.
Encrypt:
nonce = random(12 bytes)
ciphertext, tag = AES_GCM(key, nonce, plaintext, additional_data)
output = nonce || ciphertext || tag (nonce prepended)
Decrypt:
nonce = output[0:12]
ciphertext_and_tag = output[12:]
plaintext = AES_GCM_Decrypt(key, nonce, ciphertext_and_tag, additional_data)
Key: 32 bytes (AES-256). Nonce: 12 bytes random. Tag: 16 bytes (standard GCM).
7.7 PIN Backup¶
Allows restoring DM session keys on a new device using an 8-digit PIN.
Client-side PIN rules: Reject the following PINs before even sending to the server:
00000000, 11111111, 22222222, 33333333, 44444444,
55555555, 66666666, 77777777, 88888888, 99999999,
12345678, 87654321, 01234567, 76543210
Creating a backup:
1. Generate salt = random(16 bytes)
2. Derive key from PIN with full Argon2id:
key = Argon2id(
password = PIN as UTF-8 bytes,
salt = salt,
memory = 65536 KB (64 MB),
time = 3,
threads = 4,
keyLen = 32
)
3. Derive cheaper verifier key:
verifier_key = Argon2id(
password = PIN as UTF-8 bytes,
salt = salt XOR 0xFF (bitflip all salt bytes),
memory = 4096 KB (4 MB), ← much cheaper
time = 1,
threads = 1,
keyLen = 32
)
4. Encrypt key bundle with the key:
key_bundle_bytes = serialize(private_keys)
encrypted_blob = AES_GCM_Encrypt(key, key_bundle_bytes, user_id_bytes)
5. Store verifier:
pin_verifier = AES_GCM_Encrypt(verifier_key, PIN_bytes, user_id_bytes)
6. Upload: PUT /api/keys/backup with salt, encrypted_blob, pin_verifier
Restoring from backup:
1. GET /api/keys/backup/params → salt; derive pin_verifier locally;
POST /api/keys/backup/unlock with {pin_verifier}
2. Server compares the verifier under the PIN lockout, returns blob + salt
3. Re-derive key with full Argon2id(pin, salt, memory=65536, time=3, threads=4)
4. Decrypt encrypted_blob with key → private keys
5. Import private keys into local secure storage
6. Rebuild Double Ratchet sessions from restored key material
Lockout: 10 failed PIN attempts triggers a 24-hour server-side lockout. Display a countdown timer. After the lockout expires, the attempt counter resets.
8. Voice (LiveKit)¶
8.1 Joining a Voice Channel¶
1. GET /api/voice/channels/{channelID}/states
→ show current participants in UI
2. POST /api/voice/channels/{channelID}/token
→ { token, livekit_url, room_name }
3. livekit_room = LiveKit.connect(livekit_url, token)
4. Publish local audio track
5. Subscribe to remote tracks
8.2 LiveKit SDK¶
Use the official LiveKit client SDK for your platform:
- Web: @livekit/client
- iOS: LiveKitClient (Swift)
- Android: io.livekit.android
The room_name is the channel UUID. The token grants permission to join
that room only. Token TTL is short — request a new one if the connection drops.
8.3 Voice State Events¶
Voice state changes (mute, deafen, join, leave) are broadcast via the WebSocket
gateway. Subscribe to the server_id or channel_id to receive these.
Voice events are a planned extension — the current server implementation fires LiveKit webhooks to update the DB. The gateway events for voice state changes are not yet wired up.
8.4 coturn / TURN¶
On the self-hosted deployment, coturn is configured with --no-udp-relay to
force TCP relay through the Cloudflare Tunnel. This increases latency slightly
but avoids needing open UDP ports.
Clients should pass iceTransportPolicy: "all" to allow direct connection
attempts before falling back to relay.
9. Media Upload Flow¶
Client Media Service
│ │
│── POST /media/upload/request ────────────►│
│ { content_type: "image/jpeg" } │
│◄── { upload_token: "jwt..." } ────────────│
│ │
│── POST /media/upload/finalize ────────────►│
│ multipart: │ (scans for CSAM)
│ upload_token = "jwt..." │ (encrypts + stores)
│ file = <binary> │ (generates thumbnail)
│◄── Attachment { id, url, ... } ───────────│
│ │
│── POST /api/channels/{id}/messages ──────►│ (API service)
│ { content: "look", attachment_id: ... } │
Note: The API service
messagesendpoint currently acceptscontentonly. Attaching a media file to a message by ID is a planned extension.
10. Data Types Reference¶
User¶
interface User {
id: string; // UUID
username: string;
display_name: string | null;
avatar_url: string | null;
banner_url: string | null;
bio: string | null;
email: string | null; // only present on GET /api/users/@me
verified: boolean;
bot: boolean;
status: 1 | 2 | 3 | 4 | 5; // ONLINE=1, IDLE=2, DND=3, OFFLINE=4, INVISIBLE=5
status_text: string | null;
created_at: string; // ISO 8601
}
Server¶
interface Server {
id: string;
name: string;
description: string | null;
icon_url: string | null;
banner_url: string | null;
owner_id: string;
member_count: number;
verification_level: 0 | 1 | 2 | 3 | 4;
explicit_filter: 0 | 1 | 2;
system_channel_id: string | null;
rules_channel_id: string | null;
afk_channel_id: string | null;
afk_timeout_seconds: number;
created_at: string;
}
Channel¶
type ChannelType = "text" | "voice" | "announcement" | "stage" | "category" | "dm" | "group_dm";
interface Channel {
id: string;
server_id: string | null; // null for DMs
parent_id: string | null; // category parent
type: ChannelType;
name: string;
topic: string | null;
position: number;
nsfw: boolean;
slowmode_seconds: number;
bitrate: number; // voice channels only (default 64000)
user_limit: number; // 0 = unlimited
last_message_id: string | null;
created_at: string;
}
Role¶
interface Role {
id: string;
server_id: string;
name: string;
color: number; // RGB packed as integer, e.g. 0xFF5733
hoist: boolean; // show separately in member list
mentionable: boolean;
position: number;
permissions: number; // int64 bitfield
managed: boolean; // managed by integration
created_at: string;
}
ServerMember¶
interface ServerMember {
user: User;
server_id: string;
display_name: string | null;
nickname: string | null;
server_avatar: string | null;
role_ids: string[];
joined_at: string;
timeout_until: string | null;
deaf: boolean;
mute: boolean;
}
MessageResponse¶
interface MessageResponse {
id: string;
channel_id: string;
author_id: string;
// Display name if the account has one, else the username. Resolved per
// query, so a rename applies to old messages on the next fetch.
author_name: string;
author_avatar: string | null;
content: string; // plaintext (server decrypts envelope encryption)
type: number; // 0 = DEFAULT
reply_to_id: string | null;
pinned: boolean;
edited: boolean;
edited_at: string | null;
created_at: string;
}
Ban¶
interface Ban {
server_id: string;
user_id: string;
username: string;
reason: string | null;
banned_by: string;
created_at: string;
}
Invite¶
interface Invite {
code: string;
server_id: string;
server_name: string;
server_icon: string | null;
member_count: number;
channel_id: string | null;
channel_name: string | null;
creator_id: string;
uses: number;
max_uses: number | null; // null = unlimited
expires_at: string | null;
created_at: string;
}
VoiceState¶
interface VoiceState {
user_id: string;
session_id: string;
self_mute: boolean;
self_deaf: boolean;
self_video: boolean;
self_stream: boolean;
server_mute: boolean;
server_deaf: boolean;
suppress: boolean;
joined_at: string;
}
DMChannel¶
interface DMMember {
user_id: string;
username: string;
avatar: string | null;
joined_at: string;
}
interface DMChannel {
id: string;
type: "dm" | "group_dm";
last_message_id: string | null;
created_at: string;
members: DMMember[];
}
DMMessage¶
interface DeviceCiphertext {
device_id: string;
ciphertext: string; // base64
}
interface DMMessage {
id: string;
channel_id: string;
sender_id: string;
sender_device: string;
ciphertext: string; // base64 — Double Ratchet ciphertext
ratchet_header: string; // base64
x3dh_header: string | null; // base64 — only on first message in a session
device_ciphertexts: DeviceCiphertext[];
reply_to_id: string | null;
edited: boolean;
edited_at: string | null;
created_at: string;
}
Attachment¶
interface Attachment {
id: string;
uploader_id: string;
filename: string;
url: string;
proxy_url: string | null;
content_type: string;
size: number; // bytes
width: number | null;
height: number | null;
ephemeral: boolean;
}
11. Error Handling¶
All error responses follow this shape:
| Status | Meaning |
|---|---|
| 400 | Bad request — missing or invalid fields |
| 401 | Unauthorized — missing, expired, or invalid token |
| 403 | Forbidden — authenticated but insufficient permissions |
| 404 | Not found |
| 409 | Conflict — duplicate resource |
| 429 | Too many requests — rate limit hit |
| 500 | Internal server error |
Recommended client behaviour:
- 401: Clear stored token, redirect to login screen
- 429: Read Retry-After header if present; otherwise use exponential backoff
starting at 1 second, doubling up to 30 seconds
- 5xx: Show user-visible error, retry once after 2 seconds, then fail gracefully
12. Rate Limiting¶
| Limit | Window | Count | Applied to |
|---|---|---|---|
| Global | 1 second | 50 | Per authenticated user (IP for anon) |
| Auth (login/register) | 15 minutes | 10 | Per IP |
| Message send | 5 seconds | 5 | Per user |
| Gateway connect | 30 seconds | 5 | Per IP |
When a rate limit is hit, the server returns 429 Too Many Requests. The
response may include a Retry-After: <seconds> header.
13. Known Gaps / Not Yet Built¶
These features have database schemas and/or partial server implementation but no complete HTTP endpoints yet. Do not implement client-side UI for these until the server endpoints exist.
| Feature | Status |
|---|---|
| Refresh token endpoint | DB table exists (refresh_tokens), no handler |
| Group DM creation/management | Schema exists, no handler |
| WebSocket voice state events | LiveKit webhook updates DB, no WS broadcast |
| Message reactions | DB table exists, no handler |
| Message pinning | DB table exists, no handler |
| Channel read states / unread | DB table exists, no handler |
| Audit log | DB table exists, no handler |
| Attachment on message send | Schema partial — message → attachment linkage |
Appendix A — Permission Computation Example¶
Server with:
@everyone (id == server_id): permissions = PermSendMessages | PermViewChannel
Role "Moderator": permissions = PermManageMessages | PermKickMembers
Alice's roles: [@everyone, Moderator]
Channel #announcements overwrites:
@everyone: deny PermSendMessages
Moderator: allow PermSendMessages
Alice's effective permissions in #announcements:
1. Base = @everyone perms = PermSendMessages | PermViewChannel
2. Add Moderator = PermManageMessages | PermKickMembers
→ PermSendMessages | PermViewChannel | PermManageMessages | PermKickMembers
3. Apply channel overwrites:
a. @everyone deny PermSendMessages → remove PermSendMessages
b. Moderator allow PermSendMessages → add PermSendMessages back
4. Final = PermSendMessages | PermViewChannel | PermManageMessages | PermKickMembers
Appendix B — NATS Subject Reference¶
Clients never interact with NATS directly. This table is for understanding what the WebSocket gateway subscribes to internally.
| Subject pattern | Emitted when |
|---|---|
channel.<channelID>.MESSAGE_CREATE |
Message posted to channel |
channel.<channelID>.MESSAGE_UPDATE |
Message edited |
channel.<channelID>.MESSAGE_DELETE |
Message deleted |
channel.<channelID>.TYPING_START |
User starts typing |
server.<serverID>.MEMBER_JOIN |
New member joins server |
server.<serverID>.ROLE_UPDATE |
Role permissions changed |
server.<serverID>.CHANNEL_CREATE |
Channel created |
server.<serverID>.CHANNEL_UPDATE |
Channel renamed or moved |
server.<serverID>.SERVER_UPDATE |
Server name, icon or description |
server.<serverID>.CHANNEL_DELETE |
Channel deleted |
server.<serverID>.MEMBER_UPDATE |
A member's nickname or server avatar |
server.<serverID>.USER_UPDATE |
A member's profile changed |
user.<userID>.PRESENCE_UPDATE |
User changes status |
user.<userID>.ACCOUNT_UPDATE |
Own account: email verified |