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¶
- Overview
- Authentication
- Error Format
- Rate Limiting
- Pagination
- Auth Endpoints
- Server Endpoints
- Channel Endpoints
- Role Endpoints
- Member Endpoints
- Invite Endpoints
- Message Endpoints
- User Endpoints
- Voice Endpoints
- Media Endpoints
- Key Endpoints (E2EE)
- Push Endpoints
- WebSocket Gateway
- Permission Bits Reference
- 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¶
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:
3. Error Format¶
All errors return a JSON body with a single error field:
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:
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 a400, 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/refreshtakes nodevice_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_tokencarries 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.
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.
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:
| 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:
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
@everyonerole — 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. ViewChanneldeny removes the channel fromGET /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:
| 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 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:
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:
| 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):
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.
| 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:
| 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-keyforpersistentchannels,GET /api/channels/{id}/epochforsecureones), encrypts locally, and posts a sealed envelope.GET .../messagesreturns 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
persistentthe 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); forsecurethe server holds no key at all. SeeProjectInfo/encryption-modes.md.The plaintext
contentfield still works for bots, scripts and the dev seeder. It is deprecated, every use is logged, and it is ignored whenciphertextis 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:
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
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
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/loginreturns{"two_factor_required": true, "two_factor_token": "...", "user": {...}}with no tokens. A client that ignorestwo_factor_requiredtherefore 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,
contentis 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
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
| 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_policygates creating a DM channel, inOpenDM. 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 isErrDMNotAllowed. Fails closed: an error in the check refuses contact.friend_requestsgatesSendRequest, 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_typingis checked where theTYPING_STARTevent is created, in the gateway, so the event is never published rather than published-and-ignored.share_presenceis enforced by storingofflineinstead of the real status, in both write paths (SetPresenceandPATCH /api/users/@me). Masking only the broadcast would leak the real value through the member list, the user endpoint and every future reader that joinsusers. 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:
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
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:
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:
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_keyinvalidates 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:
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 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-keyandPOST/DELETE /api/device-tokens(served by the worker) no longer exist: browsers register through/api/push/registrationwithtransport: "webpush", and the VAPID key comes from/api/push/config. Migration 000026 drops theirdevice_tokenstable. 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" }
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¶
- Connect to
wss://api.{domain}/gateway?token=<jwt> - Receive
helloframe immediately: - Begin sending subscribe messages to receive events.
Heartbeat¶
Send a heartbeat every 30 seconds to keep the connection alive:
Server responds:
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¶
Typing Indicator¶
This triggers a TYPING_START event for other subscribers.
Receiving Events¶
All events follow the same envelope:
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¶
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.
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.