Skip to content

Data Types Reference

Partly out of date

Rendered from ProjectInfo/data-types.md on docs/encryption-modes-spec. Its protocol content is still useful, but parts of it describe an older client or an earlier API. Where it disagrees with the code, the code wins — see the spec errata.

Uses the design names for encryption modes. The wire values are persistent and secure.

⚠️ Where this document and Encryption Modes disagree, that document wins. It reconciles the mode names, key distribution and media model across the specs and the running code, which have diverged. EncryptionMode here uses the spec names; the database enum currently uses persistent/secure.

All types returned by the API as JSON. UUIDs are lowercase hyphenated strings. Timestamps are ISO 8601.

User

interface User {
  id: string;
  username: string;
  display_name: string | null;
  avatar_url: string | null;    // relative attachment path e.g. /media/attachments/{id}
  banner_url: string | null;
  bio: string | null;
  email: string | null;         // only 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;
}

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";

// Three encryption modes — set at channel creation, cannot be changed later.
type EncryptionMode =
  | "server_encrypted"  // Vault KEK; server can read; full history on join
  | "rolling_key"       // client epoch keys; server cannot read; no history before join
  | "peer_provided";    // no server key storage; key delivered P2P; server cannot read

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 (default 64000)
  user_limit: number;            // 0 = unlimited
  last_message_id: string | null;
  encryption_mode: EncryptionMode;
  ck_version: number;            // for rolling_key: current epoch number; 0 = no epoch yet
  created_at: string;
  updated_at: string;
}

Encryption mode summary:

Mode Who holds key Server can read History on join
server_encrypted Server (Vault KEK) Yes — disclosed to users Full
rolling_key Clients only (epoch keys) No None
peer_provided Clients only (P2P delivery) No None

ChannelEpoch

Returned by POST /api/channels/{id}/epoch. Represents one key rotation record for a rolling_key channel.

interface ChannelEpoch {
  id: string;           // UUID identifying this epoch
  channel_id: string;
  epoch_num: number;    // 1-based counter; increments monotonically
  created_by: string;   // user_id of the client that issued the rotation
  created_at: string;
  member_hash: string;  // SHA-256 of sorted server member user_id list at rotation time
}

EpochKeyResponse

Returned by GET /api/channels/{id}/epoch. Contains the caller's sealed epoch key for the current epoch.

interface EpochKeyResponse {
  epoch_id: string;      // UUID of the current epoch
  epoch_num: number;
  encrypted_key: string; // base64 — 32-byte epoch key sealed with caller's device IK X25519
}

EpochGrant

Used in POST /api/channels/{id}/epoch and POST /api/channels/{id}/epoch/{epochID}/grants request bodies.

interface EpochGrant {
  user_id: string;
  device_id: string;
  encrypted_key_b64: string; // base64 — 32-byte epoch key sealed with device IK X25519
}

PermissionOverwrite

interface PermissionOverwrite {
  id: string;
  channel_id: string;
  target_id: string;              // role or member user ID
  target_type: "role" | "member";
  allow: number;                  // int64 bitfield — permissions explicitly granted
  deny: number;                   // int64 bitfield — permissions explicitly denied
}

Resolution order: @everyone → role permissions → channel overwrite (role deny → role allow → member deny → member allow). Administrator short-circuits all checks.

ReadState

interface ReadState {
  channel_id: string;
  user_id: string;
  last_read_id: string | null;  // ID of the last acknowledged message
  mention_count: number;         // unread @mentions since last ack
  updated_at: string;
}

Role

interface Role {
  id: string;
  server_id: string;
  name: string;
  color: number;                 // RGB packed integer, e.g. 0xFF5733
  hoist: boolean;
  mentionable: boolean;
  position: number;
  permissions: number;           // int64 bitfield
  managed: boolean;
  created_at: string;
}

ServerMember

interface ServerMember {
  server_id: string;
  user_id: string;
  username: string;
  avatar_url: string | null;
  nickname: string | null;
  server_avatar: string | null;
  deaf: boolean;
  mute: boolean;
  timeout_until: string | null;
  joined_at: string;
  roles: string[];               // role IDs
}

Note: The server returns a flat struct — NOT { user: User, ... }. Use member.user_id, member.username directly.

MessageResponse

The content field and decryption behavior depend on encryption_mode:

  • server_encrypted: content is the decrypted plaintext. Server decrypts transparently.
  • rolling_key: content is base64(nonce || ciphertext). Client decrypts using the epoch key for epoch_id.
  • peer_provided: content is an opaque encrypted blob. Client decrypts using a peer-provided key.
interface MessageResponse {
  id: string;
  channel_id: string;
  author_id: string;
  author_name: string;
  author_avatar: string | null;
  content: string;
  encryption_mode: EncryptionMode;
  epoch_id: string | null;        // set for rolling_key messages; null otherwise
  type: number;                   // 0 = DEFAULT
  reply_to_id: string | null;
  pinned: boolean;
  edited: boolean;
  edited_at: string | null;
  created_at: string;
  attachments: Attachment[];
}

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

All files are encrypted by the client before upload. url is a path to the raw ciphertext; the client must fetch metadata from /media/attachments/{id} to determine how to decrypt.

interface Attachment {
  id: string;
  uploader_id: string;
  filename: string;
  url: string;               // path to raw ciphertext — NOT directly displayable
  proxy_url: string | null;
  content_type: string;
  size: number;              // bytes (of ciphertext)
  width: number | null;
  height: number | null;
  ephemeral: boolean;
}

AttachmentMeta

Returned by GET /media/attachments/{id}. Contains everything the client needs to decrypt.

type AttachmentKeyType = "public" | "dm" | "server_encrypted";

interface AttachmentMeta {
  id: string;
  filename: string;
  content_type: string;
  size: number;
  key_type: AttachmentKeyType;
  raw_url: string;              // URL to fetch raw ciphertext bytes
  // For key_type = "public":
  dek_b64: string | null;       // plaintext 32-byte DEK in base64
  // For key_type = "dm":
  encrypted_dek_b64: string | null; // sealed DEK for caller's device (base64)
  // For key_type = "server_encrypted":
  // raw_url points to a legacy endpoint that decrypts server-side
}

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;
}

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;
}

Ban

interface Ban {
  server_id: string;
  user_id: string;
  username: string;
  reason: string | null;
  banned_by: string;
  created_at: string;
}

Friend / FriendRequest

interface Friend {
  user: User;
  status: "accepted";
}

interface PendingFriend {
  user_id: string;
  username: string;
  created_at: string;
}

PreKeyBundle (keyserver)

interface PreKeyBundle {
  identity_key: string;             // base64, Ed25519 public key
  signed_prekey: string;            // base64, X25519 public key
  signed_prekey_signature: string;  // base64, Ed25519 signature
  signed_prekey_id: number;
  one_time_prekey: {
    id: number;
    key: string;                    // base64, X25519 public key
  } | null;                         // null if all OTPs consumed
}

Errors

{ "error": "human-readable message", "code": 400 }
Status Meaning
400 Bad request — missing or invalid fields
401 Unauthorized — missing/expired/invalid token
403 Forbidden — insufficient permissions
404 Not found
409 Conflict — stale epoch number or duplicate resource
422 Unprocessable — grant set does not cover all server members
429 Rate limit hit
500 Internal server error