Skip to content

Zipp-Chat — API Reference

Canonical specification

This page is Server/docs/api.md on main, rendered at build time. That file is the source of truth; the site holds no copy of it.

Table of Contents

  1. Overview
  2. Authentication
  3. Error Format
  4. Rate Limiting
  5. Pagination
  6. Auth Endpoints
  7. Server Endpoints
  8. Channel Endpoints
  9. Role Endpoints
  10. Member Endpoints
  11. Invite Endpoints
  12. Message Endpoints
  13. User Endpoints
  14. Voice Endpoints
  15. Media Endpoints
  16. Key Endpoints (E2EE)
  17. Push Endpoints
  18. WebSocket Gateway
  19. Permission Bits Reference
  20. E2EE Client Implementation Guide

1. Overview

Base URLs

Service Base URL
REST API https://api.{domain}
Media https://media.{domain}
Voice https://voice.{domain}
Keyserver https://keys.{domain}
WebSocket Gateway wss://api.{domain}/gateway

Replace {domain} with your deployment domain (e.g. zipp.example.com).

Content Type

All request bodies must be application/json unless the endpoint specifies multipart/form-data. All responses are application/json.

Timestamps

All timestamps are ISO 8601 in UTC: 2024-01-15T10:30:00Z.


2. Authentication

Most endpoints require a valid JWT access token issued by the Auth service.

Including the Token

Authorization: Bearer <access_token>

Token Lifetime

Access tokens are short-lived (configured by JWT_SECRET; check with your operator for the exact duration). There is currently no refresh token flow — clients should re-authenticate when they receive a 401.

WebSocket Exception

WebSocket connections cannot set headers in-browser. Pass the token as a query parameter:

wss://api.{domain}/gateway?token=<access_token>

3. Error Format

All errors return a JSON body with a single error field:

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

Common HTTP Status Codes

Code Meaning
200 OK
201 Created
204 No Content (no body)
400 Bad Request — invalid input
401 Unauthorized — missing or invalid token
403 Forbidden — authenticated but not permitted
404 Not Found
409 Conflict — resource already exists
410 Gone — resource expired or exhausted
413 Payload Too Large
415 Unsupported Media Type
429 Too Many Requests — rate limited
500 Internal Server Error

Special Error Shapes

The keyserver returns richer error bodies for PIN operations:

// Too many wrong PIN attempts — account locked
{
  "error": "account locked",
  "locked_until": "2024-01-15T11:00:00Z"
}

// Wrong PIN with attempts remaining
{
  "error": "incorrect PIN",
  "remaining": 7
}

4. Rate Limiting

Rate limit information is returned in response headers:

X-RateLimit-Limit: 50
X-RateLimit-Remaining: 49
X-RateLimit-Reset: 1705312200

When exceeded, HTTP 429 is returned with a Retry-After header.

Preset Limits

Scope Limit Window
Global (per user/IP) 50 req 1 second
Message send 5 req 5 seconds
Auth (register/login) 10 req 15 minutes
WebSocket connect 5 connections 30 seconds
Link preview proxy (per user) 20 req 1 minute
Verification resend (per user) 5 req 1 hour — plus a 60 s cooldown between two links

5. Pagination

Endpoints that return lists support cursor-based pagination via query parameters:

Parameter Type Description
limit integer Number of results (default 50, max 100)
before string Return records created before this ID
offset integer Skip N records (used for members list)

Results are returned newest-first unless otherwise noted.


6. Auth Endpoints

Base: https://api.{domain}


POST /api/auth/register

Create a new user account.

Authentication: None

Request:

{
  "username": "alice",
  "email": "[email protected]",
  "password": "hunter2",
  "device_id": "d7f3a91c22b4e08a"
}

Field Type Required Notes
username string Yes Unique display name
email string Yes Unique; used for login
password string Yes Hashed with bcrypt server-side
device_id string No See Device identity

Response 201:

{
  "access_token": "eyJhbGci...",
  "user": {
    "id": "018e1234-abcd-7000-8000-112233445566",
    "username": "alice",
    "email": "[email protected]",
    "email_verified": false
  }
}

On an instance that sends mail, a verification link is mailed to email after the account is created — off the request, so a slow or failing relay neither delays nor fails sign-up. See Email verification.

Errors: | Code | Condition | |------|-----------| | 409 | email already registered or username already taken (case-insensitive) |


POST /api/auth/login

Authenticate with email and password.

Authentication: None

Request:

{
  "email": "[email protected]",
  "password": "hunter2",
  "device_id": "d7f3a91c22b4e08a"
}

Response 200:

{
  "access_token": "eyJhbGci...",
  "user": {
    "id": "018e1234-abcd-7000-8000-112233445566",
    "username": "alice",
    "email": "[email protected]"
  }
}

Errors: | Code | Condition | |------|-----------| | 400 | Malformed device_id | | 401 | Invalid credentials | | 429 | Too many attempts — from this IP, or against this account |


Device identity

device_id identifies a client installation, not an account. It is stored in the JWT's did claim and on the refresh token, and it is the key that key_bundles, one_time_prekeys and every sealed-to-a-device payload are scoped by.

Generate one on first run and persist it. Send the same value on every register, login and key-bundle upload from that installation.

  • 8–64 characters of A-Z a-z 0-9 _ -. A malformed value is a 400, not silently rewritten: the id ends up as a primary-key component, and coercing it would attach a device's keys to an identifier it does not think it has.
  • Omitting it yields the shared id default. Intended for bots and scripts, which have no key material. Two clients that both omit it will overwrite each other's published key bundle, and the loser can no longer decrypt anything sealed to it.
  • POST /api/auth/refresh takes no device_id — it reuses the one the refresh token was issued with, so a refresh cannot move a session to a different device.
  • The second step of a 2FA sign-in takes none either: the two_factor_token carries the device from the password step.

POST /api/auth/password/reset

Spends a one-time reset link issued by an instance admin. Public, and in the auth rate-limit group.

{ "token": "<from the link>", "new_password": "at least 8 characters" }

204 on success; every session for the account is then revoked. 400 with reset link is invalid or has expired for a used, expired, superseded or unknown token — the caller is not told which. Two-factor is unchanged: an account with 2FA still needs its code to sign in. E2EE keys are untouched (the password never protects them).

Mailed links from the forgot-password flow are spent here too; they are the same tokens, valid for 1 hour instead of 24.

Email verification

An address is verified by opening a link mailed to it. Verification is advisory: an unverified account can do everything a verified one can. What it buys is recovery — a forgot-password link is only ever mailed to a verified address. Accounts that existed before migration 000032 were marked verified.

email_verified (boolean) is on the user object of every auth response and on GET /api/users/@me. It is not verified, which is account standing (the CSAM pipeline clears it on suspension).

Links point at {PUBLIC_URL}/account/verify#token=… and {PUBLIC_URL}/account/reset#token=… — small static pages the api serves, which read the token from the fragment (never sent to a server, never in a Referer), wipe it from the address bar and call the endpoints below. Tokens are 256-bit, single use, stored as SHA-256 only, and a new link retires every earlier unused one for the account. A verification link is also bound to the address it was sent to.

GET /api/auth/email

Authentication: None. { "enabled": true } when this instance can send mail. Without it clients show no verification banner and no "Forgot password?".

POST /api/auth/email/verify/send

Authentication: Bearer. Mails the caller a new link (valid 48 hours). Empty body.

Code Condition
202 Sent
409 email is already verified
429 A link was sent less than 60 s ago (Retry-After: 60), or the 5/hour budget is spent
502 The mail relay refused or was unreachable; try again after the cooldown
503 This instance has no mail configured

POST /api/auth/email/verify

Authentication: None — the link may be opened in a browser that is not signed in; the token is the credential. In the auth rate-limit group.

{ "token": "<from the link>" }

204 on success. 400 verification link is invalid or has expired for a used, expired, superseded or unknown token, or one sent to an address the account no longer has. On success the account's own sessions receive ACCOUNT_UPDATE { "email_verified": true } on the gateway.

POST /api/auth/password/forgot

Authentication: None. In the auth rate-limit group (per IP and per email).

{ "email": "[email protected]" }

Always 202 — whether or not the address has an account, is verified, or was sent a link in the last minute — so it cannot be used to discover who has an account. The lookup and the mail happen after the response. A link is sent only to a verified address on a non-bot account; it opens /account/reset and is spent at POST /api/auth/password/reset. 503 only when the instance has no mail configured, which is true for every caller.

Instance admin — /api/admin/*

Requires a JWT and users.instance_admin = true, read fresh on every request; anyone else gets 403 not an instance admin. The console at /admin/ (served by the api) drives these. Every mutation writes an instance_audit_log row first and is refused if that write fails.

Method Path Notes
GET /api/admin/me {user_id, admin: true} — the console's admin check
GET /api/admin/stats counts (users, servers, channels, messages, DM messages, files, storage bytes, active in 24 h, admins) + api commit / built_at
GET /api/admin/servers?q=&offset= 50 per page; q matches name, owner username or exact id. Metadata only
DELETE /api/admin/servers/{serverID} soft delete, same as the owner deleting it. 204 / 404
GET /api/admin/users?q=&offset= 50 per page; q matches username, display name, email or exact id. last_active is the newest refresh-token issue
DELETE /api/admin/users/{userID} soft delete + revoke sessions, same as self-deletion. Owned servers are left ownerless. 400 for yourself
POST /api/admin/users/{userID}/password-reset 201 {token, path, expires_at}; path is /admin/reset.html#token=…, valid 24 h, single use, supersedes earlier links
GET /api/admin/audit?offset= newest first, 50 per page

There is no endpoint that grants admin. See docs/deployment-k3s.md.


7. Server Endpoints

Base: https://api.{domain}

A server is a community space (equivalent to a Discord guild).


POST /api/servers

Create a new server. The caller becomes the owner and is added as a member. An @everyone role and a #general text channel are created automatically.

Authentication: Required

Request:

{
  "name": "My Server",
  "description": "A place to hang out"
}

Field Type Required
name string Yes
description string | null No

Response 201: Server object


GET /api/servers/{serverID}

Get a server by ID. Caller must be a member.

Authentication: Required

Response 200: Server object


GET /api/servers

List all servers the caller belongs to.

Authentication: Required

Response 200: [ Server, ... ]


PATCH /api/servers/{serverID}

Update server settings. Requires MANAGE_SERVER permission (currently PermAdministrator).

Authentication: Required

Request (all fields optional):

{
  "name": "Updated Name",
  "description": "New description",
  "icon_url": "https://cdn.example.com/icons/abc.png",
  "banner_url": null,
  "system_channel_id": "018e...",
  "rules_channel_id": null,
  "afk_channel_id": "018e...",
  "afk_timeout_seconds": 300,
  "verification_level": 0,
  "explicit_filter": 0
}

Response 200: Server object


DELETE /api/servers/{serverID}

Delete a server. Only the server owner may do this.

Authentication: Required

Response 204


Server Object

{
  "id": "018e1234-abcd-7000-8000-112233445566",
  "name": "My Server",
  "description": "A place to hang out",
  "icon_url": null,
  "banner_url": null,
  "owner_id": "018e1111-...",
  "verification_level": 0,
  "explicit_filter": 0,
  "system_channel_id": "018e2222-...",
  "rules_channel_id": null,
  "afk_channel_id": null,
  "afk_timeout_seconds": 300,
  "member_count": 42,
  "created_at": "2024-01-15T10:00:00Z",
  "updated_at": "2024-01-15T10:00:00Z"
}

8. Channel Endpoints


GET /api/servers/{serverID}/channels

List all channels in a server, ordered by position. The caller must be a member.

Authentication: Required

Response 200: [ Channel, ... ]


POST /api/servers/{serverID}/channels

Create a channel. Requires MANAGE_CHANNELS permission.

Authentication: Required

Request:

{
  "name": "general",
  "type": "text",
  "topic": "General discussion",
  "position": 0,
  "nsfw": false,
  "parent_id": null,
  "slowmode_seconds": 0,
  "bitrate": 64000,
  "user_limit": 0
}

Field Type Required Default
name string Yes
type ChannelType Yes
topic string | null No null
position integer No 0
nsfw boolean No false
parent_id string | null No null
slowmode_seconds integer No 0
bitrate integer No 64000 (voice only)
user_limit integer No 0 = unlimited (voice only)
encryption_mode "secure" | "persistent" No "secure"

Response 201: Channel object

Encryption mode — secure by default

Omit encryption_mode and the channel is end-to-end encrypted: only members hold the key and the server cannot read it. "persistent" is an explicit opt-out where the server holds the channel KEK in Vault and can read messages — which on the main ZippChat server means the operator can.

Any value other than exactly "persistent" is coerced to "secure", so an older client that does not send the field, or one that sends a typo, gets the safe mode rather than the readable one.

Choosing "persistent" buys full history for members who join later and lets POST /api/messages/{id}/decrypt work for moderation. A "secure" channel refuses that endpoint with ErrNotDecryptable, because there is no server-held key to decrypt with — by design.


PUT /api/servers/{serverID}/channels/order

Places channels in the sidebar in one atomic write. Manage channels (server level).

[ { "id": "...", "position": 0, "parent_id": null },
  { "id": "...", "position": 1, "parent_id": "<category id>" } ]

Any subset of the server's channels. 400 if an id is unknown or repeated, if a parent_id is not a category of this server, or if a category is given a parent. One CHANNEL_UPDATE per channel that actually moved. 204.

GET /api/channels/{channelID}

Get a channel by ID.

Authentication: Required

Response 200: Channel object


PATCH /api/channels/{channelID}

Update a channel. Requires MANAGE_CHANNELS permission.

Authentication: Required

Request (all fields optional):

{
  "name": "announcements",
  "topic": "Official updates",
  "parent_id": "018e...",
  "position": 2,
  "nsfw": false,
  "slowmode_seconds": 5,
  "bitrate": 96000,
  "user_limit": 10
}

Response 200: Channel object


DELETE /api/channels/{channelID}

Delete a channel. Requires MANAGE_CHANNELS permission.

Authentication: Required

Response 204


Channel Types

Value Description
"text" Text chat channel
"voice" Voice/video channel
"announcement" Announcement channel (read-only for members)
"stage" Stage channel (audience + presenters)
"category" Channel category (no messages)
"dm" Direct message between two users
"group_dm" Group direct message

Channel Object

{
  "id": "018e3333-...",
  "server_id": "018e1234-...",
  "parent_id": null,
  "type": "text",
  "name": "general",
  "topic": "General discussion",
  "position": 0,
  "nsfw": false,
  "slowmode_seconds": 0,
  "bitrate": 64000,
  "user_limit": 0,
  "last_message_id": "018e9999-...",
  "created_at": "2024-01-15T10:00:00Z",
  "updated_at": "2024-01-15T10:00:00Z"
}

GET /api/channels/{channelID}/escrow-key

Fetch the key for a persistent (server-escrowed) channel, sealed to the calling device.

Authentication: Required — channel membership

Response 200:

{
  "channel_id": "…",
  "encryption_mode": "persistent",
  "key_version": "v1",
  "device_id": "…",
  "sealed_key": "<base64>"
}

sealed_key is crypto_box_seal(channelKey, deviceSignedPrekey) — an anonymous sealed box only that device's private key can open. The signed prekey is used rather than a converted identity key because it is already X25519 and the keyserver verified its Ed25519 signature on upload.

Errors: 400 if the channel is not escrow-mode (a secure channel has no server-held key), 403 if not a member, 404 if the device has published no key bundle.


GET /api/channels/{channelID}/epoch

Fetch the current epoch key for a secure (rolling-key) channel, sealed to the calling device.

Authentication: Required — channel membership and a grant for the epoch

Response 200:

{ "epoch_id": "…", "epoch_num": 3, "encrypted_key": "<base64>" }

Errors:

Code Condition
404 Nobody has keyed this channel yet. The caller can fix this alone by creating the first epoch
403 The epoch exists and this device holds no grant for it

Both are normal results, not error conditions, and they are deliberately distinct: 404 is self-serviceable, 403 needs someone who already holds the key. Clients must render affected messages as locked rather than hiding them.

A 403 is resolved either by someone who holds the key issuing a grant (see POST …/epoch/{epochID}/grants), or by re-keying the channel.


POST /api/channels/{channelID}/epoch

Rotate: create a new epoch and distribute grants. See ProjectInfo/encryption-modes.md §4 for member_hash validation and the optimistic-concurrency rules.

Rotation does not require the current key. A fresh key is generated and sealed to every member device, so a device holding no grant can still rotate a channel it cannot read. That is what makes recovery possible when every grant belonged to devices that are gone.

Rotation does not abandon history. A message names the epoch it was sealed under, and that epoch's grants outlive the rotation, so GET …/epoch/{epochID} still serves it. Clients must decrypt each message with the epoch it names, not with whatever is current — a client that uses the current key for everything blanks the channel on every rotation.

What rotation does is bound future reading: a device granted only epoch 5 cannot read epochs 1–4, because nobody ever sealed those to it. That property lives in the grant table, not in withholding old epochs.

ck_version must be the channel's current value; a stale one is a 409. Re-fetch the channel and retry.


GET /api/channels/{channelID}/epoch/{epochID}

A specific epoch key, sealed to the calling device. This is how history survives rotation.

Authentication: Required — channel membership and a grant for that epoch

Response 200: same shape as the current-epoch route.

Errors:

Code Condition
404 That epoch does not belong to this channel
403 This device holds no grant for it

A 403 on an old epoch is the normal answer for anyone who joined after it, and is the forward-secrecy property rotation exists for. It grants no new access: the device is served exactly the blob a grant already exists for.

Cache by epoch id. One loaded page then costs one fetch per distinct epoch it contains rather than one per message.


GET /api/channels/{channelID}/epoch/{epochID}/missing-grants

The member devices with no grant for this epoch, and the public key to seal one to each. Lets a device that already holds the key grant every device that does not, in one request.

Response 200 OK

[
  {
    "user_id": "018e1111-...",
    "device_id": "web-9f3a...",
    "signed_prekey": "base64 32-byte X25519 public key"
  }
]

Requires membership but not a grant of its own: the caller is by definition someone who already has the key, and everything returned is public.

Why this exists

Epoch creation refuses to leave out any device it can see, so a device present at rotation time is always covered. The gap is only ever a device whose key bundle is published after the epoch — a second browser, a new phone, or you after clearing site data.

The client used to close that gap by fetching every member's key bundle from the keyserver and diffing locally: one request per member. That was expensive enough that it ran at most once per epoch per session, and that guard was a bug — by the time a second device existed, every device that could have granted it had already spent its single pass. Opening the channel on the account that held the key did nothing, which is exactly how it was reported.

signed_prekey was verified by the keyserver when the device published it, so a client sealing to it does not need to check the signature again.


GET /api/channels/{channelID}/search-index

Fetch the channel's shared, encrypted search index.

Authentication: Required — channel membership

Response 200: [ { "chunk_key": "2026-09", "version": 2, "ciphertext": "<base64>", "encrypted_dek": "<base64>", "kek_version": "v1" }, … ]

The server stores these blobs and cannot read them. There is deliberately no token → message_ids table, hashed or otherwise: deterministic tokens leak to frequency analysis, access patterns build a relationship graph, and file injection recovers query terms from very few injections.

chunk_key is the epoch id for secure channels — so a member who joined at epoch 5 cannot decrypt a searchable summary of history they cannot read — and a month bucket (2026-09) for persistent channels, where chunking is only about update size.


PUT /api/channels/{channelID}/search-index/{chunkKey}

Publish an updated chunk.

Authentication: Required — channel membership

Request:

{
  "expected_version": 1,
  "ciphertext": "<base64>",
  "encrypted_dek": "<base64>",
  "kek_version": "v1"
}

Response 200: { "version": 2 }

Errors: 409 when expected_version does not match the stored version — another member indexed concurrently. Re-fetch, re-apply, retry. Use expected_version: 0 to create.

The index is untrusted input. Any member can write it, so a hit is a hint: verify it against the real decrypted message before showing it. Poisoning then costs wasted work rather than producing wrong results. Omission can be detected but never prevented, so the shared index is for speed, never for completeness. It is not a moderation mechanism — ProjectInfo/encryption-modes.md §6.4 states that boundary explicitly.


Permission overwrites

Per-channel exceptions to the server roles. All three routes require Manage Channels on the channel.

A permission is in one of three states for a target: allowed (its bit is in allow), denied (in deny), or inherited (in neither, the default). Both fields exist because "not mentioned" and "explicitly denied" are different outcomes.

Resolution order, applied over the caller's role permissions: role deny → role allow → member deny → member allow. Administrator short-circuits everything, so an overwrite cannot restrain an admin or a server owner.

Two properties worth stating, because getting either wrong is a security bug:

  • A role overwrite applies only to members of that role. The @everyone role — whose id is the server id by convention — is the exception, since every member holds it. This was previously not enforced: overwrites were applied to everyone regardless, making an allow a privilege escalation.
  • ViewChannel deny removes the channel from GET /servers/{id}/channels, not merely from access. A private channel whose name and existence everyone can see is not private.

There is no inheritance from a category. Resolution reads only the channel's own overwrites, so an overwrite on a category changes nothing. Do not offer one in a UI.


GET /api/channels/{channelID}/overwrites

Response 200:

[ { "id": "018e...", "channel_id": "018e...", "target_id": "018e...", "target_type": "role", "allow": 1, "deny": 2 } ]


PUT /api/channels/{channelID}/overwrites/{targetID}

Request:

{ "target_type": "role", "allow": 1, "deny": 2 }

Field Notes
target_type "role" or "member" — anything else is a 400
allow / deny Whole bitfields, not deltas. The server stores what it is given, so send the complete state

An overwrite with allow and deny both 0 changes nothing; prefer DELETE over storing one.


DELETE /api/channels/{channelID}/overwrites/{targetID}

Returns the target to inheriting the server-level roles.

Response 204


Notification settings

Per-user mutes for servers and channels.

Mute only, deliberately. Discord's other levels ("only @mentions") need mention tracking, and nothing in this system detects a mention — so that option would be a control with nothing behind it. It arrives with the feature that gives it meaning.

A mute also stops push wakes for that channel or server: the worker checks it before queueing anything for a device (see §17).

Muting is a personal preference with no access implications: it is gated on server membership, not on ViewChannel, so a mute does not silently fail for a channel someone can see today and not tomorrow.


GET /api/notifications

Every scope the caller has muted, in one call — the sidebar needs all of them before it can draw anything. There is no route for anyone else's.

Response 200:

[
  { "channel_id": "018e...", "muted": true },
  { "server_id": "018e...", "muted": true, "mute_expires_at": "2026-09-04T04:21:04Z" }
]

Exactly one of server_id / channel_id is present. mute_expires_at is absent for a mute that lasts until turned off.

A lapsed mute is never returned. The row survives — rewriting it on read would turn every fetch into a write — but the read filters it out, so no client has to compare clocks to know what is muted.


GET /api/notifications/settings

Every scope with any setting: a live mute, a notification level, or a suppress flag. Use this rather than GET /api/notifications to read levels. That route still returns mutes only, because clients built before levels read every row it returns as a mute.

Response 200:

[
  { "server_id": "018e...", "muted": false, "level": "mentions",
    "suppress_everyone": true },
  { "channel_id": "018e...", "muted": true, "level": "all" }
]

level is all, mentions or nothing. When it is absent the scope inherits: a channel inherits its server's level, and a server falls back to the client default (mentions). DMs always notify unless muted. The suppress flags only mean something on a server row, and are omitted when false. A lapsed mute comes back as "muted": false, not hidden, because the row may still carry a level.

What the server does with these: it stores them, and it skips the push wake for a channel whose effective level is nothing. Every other decision (is this a mention? does it notify? does it badge?) is made by the client after it decrypts, because the mention markers (<@user>, <@&role>, @everyone, @here) live inside the ciphertext.


PUT /api/servers/{serverID}/notifications

PUT /api/channels/{channelID}/notifications

Request (every field optional; absent fields are left as they are):

{ "muted": true, "duration_seconds": 3600,
  "level": "mentions", "suppress_everyone": true, "suppress_roles": false }

Field Notes
muted false clears the mute and ignores the duration. Absent leaves the mute alone
duration_seconds Optional. Absent means "until turned back on". Max 30 days
level all, mentions, nothing, or "" to inherit
suppress_everyone Server only: @everyone and @here stop counting as mentions
suppress_roles Server only: role mentions stop counting as mentions. A direct <@you> always counts

A duration, not a deadline. The expiry is computed against the server's clock; a client sending a timestamp from a device an hour fast would silently mute for an hour less than it asked for.

Response 204

Errors: | Code | Condition | |------|-----------| | 400 | duration_seconds over 30 days, or not positive; level not one of the four values | | 404 | Not a member of that server — 404 rather than 403, so whether a server exists is not something a non-member can learn by trying to mute it |


POST /api/messages/{messageID}/decrypt

The only route that reveals message content to an operator. Deliberate server-side decryption of one message, for moderation. See ProjectInfo/encryption-modes.md §3.1.

Authentication: Required — Administrator

Deliberately narrower than moderation generally: deleting a message needs Manage Messages, but reading one that was not addressed to you is a different act, and the smaller the set of people who can do it, the more the audit entry means.

Request:

{ "reason": "reported for harassment, ticket #412" }

reason is required. It is the audit entry's whole value — "someone decrypted this" with no why is a record of an event, not of a decision.

Response 200:

{ "message_id": "018e...", "content": "…", "recorded": true, "reason": "…" }

The audit row is written BEFORE the plaintext is returned, and a failure to write it fails the whole call. Everywhere else an audit write is best-effort — a failed log must not undo a kick that already happened. This one is the opposite: a decryption nobody can see afterwards is exactly what the audit exists to prevent, so refusing to hand over the plaintext is the only way the record and the act cannot come apart.

Errors: | Code | Condition | |------|-----------| | 400 | No reason, or the channel is secure — the server holds no key for those by design and cannot decrypt them for anyone | | 403 | Not an administrator | | 500 | The audit row could not be written, so the decryption did not happen |


GET /api/servers/{serverID}/audit-log

Who did what in a server, newest first, capped at 200.

Authentication: Required — View audit log

Gated on View audit log rather than Administrator on purpose: the point of an audit is that people other than the person being audited can read it.

Query: ?action=message.decrypt filters to one action. Worth using — the entry that matters most is otherwise buried among routine kicks and role edits.

Response 200:

[
  {
    "id": 3,
    "actor_id": "018e...", "actor_name": "alice",
    "target_id": "018e...", "target_type": "message",
    "action": "message.decrypt",
    "reason": "reported for harassment, ticket #412",
    "created_at": "2026-09-04T06:41:21Z"
  }
]

actor_name is null when that account has been deleted — the row outlives them, which is the point.


9. Role Endpoints


GET /api/servers/{serverID}/roles

List all roles in a server.

Authentication: Required (must be a member)

Response 200: [ Role, ... ]


POST /api/servers/{serverID}/roles

Create a role. Requires MANAGE_ROLES permission.

Authentication: Required

Request:

{
  "name": "Moderator",
  "color": 16711680,
  "hoist": true,
  "mentionable": false,
  "permissions": 8
}

Field Type Required Notes
name string Yes
color integer No RGB color as integer (e.g. 0xFF0000 = 16711680)
hoist boolean No Display separately in member list
mentionable boolean No Allow @role mentions
permissions integer (int64) No Bitfield; see Permission Bits

Response 201: Role object


PATCH /api/servers/{serverID}/roles/{roleID}

Update a role. Cannot modify the @everyone role (its ID equals the server ID).

Authentication: Required — MANAGE_ROLES

Request (all fields optional):

{
  "name": "Senior Mod",
  "color": 255,
  "hoist": false,
  "mentionable": true,
  "permissions": 10
}

Response 200: Role object


DELETE /api/servers/{serverID}/roles/{roleID}

Delete a role. Cannot delete @everyone.

Authentication: Required — MANAGE_ROLES

Response 204


Role Object

{
  "id": "018e4444-...",
  "server_id": "018e1234-...",
  "name": "Moderator",
  "color": 16711680,
  "hoist": true,
  "mentionable": false,
  "position": 1,
  "permissions": 8,
  "managed": false,
  "created_at": "2024-01-15T10:00:00Z",
  "updated_at": "2024-01-15T10:00:00Z"
}

10. Member Endpoints


GET /api/servers/{serverID}/members

List members of a server.

Authentication: Required (must be a member)

Query Parameters: | Param | Type | Default | Max | |-------|------|---------|-----| | limit | integer | 50 | 100 | | offset | integer | 0 | — |

Response 200: [ Member, ... ]


PATCH /api/servers/{serverID}/members/@me

Set your per-server identity: a nickname and a server avatar that apply in this server only, leaving your account-wide profile untouched.

Authentication: Required (must be a member)

Body — both fields optional. An omitted field is left as it was; an empty string clears it back to the account-wide name or avatar.

{
  "nickname": "Allie",
  "server_avatar": "https://media.{domain}/api/media/files/018e.../download"
}
Field Notes
nickname Max 32 characters, trimmed. Requires CHANGE_NICKNAME.
server_avatar A URL from POST /api/media/upload. Membership only — there is no permission bit for it.

Response 200: the updated Member object.

Errors: 400 nickname too long · 403 not a member, or CHANGE_NICKNAME denied.

Uploading a server avatar counts against the owner's storage quota like any other profile image. Replacing one does not remove the old object — this endpoint only writes a URL, and the media service owns the file. A client that replaces an avatar should delete the one it replaced with DELETE /api/media/files/{fileID} (the file ID is the last path segment of the old URL), or the account slowly fills with unreferenced images it is still being charged for.

Emits MEMBER_UPDATE on that server's subject only — a nickname belongs to one server, so unlike USER_UPDATE it is never fanned out to the other servers the user shares.


DELETE /api/servers/{serverID}/members/{userID}

Kick a member from the server. Requires KICK_MEMBERS permission. Cannot kick the server owner.

Authentication: Required

Response 204


PUT /api/servers/{serverID}/members/{userID}/roles/{roleID}

Assign a role to a member. Requires MANAGE_ROLES permission.

Authentication: Required

Response 204


DELETE /api/servers/{serverID}/members/{userID}/roles/{roleID}

Remove a role from a member.

Authentication: Required — MANAGE_ROLES

Response 204


Member Object

{
  "server_id": "018e1234-...",
  "user_id": "018e1111-...",
  "username": "alice",
  "display_name": "Alice Liddell",
  "avatar_url": "https://media.{domain}/api/media/files/018e.../download",
  "nickname": "Allie",
  "server_avatar": null,
  "deaf": false,
  "mute": false,
  "timeout_until": null,
  "joined_at": "2024-01-15T10:00:00Z",
  "roles": ["018e4444-...", "018e5555-..."]
}

Resolve a member's shown name as nickname → display_name → username. nickname is per-server, display_name is account-wide, and username always exists. Messages already carry the second and third of those resolved as author_name.

Resolve their avatar the same way: server_avatar → avatar_url → initials.

Both per-server fields are set with PATCH /api/servers/{serverID}/members/@me. A cleared field is null, never "" — an empty string is not null, so it would win the resolution above and render as a blank name.


11. Invite Endpoints


POST /api/servers/{serverID}/invites

Create an invite link for a server.

Authentication: Required — CREATE_INSTANT_INVITE permission

Request:

{
  "max_uses": 10,
  "expires_in": 86400
}

Field Type Required Notes
max_uses integer | null No null = unlimited
expires_in integer | null No Seconds from now; null = never expires

Response 201: Invite object


GET /api/invites/{code}

Preview an invite without joining.

Authentication: None — someone following an invite link is usually not signed in yet.

Response 200: Invite object

Errors: | Code | Condition | |------|-----------| | 404 | Invite not found | | 410 | Invite expired or use limit reached |


POST /api/invites/{code}

Join a server using an invite code.

Authentication: Required

Response 200: Invite object

Errors: | Code | Condition | |------|-----------| | 403 | You are banned from this server | | 409 | You are already a member | | 410 | Invite expired or exhausted |


Invite Object

{
  "code": "xK9mNp",
  "server_id": "018e1234-...",
  "server_name": "My Server",
  "server_icon": null,
  "member_count": 42,
  "channel_id": "018e3333-...",
  "channel_name": "general",
  "creator_id": "018e1111-...",
  "uses": 3,
  "max_uses": 10,
  "expires_at": "2024-01-16T10:00:00Z",
  "created_at": "2024-01-15T10:00:00Z"
}

GET /api/servers/{serverID}/invites

Every invite for the server, newest first, including expired and used-up ones. Manage server. Each entry is the invite object plus creator_username.

DELETE /api/servers/{serverID}/invites/{code}

Revokes an invite at once. Manage server, or the member who created it. 204 / 404.


12. Message Endpoints

Base: https://api.{domain}

Encryption note: The client encrypts. The server does not decrypt on read.

This changed — earlier versions of this document said the server encrypted transparently and clients sent plaintext. That is no longer true for any channel type.

A client fetches the channel key (GET /api/channels/{id}/escrow-key for persistent channels, GET /api/channels/{id}/epoch for secure ones), encrypts locally, and posts a sealed envelope. GET .../messages returns that envelope verbatim; the client decrypts it. Plaintext never enters the API process on a normal read, so it cannot reach response bodies, proxy buffers, NATS payloads or logs.

The two modes differ in who holds the key, not in who does the crypto: for persistent the server holds the KEK in Vault and will hand it to any member's device (and can decrypt under audit — this is escrow, not E2EE); for secure the server holds no key at all. See ProjectInfo/encryption-modes.md.

The plaintext content field still works for bots, scripts and the dev seeder. It is deprecated, every use is logged, and it is ignored when ciphertext is present.


POST /api/channels/{channelID}/messages

Send a message.

Authentication: Required — SEND_MESSAGES permission (server channels) or channel membership (DMs)

Rate limit: 5 requests / 5 seconds

Request:

{
  "ciphertext": "aENtGj3j7pXLREqeWtj7xsw1GETYGwX1...",
  "encrypted_dek": "lmqhAyW+S7DZXRK3sjCRpvWF3LDaJCL4...",
  "kek_version": "v1",
  "epoch_id": null,
  "reply_to_id": null,
  "attachment_ids": []
}

Field Type Required Notes
ciphertext string (base64) Yes AES-256-GCM under a per-message DEK
encrypted_dek string (base64) Yes That DEK wrapped by the channel key, with kek_version as additional data. Empty for secure channels — the client owns the key outright.
kek_version string Yes Key version the DEK was wrapped under. The channel's mode decides what is stored: a secure channel always stores "secure", and a persistent channel refuses "secure" or an empty value with 400 — otherwise a client could mark its messages as beyond moderation decryption. Same rule on edit.
epoch_id string | null For secure Which epoch key opens this message
reply_to_id string | null No ID of the message being replied to
attachment_ids string[] No Previously uploaded sealed attachments
content string Deprecated Plaintext, encrypted server-side. Ignored when ciphertext is present; every use is logged.

Response 201: Message object

Errors: | Code | Condition | |------|-----------| | 400 | Content is empty | | 403 | Insufficient permissions / not a member | | 404 | Channel not found |


GET /api/channels/{channelID}/messages

Fetch message history.

Authentication: Required — VIEW_CHANNEL permission

Query Parameters: | Param | Type | Default | Notes | |-------|------|---------|-------| | limit | integer | 50 | Max 100 | | before | string | — | Message ID; returns messages before this one | | ids | string | — | Comma-separated message IDs; returns exactly that set instead of a page. Max 100. Scoped to this channel — ids from another channel return nothing. |

Response 200: [ Message, ... ] — ordered newest first, sealed (see the encryption note above).

The ids form exists for search: a shared index entry can point at a message far outside the page the client holds, and the hit must be verified against the real message before it is displayed.


PATCH /api/channels/{channelID}/messages/{messageID}

Edit a message. Only the original author may edit.

Authentication: Required

Request:

{
  "content": "Edited content"
}

Response 200: Message object


DELETE /api/channels/{channelID}/messages/{messageID}

Delete a message. Author can delete their own; MANAGE_MESSAGES permission allows deleting others'.

Authentication: Required

Response 204


POST /api/channels/{channelID}/messages/{messageID}/reactions

Add your reaction to a message.

Authentication: Required — VIEW_CHANNEL + ADD_REACTIONS

Request

{ "emoji": "\ud83d\udc4d" }

The emoji is arbitrary text of at most 64 bytes. It travels in the body rather than a path segment because it is user-supplied unicode.

Re-adding a reaction you already hold is a no-op, not an error.

Response 204


DELETE /api/channels/{channelID}/messages/{messageID}/reactions?emoji=X

Remove your reaction. The emoji is a query parameter, since a DELETE body is not reliably forwarded.

Removing a reaction that is not there is a no-op. No permission is checked — losing ADD_REACTIONS must not strand a reaction you can no longer take back.

Response 204


PUT /api/channels/{channelID}/messages/{messageID}/pin

DELETE /api/channels/{channelID}/messages/{messageID}/pin

Pin or unpin a message.

Authentication: Required — MANAGE_MESSAGES. A pin is a channel-wide statement, not a personal bookmark.

Response 204


GET /api/channels/{channelID}/pins

A channel's pinned messages, most recently pinned first.

Authentication: Required — VIEW_CHANNEL + READ_MESSAGE_HISTORY

Response 200 — an array of Message objects, still encrypted. The client decrypts them with the same channel key it uses for the message list.


POST /api/auth/login/2fa

Second step of a sign-in for an account with two-factor authentication.

Authentication: none — the two_factor_token from /api/auth/login is what proves the password step succeeded. It is signed, scoped to this purpose so it can never be used as an access token, and expires after 5 minutes.

Request

{ "two_factor_token": "...", "code": "123456" }

code is either a 6-digit TOTP code or one of the recovery codes. Recovery codes are single-use.

Response 200 — the normal auth response. 401 for a bad code, a reused code, or an expired challenge.

When an account has 2FA, POST /api/auth/login returns {"two_factor_required": true, "two_factor_token": "...", "user": {...}} with no tokens. A client that ignores two_factor_required therefore fails closed rather than appearing signed in.


GET /api/auth/2fa

Whether 2FA is on for the caller, and how many recovery codes remain.

Response 200 — {"enabled": true, "recovery_codes_left": 8}


POST /api/auth/2fa/setup

Generates an unconfirmed TOTP secret. 2FA is not on until it is confirmed — a secret someone generated and abandoned must not be able to lock them out.

Response 200 — {"secret": "...", "uri": "otpauth://totp/..."}. 409 when 2FA is already enabled; disable it first, which costs a password.


POST /api/auth/2fa/enable

Verifies a code against the pending secret and turns 2FA on.

Request { "code": "123456" }

Response 200 — {"recovery_codes": ["...", ...]}, ten single-use codes returned once. Only their bcrypt hashes are stored.


POST /api/auth/2fa/disable

POST /api/auth/2fa/recovery-codes

Turn 2FA off, or void the old recovery codes and issue a new set. Both take { "password": "..." } — an access token proves who is asking, not that they are still the person who set this up.


Message Object

{
  "id": "018e9999-...",
  "channel_id": "018e3333-...",
  "author_id": "018e1111-...",
  "author_name": "Alice Liddell",
  "author_avatar": "https://media.{domain}/api/media/files/018e.../download",
  "content": "Hello world",
  "type": 0,
  "reply_to_id": null,
  "pinned": false,
  "edited": false,
  "edited_at": null,
  "created_at": "2024-01-15T10:30:00Z",
  "reactions": [
    { "emoji": "\ud83d\udc4d", "count": 3, "me": true }
  ]
}

reactions is aggregated per emoji rather than listing every reactor: the client renders a count and whether you are in it, and me is what decides whether a click adds or removes. It is present on message list reads and empty on a freshly sent message.

For DM channels, content is the base64-encoded E2EE ciphertext. Clients must decrypt it locally using the Double Ratchet session state.


13. User Endpoints

Base: https://api.{domain}


GET /api/users/@me

Get the authenticated user's own profile.

Authentication: Required

Response 200: User object


PATCH /api/users/@me

Update the authenticated user's profile.

Authentication: Required

Request (all fields optional):

{
  "username": "alice2",
  "display_name": "Alice Smith",
  "avatar_url": "https://media.{domain}/api/media/files/018e.../download",
  "banner_url": null,
  "bio": "Software engineer",
  "status_text": "Working on something cool",
  "status": 1
}

Field Type Notes
username string Must be unique
display_name string | null
avatar_url string | null Should be a URL from the media service
banner_url string | null
bio string | null
status_text string | null Custom status message
status integer | null 0 = offline, 1 = online, 2 = idle, 3 = do not disturb

Response 200: User object

Errors: | Code | Condition | |------|-----------| | 409 | Username already taken |


POST /api/users/@me/onboarding

Mark the first-run wizard finished. The client calls it when the user completes or skips the wizard; until then onboarded_at on GET /api/users/@me is null and the client shows the wizard after sign-in. Stored server-side so a second device does not show it again. Idempotent: later calls keep the first timestamp. Accounts that existed before migration 000029 were backfilled with their created_at.

Authentication: Required

Request: no body

Response 200: User object, with onboarded_at set


GET /api/users/{userID}

Get a user's public profile.

Authentication: Required

Response 200: User object


GET /api/servers/{serverID}/members/search

Search for members by username prefix.

Authentication: Required (must be a member)

Query Parameters: | Param | Type | Required | |-------|------|----------| | q | string | Yes — minimum 1 character |

Response 200: [ Member, ... ] — up to 25 results


User Object

{
  "id": "018e1111-...",
  "username": "alice",
  "display_name": "Alice Smith",
  "avatar_url": null,
  "banner_url": null,
  "bio": "Software engineer",
  "status_text": "Working",
  "status": 1,
  "created_at": "2024-01-15T09:00:00Z"
}

GET /api/users/@me and PATCH /api/users/@me also return email, email_verified (see Email verification), updated_at and onboarded_at (timestamp or null; see POST /api/users/@me/onboarding). Other users' profiles never carry them.


GET /api/users/@me/privacy

Your privacy policy. There is deliberately no endpoint for anyone else's — "does this person accept DMs" is answered only by trying, because an endpoint that reports it lets anyone enumerate who has locked down.

Response 200 OK

{
  "dm_policy": "everyone",
  "friend_requests": true,
  "share_typing": true,
  "share_presence": true
}

A user who has never saved settings has no row, and the server answers with these defaults rather than 404.

PATCH /api/users/@me/privacy

Partial update — send only what changes. PATCH rather than PUT so a client that predates a field cannot switch it off by omitting it.

Request

{ "dm_policy": "friends" }
Field Type Meaning
dm_policy everyone | friends | nobody Who may open a DM with you
friend_requests bool Whether anyone may send you a friend request
share_typing bool Whether your typing is broadcast
share_presence bool Whether your online status is published

Response 200 OK — the full settings object after the write.

400 if dm_policy is outside the enum.

How each one is enforced

These are enforced on the request, not by hiding a control. A modified client gains nothing by ignoring them.

  • dm_policy gates creating a DM channel, in OpenDM. It does not close conversations that already exist — "stop new people reaching me" is a different act from "silence the threads I already have", and blocking covers the case where one specific person must be cut off. Refusal is ErrDMNotAllowed. Fails closed: an error in the check refuses contact.
  • friend_requests gates SendRequest, and refusal reuses the same generic error as a block (cannot send request to this user). A distinct "not accepting requests" message would let a blocked person tell the two apart and confirm they were blocked by that specific person — the one thing blocking exists to prevent.
  • share_typing is checked where the TYPING_START event is created, in the gateway, so the event is never published rather than published-and-ignored.
  • share_presence is enforced by storing offline instead of the real status, in both write paths (SetPresence and PATCH /api/users/@me). Masking only the broadcast would leak the real value through the member list, the user endpoint and every future reader that joins users. The cost is that your own client sees offline too, which is what "invisible" means everywhere else.

The two broadcast switches fail open — a lookup error publishes — because they protect a preference rather than access to anything, and silently dropping presence on a timed-out query would look like a broken app to someone who set nothing.


14. Voice Endpoints

Base: https://voice.{domain}

E2EE: All voice and video streams are end-to-end encrypted using the room key returned with the token. The SFU (LiveKit) forwards encrypted RTP and never decrypts media.


POST /api/voice/channels/{channelID}/token

Mint a LiveKit JWT and retrieve the E2EE room key for a voice channel.

Authentication: Required — CONNECT permission

Response 200:

{
  "token": "eyJhbGci...",
  "livekit_url": "wss://voice.{domain}",
  "room_key": "base64-encoded-32-byte-key"
}

Field Type Notes
token string LiveKit JWT; pass to the LiveKit SDK
livekit_url string WebSocket URL for the LiveKit connection
room_key string Base64-encoded 32-byte AES-256 key for E2EE media encryption. Absent for a DM.
key_source string server (use room_key) or client (a DM call: the key is in the E2EE ring message — see DM calls)
ice_servers array TURN relays, when configured

For a DM channel the token is only minted while the DM has a live call (409 otherwise), and it carries no room_key: the media key is the caller's and never reaches the server. A client must refuse to join a client room without the key from the ring message.

The token grants: - Microphone + camera always enabled - Screenshare enabled only if the caller has STREAM permission

Errors: | Code | Condition | |------|-----------| | 403 | Not a member, or missing VIEW_CHANNEL / CONNECT |

The token may publish the camera always, the microphone only with SPEAK, and screen share only with STREAM. SPEAK used to be ignored. | 404 | Channel not found |


DM calls

Design and threat model: ProjectInfo/dm-calls.md. The server referees a call — ringing, answering, timing out — and never holds its media key. The caller first sends an E2EE DM message whose payload is {"zipp":1,"t":"call","key":"<base64>"}, then starts the call with that message's id.

Call object (also the CALL_UPDATE payload):

{
  "id": "…", "channel_id": "…", "caller_id": "…", "message_id": "…",
  "state": "ringing",            // ringing | active | ended
  "end_reason": null,            // completed | missed | declined | cancelled, when ended
  "answered_by": null,
  "created_at": "2026-11-07T18:00:00Z", "answered_at": null, "ended_at": null
}

Endpoint
POST /api/voice/dm/{channelID}/calls {"message_id":"…"} Ring the other members. 201 call. 400 unless message_id is a DM message in this channel you sent in the last 2 minutes (this is what makes blocking apply to calls). 409 {"call":{…}} when a call is already live — join that one. 404 for a server channel.
GET /api/voice/dm/{channelID}/calls?limit=50 Recent calls, newest first. Members only.
POST /api/voice/dm/calls/{callID}/accept Callee answers: ringing → active. Idempotent on an active call. 403 for the caller.
POST /api/voice/dm/calls/{callID}/decline 1:1: ringing → ended/declined. Group DM: only the decliner's devices stop ringing (CALL_UPDATE with "dismissed": true, to them alone).
POST /api/voice/dm/calls/{callID}/cancel Caller withdraws a ringing call: ended/cancelled.

A call rings for 45 s, then ends missed. An answered call ends completed when its LiveKit room empties (webhook, with a 60 s sweeper as a backstop); hanging up is just leaving the room. Every transition publishes CALL_UPDATE on user.<id> for every DM member.


GET /api/voice/channels/{channelID}/states

Get all current participants in a voice channel.

Authentication: Required

Requires VIEW_CHANNEL on the channel — server membership included — or being a participant of a DM call. 403 otherwise. This used to need only a session, so anyone could read any call's roster by channel id. It needs VIEW_CHANNEL and not CONNECT because the same VOICE_STATE_UPDATE events reach every gateway subscriber of the channel, and the gateway admits those on VIEW_CHANNEL.

Response 200:

[
  {
    "user_id": "018e1111-...",
    "session_id": "lk-session-id",
    "joined_at": "2024-01-15T10:30:00Z",
    "self_mute": false,
    "self_deaf": false,
    "self_video": false,
    "self_stream": false,
    "server_mute": false,
    "server_deaf": false,
    "suppress": false
  }
]


PATCH /api/voice/channels/{channelID}/state

Set your own mute and deafen in a voice channel you are connected to, so other members see it beside your name.

Authentication: Required. The user is always the caller; there is no way to set someone else's state here.

Request:

{ "self_mute": true, "self_deaf": false }

Response 204. Publishes VOICE_STATE_UPDATE with action: "update".

Errors: 404 if you have no presence row in this channel yet. The row is created by LiveKit's participant_joined webhook, which can land after the client has connected, so a client should send its state again once it sees itself in GET .../states.

Both flags are the client's report. LiveKit sends no webhook for a muted track and has no concept of deafen, so the server cannot observe either.


POST /api/voice/webhook

LiveKit server webhook. Not a client endpoint — called by the LiveKit SFU only.

This endpoint is unauthenticated with JWTs; LiveKit signs the body with the API secret. Client applications must never call this endpoint directly.


15. Media Endpoints

Base: https://media.{domain}

All files are stored encrypted (AES-256-GCM) in MinIO. The media service decrypts on download. Files are scanned for CSAM by exact SHA-256 hash before upload is accepted.


POST /api/media/upload

Upload a file. Uses multipart/form-data with a field named file.

Authentication: Required

Content-Type: multipart/form-data

Limits: | Type | Max Size | |------|----------| | Images (JPEG, PNG, GIF, WebP) | 10 MB | | All other allowed types | 50 MB |

A per-account storage quota applies on top of the per-file limit — see Storage quota. An upload that would exceed it is rejected with 507, before anything is stored.

Allowed MIME types (detected from file magic bytes, not client Content-Type):

Category Types
Images image/jpeg, image/png, image/gif, image/webp
Documents application/pdf
Audio audio/mpeg, audio/wav, audio/ogg
Video video/mp4, video/webm

Response 201:

{
  "id": "018eaaaa-...",
  "url": "https://media.{domain}/api/media/files/018eaaaa-.../download",
  "thumbnail_url": "https://media.{domain}/api/media/files/018eaaaa-.../download?thumb=1",
  "content_type": "image/jpeg",
  "original_name": "photo.jpg",
  "size_bytes": 204800,
  "width": 1920,
  "height": 1080,
  "created_at": "2024-01-15T10:30:00Z"
}

thumbnail_url is only present for image types (256px wide JPEG). width and height are only present for image types.

Errors: | Code | Condition | |------|-----------| | 400 | Content policy violation (CSAM detected — intentionally generic) | | 413 | File exceeds size limit | | 415 | File type not allowed |


GET /api/media/files/{fileID}

Get file metadata without downloading.

Authentication: Required

Response 200: Same shape as upload response (without re-fetching binary)


GET /api/media/files/{fileID}/download

Download the decrypted file. The server decrypts server-side and streams plaintext bytes.

Authentication: Required

Response 200: Binary file bytes with appropriate Content-Type header

Content-Type: image/jpeg
Content-Disposition: inline; filename="photo.jpg"

DELETE /api/media/files/{fileID}

Delete a file. Only the owner may delete their own files.

Authentication: Required

Response 204

Errors: | Code | Condition | |------|-----------| | 403 | The file is the subject of a report | | 404 | No such file, or not the caller's |

This removes the bytes. The object and its thumbnail are deleted from object storage, and the space is returned to the owner's quota immediately. Irreversible — there is no bin.

The metadata row is a soft delete: messages and attachments reference it, so the message keeps its place and shows the file as gone rather than disappearing. Do not read the surviving row as meaning the bytes survive.

A reported file cannot be deleted by its owner. The evidence copy is taken at report time and lives in its own bucket, so a report survives either way, but letting the subject of a report delete the original invites exactly the race it looks like.


Storage quota

Object storage was the only unbounded per-account cost in the system: a 50 MB per-file ceiling says nothing about the thousandth upload. MEDIA_USER_QUOTA_BYTES caps what one account may hold at once (default 1 GiB; 0 disables the check).

Usage counts everything the account owns and has not deleted — channel attachments, profile pictures and banners alike. It is summed from media_files on demand rather than kept in a counter, so it cannot drift.

Thumbnails are not counted. They exist to make the app cheaper to use, and charging people for the optimisation would push clients to skip it. That exemption is only safe because a thumbnail is capped, is one per file, and cannot be uploaded without a parent object that is charged — see Thumbnails.

The check runs before the object is stored, so a rejected upload leaves nothing behind. Two concurrent uploads can both pass it; the overshoot is bounded by one upload's size, and serialising every upload behind a lock to defend a soft limit is not worth it.


Sealed files: who may upload and download

POST /api/media/sealed and GET /api/media/files/{fileID}/sealed (and /sealed/thumb) are scoped to the file's channel:

Action Requires
Upload channel_id naming a channel where the uploader has VIEW_CHANNEL + SEND_MESSAGES (a DM: being a participant). Missing or malformed channel_id → 400; no access → 403.
Download Being the uploader, or VIEW_CHANNEL in the file's channel (a DM: being a participant). Anything else → 404, the same as a file that does not exist.

These used to need only a session: any signed-in user with a file id got the ciphertext and the wrapped DEK, and file ids travel in every message that references them. A sealed file uploaded before channel_id was recorded is downloadable by its uploader only.


Thumbnails

A sealed upload may carry a thumbnail: a downscaled preview the client generated, encrypted under the same DEK as the file. It belongs to the file rather than being a file of its own — one wrapped key, and a client that can open the image opens its preview with the key it already has.

The server cannot generate one. It never sees the plaintext, so thumbnailing is the client's job (ProjectInfo/encryption-modes.md §5).

Rule Value Enforced how
Max bytes 64 KiB Measured on the ciphertext — real
Max declared dimension 512 px Client's word; bounds-checked, not verified

Be clear about what the second row is worth. The thumbnail is encrypted, so the service cannot decode it and cannot verify a single pixel — thumbnail_width and thumbnail_height are recorded, not trusted. The byte cap is the enforceable control, and it is deliberately tight: a 512px JPEG that will not fit in 64 KiB is not a thumbnail. Without it, "thumbnail" would be the name of a free storage tier.

Generate JPEG, not PNG. A 256px PNG of a photograph runs to ~120 KB — past the cap, and pointless for something drawn at thumbnail size. The reference client uses 256px JPEG at quality 0.75, which lands around 8–10 KB.

Oversized thumbnails are a 400, and the upload is refused as a whole rather than being silently stored without its preview.


GET /api/media/files/{fileID}/sealed/thumb

The preview, with the same headers as the full sealed download — including the same X-Encrypted-DEK.

Authentication: Required

Errors: | Code | Condition | |------|-----------| | 404 | The file has no thumbnail |

A 404 is ordinary: not every upload is an image, and anything from before thumbnails existed has none. Fall back to the full file.


GET /api/media/usage

The caller's own storage position. There is no route for anyone else's.

Authentication: Required

Response 200:

{ "used_bytes": 48219021, "quota_bytes": 1073741824, "file_count": 37 }

quota_bytes of 0 means this instance sets no limit.


GET /api/media/files

The caller's own stored files, largest first — this list exists to free space, and the file worth deleting is rarely the newest one. Capped at 500.

Authentication: Required

Response 200:

[
  {
    "id": "018e...",
    "content_type": "image/png",
    "original_name": "screenshot.png",
    "size_bytes": 2418291,
    "created_at": "2026-09-03T10:00:00Z",
    "channel_id": "018e...",
    "channel_name": "general",
    "reported": false
  }
]

channel_id/channel_name are absent for a profile picture or banner, which belong to no channel. reported marks a file its owner cannot delete.


POST /api/link-preview

Fetches a web page's preview tags (OpenGraph, Twitter card, <title>) and its preview image, for a client that cannot fetch the page itself — a browser, which CORS stops from reading other sites. Opt-in on the client: calling it shows this instance the URL being previewed. Native clients fetch previews directly and never call it. The client seals the result into the message as an attachment (application/vnd.zippchat.link-preview+json), so the instance does not learn whether the preview was sent, or to whom.

Served by the api service; LINK_PREVIEW_PROXY=false removes the route (404). The URL is never logged. Fetches refuse non-public addresses at connect time (loopback, RFC 1918, link-local, CGNAT, NAT64/6to4 and the other reserved ranges), on every redirect hop, and ignore any outbound proxy in the environment. At most 3 redirects, 8 s in total, 1 MiB of HTML, 3 MiB of image.

Authentication: Required. Rate limit: 20 per minute per user.

Request:

{ "url": "https://example.com/article" }

Response 200:

{
  "url": "https://example.com/article",
  "title": "An article",
  "description": "What it is about",
  "site_name": "Example",
  "image": "<base64>",
  "image_type": "image/jpeg"
}

Every field but url (the final URL after redirects) may be absent.

Errors: | Code | Condition | |------|-----------| | 400 | Missing URL, or not http/https, or with credentials in it | | 404 | The instance does not offer the proxy | | 422 | Not a web page, a non-public address, or nothing to preview | | 429 | Rate limit | | 502 | The page could not be fetched |


16. Key Endpoints (E2EE)

Base: https://keys.{domain}

These endpoints implement the X3DH key exchange protocol. Clients must call them before initiating DM sessions. See E2EE Client Implementation Guide for the full flow.


POST /api/keys/bundle

Upload or replace the authenticated user's key bundle.

Authentication: Required

Request:

{
  "identity_key": "<base64-encoded 32-byte Ed25519 public key>",
  "signed_prekey": "<base64-encoded 32-byte X25519 public key>",
  "signed_prekey_signature": "<base64-encoded 64-byte Ed25519 signature>",
  "signed_prekey_id": 1,
  "device_name": "Firefox on Linux"
}

Field Type Notes
identity_key base64 bytes Ed25519 public key (32 bytes)
signed_prekey base64 bytes X25519 public key (32 bytes)
signed_prekey_signature base64 bytes Ed25519 signature of signed_prekey using identity_key (64 bytes)
signed_prekey_id integer Monotonically increasing ID for rotation tracking
device_name string Optional label shown in the owner's device list. Truncated to 64 chars. Omitting it keeps any name already stored

The bundle is stored against the did claim in the caller's token — see Device identity. Two clients sharing one device_id overwrite each other here.

The server verifies the signature against the submitted identity_key and rejects a mismatch with 400. The whole bundle is replaced, identity key included.

Changing identity_key invalidates every existing session with this user. Their peers hold ratchet state derived from the old key, so all of it becomes undecryptable and each conversation has to re-handshake. It is allowed — refusing it would permanently lock out anyone who reinstalls without a PIN backup — and the server logs it, but peers are not yet shown a safety-number warning. Publishing a new identity key is not routine; do it only when the device genuinely has new keys.

Response 204


GET /api/keys/devices

List the caller's own devices. There is no route for anyone else's.

Authentication: Required

Response 200:

[
  {
    "device_id": "d7f3a91c22b4e08a",
    "device_name": "Firefox on Linux",
    "identity_key_hex": "3b1f...",
    "prekeys_left": 47,
    "first_seen": "2026-09-03T10:00:00Z",
    "last_updated": "2026-09-03T12:31:00Z",
    "current": true
  }
]

current marks the device making the request, from the token's did claim. prekeys_left is what distinguishes a live device from a stale one: a device that stops running stops replenishing them, and one at zero cannot accept new DM sessions.


DELETE /api/keys/devices/{deviceID}

Revoke a device: deletes its key bundle, its unused prekeys, and its refresh tokens, in one transaction.

Authentication: Required

Response 204

Errors: | Code | Condition | |------|-----------| | 400 | deviceID is the caller's own device — sign out instead | | 404 | No such device |

The revoked device's access token stays valid until it expires (15 minutes by default). Checking a revocation list on every request would put a database read in front of every authenticated call; the short TTL is the mitigation.

Revocation stops anything new being sealed to the device. It cannot make messages already sealed to it unreadable — nobody but that device holds the key that opens them.


GET /api/keys/bundle/{userID}

Fetch another user's public key bundle to initiate an X3DH session. Consumes one one-time prekey (OTP) per device, if available.

Authentication: Required

Who may fetch. The caller must share a context with userID: be that user (their own other devices), share a live DM or group DM with them, or share a server with them — and neither may have blocked the other. Anyone else gets 404, the same as a user with no keys, so the endpoint does not confirm who exists. A DM is opened before its first message is sealed, so every legitimate X3DH caller passes.

Budget. Consuming fetches (consume omitted or true) are limited to 10 per hour per (caller, target) and 60 per hour per caller; over it is 429 with Retry-After. consume=false is not budgeted — an epoch rotation makes one per member. Before this, any account could drain anyone's prekey pool and silently downgrade every later DM with them to X3DH without a one-time prekey.

Query Parameters: | Param | Type | Default | Notes | |-------|------|---------|-------| | consume | boolean | true | false returns the long-lived keys and no one-time prekey |

Pass consume=false whenever you are not starting an X3DH session — sealing a channel key to every member device, for example. A consumed prekey is gone, and callers that burn them for other purposes drain the pool and cost real forward secrecy: epoch rotation fetches every member device, so it used to consume one prekey per device per rotation for no benefit.

Response 200:

{
  "device_id": "018e1111-...",
  "identity_key": "<base64>",
  "signed_prekey": "<base64>",
  "signed_prekey_signature": "<base64>",
  "signed_prekey_id": 1,
  "one_time_prekey": "<base64>",
  "one_time_prekey_id": 42
}

one_time_prekey and one_time_prekey_id are null if the user has no OTPs remaining. X3DH still works without an OTP but provides weaker forward secrecy.


POST /api/keys/prekeys

Upload a batch of one-time prekeys.

Authentication: Required

Request:

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

Maximum 100 OTPs per batch; maximum 500 OTPs stored per user.

Response 204

Errors: | Code | Condition | |------|-----------| | 400 | Too many OTPs already stored |


GET /api/keys/prekeys/count

Get the number of one-time prekeys remaining for the authenticated user.

Authentication: Required

Response 200:

{
  "count": 87
}

Upload new OTPs when count drops below ~20.


PUT /api/keys/backup

Create or replace the PIN-protected encrypted key backup.

Authentication: Required

Request:

{
  "salt": "<base64-encoded 32-byte random salt>",
  "encrypted_blob": "<base64-encoded AES-256-GCM encrypted key material>",
  "pin_verifier": "<base64-encoded cheap Argon2id hash for fast PIN pre-check>",
  "argon2_memory": 65536,
  "argon2_iterations": 3,
  "argon2_parallelism": 4,
  "version": 1
}

The client is responsible for running Argon2id KDF and encrypting key_material. See E2EE Client Implementation Guide for the exact algorithm.

Response 204


GET /api/keys/backup/params

The salt and KDF parameters of the caller's backup — never the blob or the verifier, so nothing here can be brute forced. Use it to derive the verifier for /unlock, and to check whether a backup exists.

Response 200: { "salt", "argon2_memory", "argon2_iterations", "argon2_parallelism", "version" } — 404 when there is no backup.


POST /api/keys/backup/unlock

Releases the encrypted blob to a caller presenting the backup's PIN verifier — crypto.GeneratePINVerifier(pin, salt), the same value the client computed and sent as pin_verifier when it created the backup. The verifier crosses the wire; the PIN does not.

Request: { "pin_verifier": "<base64>" }

Response 200: same body as GET /api/keys/backup.

Errors: 400 no verifier; 401 wrong verifier, body includes remaining; 404 no backup; 429 locked, body includes locked_until. Wrong verifiers count toward the same 10-attempt / 24-hour lockout as /restore.

This is the restore path clients should use. Without it the blob went to any session that asked, and a stolen session could try all 10⁸ PINs offline with no lockout ever seeing an attempt.


GET /api/keys/backup — deprecated

Returns the encrypted blob without any PIN check. Kept for clients that have not moved to params + unlock, with two limits: it is refused (429) while the PIN lockout is active — otherwise failing /unlock ten times and then calling this would sidestep the lockout — and it is limited to 20 fetches per 24 hours (429 with Retry-After). One fetch is still enough for an offline attack, so these limits make abuse noisy; they do not prevent it. Remove once clients use /unlock.


POST /api/keys/backup/restore

Restore an encrypted backup by submitting the user's PIN. The server runs the Argon2id KDF and validates the PIN before returning the encrypted blob.

Authentication: Required

Request:

{
  "pin": "12348765"
}

PIN must be exactly 8 digits. Weak PINs (00000000, 11111111, 12345678, 87654321, etc.) are rejected.

Response 200:

{
  "salt": "<base64>",
  "encrypted_blob": "<base64>",
  "pin_verifier": "<base64>",
  "argon2_memory": 65536,
  "argon2_iterations": 3,
  "argon2_parallelism": 4,
  "version": 1
}

Errors: | Code | Description | |------|-------------| | 400 | Invalid PIN format or weak PIN | | 401 | Wrong PIN; body includes remaining attempts | | 404 | No backup found | | 429 | Account locked; body includes locked_until timestamp |


DELETE /api/keys/backup

Delete the key backup.

Authentication: Required

Response 204


17. Push Endpoints

Served by the api service under /api/push/ (routed by the existing /api/ nginx block). Design, threat model and the self-hosting trade: ProjectInfo/push-notifications.md.

Every route acts on the calling device — the token's device_id claim. There is no way to name another device. A token minted with the legacy shared device id default gets 400: every such device would share one registration.

The model in one paragraph: when a message arrives for you, the worker records that something is waiting for this device and sends a wake through FCM, UnifiedPush or (for a browser) Web Push. The wake is the constant {"v":"1","t":"wake"} — no sender, channel, server, count or content. The app then calls POST /api/push/drain over its own connection, fetches the messages it names through the ordinary endpoints, decrypts them on the device and draws the notification itself.

GET /api/device-tokens/vapid-key and POST/DELETE /api/device-tokens (served by the worker) no longer exist: browsers register through /api/push/registration with transport: "webpush", and the VAPID key comes from /api/push/config. Migration 000026 drops their device_tokens table. Those pushes carried the sender's and channel's names; a wake carries neither.


GET /api/push/config

Which transports this instance can deliver through.

Response 200:

{ "fcm": false, "unifiedpush": true, "webpush": true,
  "vapid_public_key": "BPhxpHxIrqQ_aa_6MpPrgtZ..." }

fcm is true only when the operator configured FCM (FCM_PROJECT_ID on api, and the service account on the worker). A self-hosted instance normally has fcm: false and its users need a UnifiedPush distributor.

webpush is true when a VAPID key pair is configured (VAPID_PUBLIC_KEY on api, the pair on the worker; generate with cmd/vapidgen). vapid_public_key is the applicationServerKey a browser passes to pushManager.subscribe(); it is public by design. A browser's wake shows a generic notification, since a service worker cannot decrypt; opening the app fetches and decrypts as usual.


PUT /api/push/registration

Store this device's registration, replacing any earlier one — a device has one way to be woken, so switching transport stops the old one. Call it on every app start, every sign-in and every token or endpoint change.

Request (Web Push — browsers):

{
  "platform": "webpush",
  "token": "https://fcm.googleapis.com/fcm/send/abc123",
  "keys": { "p256dh": "BJxK...", "auth": "k9Xq..." },
  "device_name": "Firefox on Linux"
}

Request (FCM / APNs — mobile):

{
  "transport": "unifiedpush",
  "token": "https://ntfy.sh/upAbC123?up=1",
  "p256dh": "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
  "auth": "BTBZMqHH6r4Tts7J_aSIgg"
}

Field Notes
transport fcm, unifiedpush or webpush (a browser PushSubscription; only when /api/push/config reports webpush: true)
token The FCM registration token, or the UnifiedPush endpoint URL. An endpoint must be https:// (http:// is accepted only when ENVIRONMENT=development), at most 2048 bytes, no userinfo
p256dh, auth UnifiedPush only, optional, both or neither. The connector's RFC 8291 keys, base64url (padding optional): a 65-byte uncompressed P-256 point that must be on the curve, and a 16-byte secret. With them the wake is encrypted to the app and the distributor sees an opaque blob. Ignored for FCM

Response 200:

{ "transport": "unifiedpush", "encrypted": true,
  "created_at": "2026-10-15T22:38:20Z", "updated_at": "2026-10-15T22:38:20Z" }
The token is not echoed back.

Errors: 400 with a reason for any validation failure above.


GET /api/push/registration

This device's registration, as above, or 404 if it has none — including after the worker deleted it because the transport reported it dead.


DELETE /api/push/registration

Stop waking this device. Absent is not an error. Call it on logout, before discarding tokens.

Response 204


POST /api/push/drain

Return and delete everything waiting for this device, and clear its outstanding wake, in one transaction. A drain, not a read: a second call returns []. Each device has its own queue.

Response 200 (Cache-Control: no-store), oldest first:

[
  {
    "channel_id": "e76b40af-…",
    "server_id": "7e829a98-…",
    "is_dm": false,
    "message_count": 4,
    "first_message_id": "088e1b14-…",
    "first_at": "2026-10-15T22:39:22.791824Z",
    "last_message_id": "9de58c79-…"
  }
]

One entry per channel, not per message. server_id is null for DMs. Fetch from first_message_id onwards with the ordinary message endpoints.

What queues an entry: a new message in a DM or group DM you are in, or in a server text/announcement channel where you hold ViewChannel, that you did not write, in a channel or server you have not muted. Mentions are not considered — they are inside the ciphertext — so the app decides after decrypting whether a channel message is worth a notification.

Wake delivery (worker)

Payload {"v":"1","t":"wake"}. FCM: data-only (no notification block), collapse_key: "wake", ttl: 3600s. UnifiedPush: RFC 8291 aes128gcm when keys were given, else the JSON in the clear; TTL: 3600, Topic: wake
Priority FCM HIGH / UnifiedPush Urgency: high when a DM is waiting, otherwise NORMAL / normal
Coalescing At most one outstanding wake per device; ≥ 15 s between wakes; an outstanding wake is re-sent after 15 min; a 5 s sweeper catches messages that arrived inside the interval
Pruning Deleted on FCM UNREGISTERED / SENDER_ID_MISMATCH / invalid-token, UnifiedPush 404/410; after 10 consecutive transient failures (exponential backoff, capped at 1 h); when the device has no live refresh token (swept every 10 min); on device revocation; after 90 days without re-registration. Queue rows older than 7 days are dropped
Endpoint safety No redirects, constant body, 10 s timeout. PUSH_UP_ALLOW_PRIVATE=false (default) refuses endpoints resolving to loopback/private/link-local addresses

Worker env: FCM_PROJECT_ID, FCM_CREDENTIALS_JSON (service-account key JSON, from Vault) — both empty disables FCM cleanly. PUSH_UP_ALLOW_PRIVATE. api env: FCM_PROJECT_ID, only to report fcm: true.


18. WebSocket Gateway

wss://api.{domain}/gateway?token=<access_token>

The gateway multiplexes real-time events across all subscribed channels and servers over a single WebSocket connection per client.

Connection

  1. Connect to wss://api.{domain}/gateway?token=<jwt>
  2. Receive hello frame immediately:
    { "op": "hello" }
    
  3. Begin sending subscribe messages to receive events.

Heartbeat

Send a heartbeat every 30 seconds to keep the connection alive:

{ "op": "heartbeat" }

Server responds:

{ "op": "heartbeat_ack" }

Subscribing to Channels

{
  "op": "subscribe",
  "channel_ids": ["018e3333-...", "018e4444-..."],
  "server_ids":  ["018e1234-..."]
}

After subscribing, the client receives events for all listed channels and servers that it may see: a channel needs VIEW_CHANNEL (server membership included) or DM participation, and a server needs membership. Ids must be canonical UUIDs. Refused ids are dropped, not fatal — the rest of the frame is still applied — and the client gets one {"op":"error","message":"subscription refused for ids you cannot view"}. Authorisation happens at subscribe time; a client that loses access keeps its subscription until it unsubscribes or reconnects (the spec's refetch-on-hello rule bounds what a stale subscription can show).

typing needs VIEW_CHANNEL + SEND_MESSAGES in the channel (or DM participation); otherwise the frame is dropped with {"op":"error","message":"typing refused"}.

Before this, any id was accepted — including NATS wildcards — so any session could receive any channel's events and type into it.

Subscribing to Your Own Events

User-scoped events (presence, DM notifications) are delivered on the user.<yourID> NATS prefix automatically when you connect — no explicit subscribe needed.

Unsubscribing

{
  "op": "unsubscribe",
  "channel_ids": ["018e4444-..."]
}

Typing Indicator

{
  "op": "typing",
  "channel_id": "018e3333-..."
}

This triggers a TYPING_START event for other subscribers.

Receiving Events

All events follow the same envelope:

{
  "op": "event",
  "type": "MESSAGE_CREATE",
  "data": { ...event-specific payload... }
}

Event Types

Type Description Data payload
MESSAGE_CREATE New message sent Message object
MESSAGE_UPDATE Message edited Message object
MESSAGE_DELETE Message deleted { "id": "...", "channel_id": "..." }
TYPING_START User started typing { "user_id": "...", "channel_id": "..." }
VOICE_STATE_UPDATE User joined/left voice, or changed their own mute/deafen (action: join/leave/update) Voice state object
CALL_UPDATE A DM call rang, was answered, or ended. Sent on the user subject of every DM member. Call object
MEMBER_JOIN User joined server Member object
ROLE_UPDATE Role created/updated Role object
PRESENCE_UPDATE User presence changed { "user_id": "...", "status": 1 }
ACCOUNT_UPDATE Something private about your own account changed — today only that the email was verified. Sent on your user subject only. { "email_verified": true }
USER_UPDATE Profile changed (name, avatar, bio) { "user_id": "...", "username": "...", "display_name": "...", ... }
CHANNEL_CREATE Channel created { "server_id": "...", "channel_id": "..." }
CHANNEL_UPDATE Channel renamed, moved or re-topiced { "server_id": "...", "channel_id": "..." }
SERVER_UPDATE Server name, icon or description changed { "server_id": "..." } — refetch GET /api/servers
CHANNEL_DELETE Channel deleted { "server_id": "...", "channel_id": "..." }
MEMBER_UPDATE A member's nickname or server avatar changed { "server_id": "...", "user_id": "...", "nickname": "...", "server_avatar": "..." }

The CHANNEL_* payloads carry IDs and nothing else. They are delivered to every member of the server, and a channel's name is exactly what a member denied View channel must not be told — so on receipt, refetch GET /api/servers/{serverID}/channels, which is filtered per caller.

Error Frame

{
  "op": "error",
  "message": "authentication failed"
}

The connection is closed after an error frame.


Voice State Object

{
  "user_id": "018e1111-...",
  "session_id": "lk-session-id",
  "joined_at": "2024-01-15T10:30:00Z",
  "self_mute": false,
  "self_deaf": false,
  "self_video": true,
  "self_stream": false,
  "server_mute": false,
  "server_deaf": false,
  "suppress": false
}

19. Permission Bits Reference

Permissions are stored as a 64-bit integer bitfield.

A non-member has no permissions. @everyone is a role that members hold; until 2026-10-17 the resolvers in the api and voice services handed a user who had never joined a server that server's @everyone permissions, so any session could list any channel's messages, and fetch a voice token plus the call's room_key, by channel id. The owner still resolves to ADMINISTRATOR.

Permission Bit Value Description
VIEW_CHANNEL 0 1 See channel and read messages
SEND_MESSAGES 1 2 Post messages
MANAGE_MESSAGES 3 8 Delete and pin others' messages
CONNECT 10 1024 Join a voice channel
SPEAK 11 2048 Transmit audio in voice
STREAM 16 65536 Share screen / video
ADMINISTRATOR 29 536870912 Bypass all permission checks

Combining permissions: bitwise OR the values together.

SEND_MESSAGES | VIEW_CHANNEL = 2 | 1 = 3

20. E2EE Client Implementation Guide

Overview

Direct messages (channel type dm or group_dm) use true end-to-end encryption. The server never sees plaintext. Clients implement the Signal Protocol: X3DH for session initiation, Double Ratchet for per-message keys.

All cryptographic operations use: - X25519 for key exchange - Ed25519 for signing - AES-256-GCM for symmetric encryption - HKDF-SHA256 for key derivation

1. Registration (One-Time Setup)

// Client generates:
identityKeyPair = generateEd25519()          // long-term identity
signedPreKey    = generateX25519()           // rotated every ~7 days
signature       = Ed25519Sign(identityKeyPair.private, signedPreKey.public)
otps            = [generateX25519() × 100]  // one-time prekeys

// Client uploads (public parts only — private keys NEVER leave the device):
POST /api/keys/bundle
POST /api/keys/prekeys    (first batch of 100)

2. Initiating a DM Session (Alice → Bob)

// Fetch Bob's bundle — consumes one of Bob's OTPs
bundle = GET /api/keys/bundle/{bobUserID}

// Verify Bob's signed prekey signature
assert Ed25519Verify(
    bundle.identity_key,
    bundle.signed_prekey,
    bundle.signed_prekey_signature
)

// Generate ephemeral key pair
ephemeralKey = generateX25519()

// Run X3DH
DH1 = X25519(Alice.identityKey.private, bundle.signed_prekey)
DH2 = X25519(ephemeralKey.private, bundle.identity_key)
DH3 = X25519(ephemeralKey.private, bundle.signed_prekey)
DH4 = X25519(ephemeralKey.private, bundle.one_time_prekey)   // if OTP available
sharedSecret = HKDF(SHA256, DH1 || DH2 || DH3 || DH4)

// Initialise Double Ratchet sender state with sharedSecret
ratchet = DoubleRatchetInit(sharedSecret, bundle.signed_prekey)

// Encrypt first message
{ ciphertext, ratchetHeader } = ratchet.Encrypt(plaintext)

// Send — include X3DH header so Bob can derive the same secret
POST /api/channels/{channelID}/messages
{
  "content": base64({
    "ciphertext": "<base64>",
    "ratchet_header": "<base64>",
    "x3dh_header": {
      "ephemeral_key": "<alice's ephemeral public key, base64>",
      "signed_prekey_id": bundle.signed_prekey_id,
      "one_time_prekey_id": bundle.one_time_prekey_id
    }
  })
}

3. Receiving and Decrypting (Bob)

// On receiving a message
payload = base64Decode(message.content)

if payload.x3dh_header:
    // First message in session — run X3DH receive side
    // Look up Bob's private key for the OTP ID used
    otpPrivate = lookupOTP(payload.x3dh_header.one_time_prekey_id)

    DH1 = X25519(Bob.signedPrekey.private, payload.x3dh_header.ephemeral_key)
    DH2 = X25519(Bob.identityKey.private,  payload.x3dh_header.ephemeral_key)
    DH3 = X25519(Bob.signedPrekey.private, payload.x3dh_header.ephemeral_key)
    DH4 = X25519(otpPrivate,               payload.x3dh_header.ephemeral_key)
    sharedSecret = HKDF(SHA256, DH1 || DH2 || DH3 || DH4)

    ratchet = DoubleRatchetInit(sharedSecret, Bob.signedPrekey)

// Decrypt with ratchet
plaintext = ratchet.Decrypt(payload.ciphertext, payload.ratchet_header)

4. PIN Backup (Key Recovery)

Allows users to restore DM history on a new device. Do not back up private keys without PIN protection.

// Create backup
pin        = getUserPin()             // 8-digit PIN from user
salt       = randomBytes(32)
masterKey  = Argon2id(pin, salt, memory=65536, iterations=3, parallel=4)
verifier   = Argon2id(pin, salt, memory=8192,  iterations=1, parallel=1)
blob       = AES256GCM.Encrypt(masterKey, exportPrivateKeys())

PUT /api/keys/backup { salt, encrypted_blob: blob, pin_verifier: verifier, ... }

// Restore on new device
POST /api/keys/backup/restore { "pin": "12348765" }
// → returns { salt, encrypted_blob, ... }

masterKey = Argon2id(userEnteredPin, response.salt, ...)
privateKeys = AES256GCM.Decrypt(masterKey, response.encrypted_blob)

5. Maintaining Prekeys

Monitor OTP count regularly and replenish:

count = GET /api/keys/prekeys/count
if count.count < 20:
    newOTPs = [generateX25519() × 100]
    POST /api/keys/prekeys { prekeys: newOTPs }

This document was generated from the Zipp-Chat server source code. For internal implementation details see architecture.md.