Skip to content

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

  1. Architecture Overview
  2. Base URLs & Service Map
  3. Authentication
  4. Permissions
  5. HTTP API Reference
  6. 5.1 Auth
  7. 5.2 Users
  8. 5.3 Servers
  9. 5.4 Channels
  10. 5.5 Roles
  11. 5.6 Members
  12. 5.7 Bans
  13. 5.8 Invites
  14. 5.9 Messages
  15. 5.10 Voice
  16. 5.11 Media
  17. 5.12 Key Bundles
  18. 5.13 Key Backup (PIN)
  19. 5.14 Device Tokens
  20. 5.15 CSAM Reporting
  21. 5.16 Direct Messages (DM)
  22. 5.17 Friends
  23. WebSocket Gateway
  24. End-to-End Encryption
  25. Voice (LiveKit)
  26. Media Upload Flow
  27. Data Types Reference
  28. Error Handling
  29. Rate Limiting
  30. 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:

Authorization: Bearer <access_token>

WebSocket connection:

GET /gateway?token=<access_token>
(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:

{
  "access_token": "string",
  "user": { "id": "uuid", "username": "string", "email": "string" }
}

Errors: 400 (validation), 409 (email taken)


POST /api/auth/login

Authenticate an existing account.

Request:

{
  "email": "string",
  "password": "string"
}

Response 200:

{
  "access_token": "string",
  "user": { "id": "uuid", "username": "string", "email": "string" }
}

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:

{
  "name": "string (required)",
  "description": "string (optional)"
}

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:

{
  "name": "string (required)",
  "color": 0,
  "hoist": false,
  "mentionable": false,
  "permissions": 0
}

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:

{ "reason": "string (optional)" }

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,
  "expires_in": 86400
}

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:

{
  "content": "string (required)",
  "reply_to_id": "uuid (optional)"
}

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:

{ "content": "string (required)" }

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:

{ "content_type": "image/png" }

Response 200:

{ "upload_token": "jwt-string" }

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:

{
  "prekeys": [
    { "key_id": 1, "public_key": "base64" },
    { "key_id": 2, "public_key": "base64" }
  ]
}

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:

{ "count": 87 }


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:

{ "pin": "12345678" }

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",
  "token": "fcm-registration-token"
}

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:

{
  "attachment_id": "uuid",
  "evidence_note": "string (optional)"
}

Duplicate reports (same reporter + same attachment, not yet dismissed) return 409 Conflict.

Response 201:

{ "report_id": "uuid" }


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:

{ "user_id": "uuid" }

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 payload
  • ratchet_header: current ratchet public key + chain/message indices
  • x3dh_header: only present on the first message to a recipient (includes ephemeral_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:

{ "username": "alice" }
or
{ "user_id": "uuid" }

Response 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

GET wss://api.yourdomain.com/gateway?token=<access_token>

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:

{
  "op": "subscribe",
  "channel_ids": ["uuid1", "uuid2"],
  "server_ids": ["uuid3"]
}

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:

{
  "op": "unsubscribe",
  "channel_ids": ["uuid1"],
  "server_ids": []
}

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:

{
  "op": "event",
  "type": "EVENT_TYPE",
  "data": { ... }
}

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

{
  "op": "event",
  "type": "MESSAGE_DELETE",
  "data": {
    "id": "uuid",
    "channel_id": "uuid"
  }
}

TYPING_START

{
  "op": "event",
  "type": "TYPING_START",
  "data": {
    "channel_id": "uuid",
    "user_id": "uuid"
  }
}

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

{
  "op": "event",
  "type": "CHANNEL_CREATE",
  "data": {
    "server_id": "uuid",
    "channel_id": "uuid"
  }
}

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
Also reject any PIN that is not exactly 8 decimal digits.

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 messages endpoint currently accepts content only. 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:

{
  "error": "human-readable message"
}

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