2026-03-30 22:31:06 +02:00
# REST API Reference
OwnCord server REST API reference. All endpoints use the base URL `https://{server}:{port}/api/v1` .
---
## Authentication
All authenticated endpoints require a session token delivered via the `Authorization: Bearer {token}` header. Tokens are obtained from `POST /api/v1/auth/login` , `POST /api/v1/auth/register` , or `POST /api/v1/auth/verify-totp` after a partial 2FA challenge.
### Session Lifecycle
- Sessions are created on login/register and stored with a SHA-256 hash of the raw token, the client IP, User-Agent, and an expiry timestamp.
- Each authenticated request updates the session's `last_active` timestamp.
- Banned users are rejected at the middleware level with `403 FORBIDDEN` .
### Middleware Stack (all routes)
1. **RequestID** -- assigns a unique `X-Request-Id` response header.
2. **Recoverer** -- catches panics and returns 500.
3. **Request Logger** -- structured logging of method, path, status, duration.
2026-07-23 15:28:41 +02:00
4. **SecurityHeadersWithTLS** -- (adds `Strict-Transport-Security` when TLS is on) sets `X-Content-Type-Options: nosniff` , `X-Frame-Options: DENY` , `X-XSS-Protection: 0` , `Referrer-Policy: strict-origin-when-cross-origin` , `Content-Security-Policy: default-src 'self'` , `Permissions-Policy: camera=(), microphone=(), geolocation=()` , `Cache-Control: no-store` .
2026-03-30 22:31:06 +02:00
5. **MaxBodySize** -- 1 MiB default for all routes except `/api/v1/uploads` (which has its own 100 MiB limit).
---
## Standard Error Response
All error responses use this JSON envelope:
```json
{
"error" : "ERROR_CODE" ,
"message" : "Human-readable detail"
}
```
### Error Codes
| Code | HTTP Status | When It Occurs |
| ---- | ----------- | -------------- |
| `UNAUTHORIZED` | 401 | Missing/invalid/expired session token |
| `INVALID_CREDENTIALS` | 401 | Login/register with bad username/password/invite (generic to prevent enumeration) |
| `FORBIDDEN` | 403 | Insufficient permissions, banned account, or admin IP restriction |
| `NOT_FOUND` | 404 | Resource (channel, message, user, invite, file, backup) not found |
| `RATE_LIMITED` | 429 | Too many requests; response includes `Retry-After` header (seconds) |
| `INVALID_INPUT` / `BAD_REQUEST` | 400 | Malformed body, missing required fields, invalid query params |
| `CONFLICT` | 409 | Duplicate username on register, or server already up-to-date on update |
| `TOO_LARGE` | 413 | File exceeds upload size limit |
| `SERVER_ERROR` / `INTERNAL` | 500 | Internal server error |
2026-07-20 13:29:57 +02:00
| `BAD_GATEWAY` | 502 | Upstream failure (GitHub API, LiveKit, GIF provider, asset download) |
| `GIF_DISABLED` | 503 | GIF proxy is not configured on this server (no `gif.api_key` ) |
2026-03-30 22:31:06 +02:00
---
## Auth Endpoints
### POST /api/v1/auth/register
Create a new account using an invite code. The first user is created via `/admin/api/setup` instead.
**Auth:** None (public)
**Rate limit:** 3 requests/minute per IP
#### Request
```json
{
"username" : "alex" ,
"password" : "MyStr0ng!Pass" ,
"invite_code" : "abc123def"
}
```
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| `username` | string | Yes | HTML-stripped, trimmed. Must be non-empty. |
| `password` | string | Yes | Validated for strength (min length, complexity). |
| `invite_code` | string | Yes | Must be a valid, non-expired, non-revoked invite with remaining uses. |
#### Response 201 Created
```json
{
"token" : "raw-session-token-64-chars" ,
"user" : {
"id" : 2 ,
"username" : "alex" ,
"avatar" : "" ,
2026-08-01 22:06:14 +02:00
"display_name" : null ,
"about" : null ,
"custom_status" : null ,
2026-03-30 22:31:06 +02:00
"status" : "offline" ,
"role_id" : 4 ,
"totp_enabled" : false ,
"created_at" : "2026-03-24T12:00:00Z"
}
}
```
2026-08-01 22:06:14 +02:00
See [GET /api/v1/auth/me ](#get-apiv1authme ) for the full user-object field table.
2026-03-30 22:31:06 +02:00
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Missing username/password/invite_code, or weak password |
| 400 | `INVALID_CREDENTIALS` | Bad invite code, expired/revoked invite, or duplicate username |
| 403 | `FORBIDDEN` | Registration is closed or unavailable while server-wide 2FA is required |
| 429 | `RATE_LIMITED` | Exceeded 3 registrations/minute from this IP |
| 500 | `SERVER_ERROR` | Hashing failure, session creation failure, or DB error |
---
### POST /api/v1/auth/login
Authenticate with username and password.
**Auth:** None (public)
**Rate limit:** 60 requests/minute per IP. After 10 consecutive failures from the same IP, the IP is locked out for 15 minutes.
#### Request
```json
{
"username" : "alex" ,
"password" : "MyStr0ng!Pass"
}
```
#### Response 200 OK
If the account does not have TOTP enabled:
```json
{
"token" : "raw-session-token-64-chars" ,
"requires_2fa" : false ,
"user" : {
"id" : 1 ,
"username" : "alex" ,
2026-08-01 22:06:14 +02:00
"avatar" : "/api/v1/files/uuid" ,
"display_name" : "Alex" ,
"about" : null ,
"custom_status" : null ,
2026-03-30 22:31:06 +02:00
"status" : "offline" ,
"role_id" : 4 ,
"totp_enabled" : false ,
"created_at" : "2026-03-24T12:00:00Z"
}
}
```
2026-08-01 22:06:14 +02:00
See [GET /api/v1/auth/me ](#get-apiv1authme ) for the full user-object field table.
2026-03-30 22:31:06 +02:00
If the account has TOTP enabled:
```json
{
"partial_token" : "opaque-partial-token" ,
"requires_2fa" : true
}
```
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Missing username or password |
| 401 | `UNAUTHORIZED` | Wrong username or password |
| 403 | `FORBIDDEN` | Account is banned/suspended |
| 429 | `RATE_LIMITED` | IP locked out after 10 consecutive failures (15 min cooldown) |
| 500 | `SERVER_ERROR` | Session creation failure |
---
### POST /api/v1/auth/verify-totp
Complete a TOTP login challenge started by `POST /api/v1/auth/login` .
**Auth:** Required with the `partial_token` from the login response
**Rate limit:** 10 requests/minute per IP, plus a 5-attempt budget per partial challenge
#### Request
```json
{
"code" : "123456"
}
```
#### Response 200 OK
```json
{
"token" : "raw-session-token-64-chars" ,
"requires_2fa" : false ,
"user" : {
"id" : 1 ,
"username" : "alex" ,
2026-08-01 22:06:14 +02:00
"avatar" : "/api/v1/files/uuid" ,
"display_name" : "Alex" ,
"about" : null ,
"custom_status" : null ,
2026-03-30 22:31:06 +02:00
"status" : "offline" ,
"role_id" : 4 ,
"totp_enabled" : true ,
"created_at" : "2026-03-24T12:00:00Z"
}
}
```
2026-08-01 22:06:14 +02:00
See [GET /api/v1/auth/me ](#get-apiv1authme ) for the full user-object field table.
2026-03-30 22:31:06 +02:00
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Malformed request body |
| 401 | `UNAUTHORIZED` | Missing/expired challenge, invalid TOTP code, or challenge consumed |
| 500 | `SERVER_ERROR` | Session creation failure |
---
### GET /api/v1/auth/me
Get the current authenticated user's profile.
**Auth:** Required (Bearer token)
#### Response 200 OK
```json
{
"id" : 1 ,
"username" : "alex" ,
2026-08-01 22:06:14 +02:00
"avatar" : "/api/v1/files/uuid" ,
"display_name" : "Alex" ,
"about" : "A short bio." ,
"custom_status" : "building things" ,
2026-03-30 22:31:06 +02:00
"status" : "online" ,
"role_id" : 2 ,
"totp_enabled" : true ,
"created_at" : "2026-03-24T12:00:00Z"
}
```
2026-08-01 22:06:14 +02:00
This is the canonical **user object** , also returned as `user` by register,
login and the TOTP challenge.
2026-03-30 22:31:06 +02:00
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | int64 | User ID |
2026-08-01 22:06:14 +02:00
| `username` | string | Unique handle; the name `@mentions` resolve against |
| `avatar` | string | Avatar URL (`/api/v1/files/{id}` after an upload, or an `https://` URL), or empty string |
| `display_name` | string\|null | Nickname rendered instead of `username` ; null when unset |
| `about` | string\|null | Profile bio, max 300 characters; null when unset |
| `custom_status` | string\|null | Free-text status line, max 128 characters; null when unset. Set over WebSocket (`presence_update` ), not over REST |
| `status` | string | One of: `online` , `idle` , `dnd` , `invisible` , `offline` . **This is the caller's own true status** , so `invisible` appears here; every payload describing this user to *anyone else* reports `offline` instead |
2026-03-30 22:31:06 +02:00
| `role_id` | int64 | Numeric role ID (1=Owner, 2=Admin, 3=Moderator, 4=Member) |
| `totp_enabled` | bool | Whether the user has a confirmed TOTP secret |
| `created_at` | string | ISO 8601 timestamp |
---
### POST /api/v1/auth/logout
Invalidate the current session token.
**Auth:** Required (Bearer token)
#### Response 204 No Content
---
2026-03-31 18:07:23 +02:00
### DELETE /api/v1/auth/account
Permanently delete the authenticated user's account. Requires password confirmation.
**Auth:** Required (Bearer token)
**Rate limit:** 5 requests/minute per IP. After 3 failed password attempts, the endpoint locks out for 15 minutes per user.
#### Request
```json
{
"password" : "MyStr0ng!Pass"
}
```
#### Response 204 No Content
Account deleted successfully. All sessions, messages (soft-deleted), and associated data are cleaned up.
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Missing or incorrect password |
| 403 | `FORBIDDEN` | Cannot delete the last admin account |
| 429 | `RATE_LIMITED` | Locked out after 3 failed password attempts (15 min cooldown) |
| 500 | `SERVER_ERROR` | Database error during deletion |
---
2026-03-30 22:31:06 +02:00
### POST /api/v1/users/me/totp/enable
Start TOTP enrollment for the authenticated user. The secret is not persisted until `/api/v1/users/me/totp/confirm` succeeds.
**Auth:** Required
**Rate limit:** 5 requests/minute per IP
#### Request
```json
{
"password" : "MyStr0ng!Pass"
}
```
#### Response 200 OK
```json
{
"qr_uri" : "otpauth://totp/OwnCord:alex?..." ,
"backup_codes" : []
}
```
---
### POST /api/v1/users/me/totp/confirm
Confirm a pending TOTP enrollment.
**Auth:** Required
**Rate limit:** 5 requests/minute per IP
#### Request
```json
{
"password" : "MyStr0ng!Pass" ,
"code" : "123456"
}
```
#### Response 204 No Content
---
### DELETE /api/v1/users/me/totp
Disable TOTP for the authenticated user.
**Auth:** Required
**Rate limit:** 5 requests/minute per IP
#### Request
```json
{
"password" : "MyStr0ng!Pass"
}
```
#### Response 204 No Content
---
2026-07-19 13:51:44 +00:00
## User Profile & Sessions
### PATCH /api/v1/users/me
2026-08-01 22:06:14 +02:00
Update the authenticated user's profile. Broadcasts a `user_update` WebSocket
message to all clients on success, carrying the full profile snapshot (the
event replaces the client's copy rather than patching it).
2026-07-19 13:51:44 +00:00
**Auth:** Required
**Rate limit:** 10 requests/minute
#### Request
```json
{
"username" : "newname" ,
2026-08-01 22:06:14 +02:00
"avatar" : "https://example.com/pic.png" ,
"display_name" : "New Name" ,
"about" : "A short bio."
2026-07-19 13:51:44 +00:00
}
```
2026-08-01 22:06:14 +02:00
| Field | Rules |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `username` | Required. The unique handle; `@mentions` resolve against it. |
| `avatar` | Optional. Must be an `https://` URL (max 512 chars) or `""` to clear. Upload a file instead with `POST /api/v1/users/me/avatar` . |
| `display_name` | Optional, 1– 32 characters. Shown instead of `username` everywhere; `""` clears it and falls back to the username. Rejected if it contains control or invisible (bidi-override) characters. |
| `about` | Optional, max 300 characters. `""` clears it. |
Omitting a field leaves it unchanged; sending `""` clears the nullable ones.
`display_name` and `about` are HTML-sanitized and trimmed server-side, and the
length caps count characters, not bytes.
2026-07-19 13:51:44 +00:00
#### Response 200 OK
Returns the updated user object (same shape as `GET /api/v1/auth/me` ).
---
2026-08-01 22:06:14 +02:00
### POST /api/v1/users/me/avatar
Upload an avatar image and point the authenticated user's avatar at it.
Broadcasts a `user_update` on success, exactly like the PATCH above.
The bytes are stored as an ordinary attachment with no channel, and
`users.avatar` is set to `/api/v1/files/{id}` . That URL is what makes the
picture readable: `GET /api/v1/files/{id}` normally serves an unlinked
attachment only to its uploader, and additionally admits one that some user's
avatar currently points at — so an avatar is readable by every authenticated
user for exactly as long as it is in use, and stops being readable the moment
it is replaced.
Not registered when the server has no working storage backend.
**Auth:** Required
**Rate limit:** 5 uploads/minute per user
#### Request
`multipart/form-data` with a single `file` part.
| Rule | Value |
| ---------- | --------------------------------------------------------------------------------------------------------------------- |
| Type | `image/png` , `image/jpeg` or `image/webp` , sniffed from the file's own bytes (the client's `Content-Type` is ignored) |
| Size | 1 MiB |
| Dimensions | 1024x1024, measured from the sniffed image |
GIF is refused (an animated avatar renders in every message row), and so is
SVG — it is markup with script and external-fetch capability, and an avatar is
rendered inline by definition. The server does not re-encode or crop; the
client is expected to downscale and square-crop before uploading.
#### Response 201 Created
```json
{
"id" : "5f2c..." ,
"filename" : "me.png" ,
"size" : 20481 ,
"mime" : "image/png" ,
"url" : "/api/v1/files/5f2c..." ,
"width" : 256 ,
"height" : 256
}
```
#### Errors
| Status | Code | Cause |
| ------ | -------------- | -------------------------------------------------------------- |
| 400 | `BAD_REQUEST` | Missing `file` part, wrong type, too large, or too many pixels |
| 429 | `RATE_LIMITED` | Too many uploads |
---
2026-07-19 13:51:44 +00:00
### PUT /api/v1/users/me/password
Change the authenticated user's password. Verifies the old password, enforces
password strength, and revokes all *other* sessions on success.
**Auth:** Required
**Rate limit:** 5 requests/minute, plus a failed-confirmation lockout on
repeated wrong old passwords
#### Request
```json
{
"old_password" : "OldPass!1" ,
"new_password" : "NewStr0ng!Pass"
}
```
#### Response 204 No Content
Password changed and other sessions revoked. If the password change committed
but revoking other sessions failed, the endpoint returns **200 OK** with a
warning body instead (the new password is in effect — do not retry with the
old one).
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Weak new password, or new password equals old |
| 403 | `FORBIDDEN` | Incorrect old password |
| 429 | `RATE_LIMITED` | Too many attempts / lockout |
---
### GET /api/v1/users/me/sessions
List the authenticated user's active sessions.
**Auth:** Required
#### Response 200 OK
```json
{
"sessions" : [
{
"id" : 12 ,
"device" : "Mozilla/5.0 ..." ,
"ip" : "192.168.1.100" ,
"created_at" : "2026-07-01T10:00:00Z" ,
"last_used" : "2026-07-19T09:00:00Z" ,
"is_current" : true
}
]
}
```
---
### DELETE /api/v1/users/me/sessions/{id}
Revoke one of the authenticated user's sessions by ID.
**Auth:** Required
#### Response 204 No Content
---
2026-03-30 22:31:06 +02:00
## Channel Endpoints
### GET /api/v1/channels
List all channels the authenticated user has `READ_MESSAGES` permission for. DM channels are NOT included (use `GET /api/v1/dms` instead).
**Auth:** Required
#### Response 200 OK
```json
[
{
"id" : 1 ,
"name" : "general" ,
"type" : "text" ,
"topic" : "Welcome to the server!" ,
"category" : "Text Channels" ,
"position" : 0 ,
"slow_mode" : 0 ,
2026-08-01 22:06:14 +02:00
"archived" : false ,
"nsfw" : false ,
"voice_max_users" : 0 ,
"voice_max_video" : 0
2026-03-30 22:31:06 +02:00
}
]
```
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | int64 | Channel ID |
| `name` | string | Channel name |
2026-07-19 16:00:18 +00:00
| `type` | string | `text` , `voice` , or `announcement` (announcement channels are read like text but only `MANAGE_MESSAGES` holders can post) |
2026-03-30 22:31:06 +02:00
| `topic` | string | Channel topic/description |
| `category` | string | Category grouping |
| `position` | int | Sort order within category |
| `slow_mode` | int | Slow-mode delay in seconds (0 = disabled) |
| `archived` | bool | Whether the channel is archived |
2026-08-01 22:06:14 +02:00
| `nsfw` | bool | Age-restriction label. **Stored and shipped only** — the server applies no content behaviour to a flagged channel (see below) |
| `voice_max_users` | int | Voice capacity, 0 = unlimited. Enforced on join (`CHANNEL_FULL` ) |
| `voice_max_video` | int | Simultaneous cameras/screen shares, 0 = unlimited. Enforced on publish (`VIDEO_LIMIT` ) |
#### The `nsfw` flag
`nsfw` is metadata and nothing else. The server stores it, ships it in `ready`
and in the `channel_create` / `channel_update` broadcasts, and audits an
operator flipping it — and does **not** filter content, check anyone's age, or
restrict who may read or post in a flagged channel. Every consequence is the
client's: the desktop client shows a one-time-per-session "may contain
sensitive content" gate before rendering a flagged channel's messages
(remembered in `sessionStorage` , so a new session asks again) and marks the
channel in its sidebar. A client that ignores the field behaves exactly as it
did before the field existed.
2026-03-30 22:31:06 +02:00
---
### GET /api/v1/channels/{id}/messages
Paginated message history for a channel.
**Auth:** Required
**Permission:** `READ_MESSAGES` on the channel (or DM participant membership)
#### Query Parameters
| Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- |
| `before` | int64 | 0 (latest) | >= 0 | Cursor: return messages with ID less than this value |
| `limit` | int | 50 | 1-100 | Number of messages to return |
#### Response 200 OK
```json
{
"messages" : [
{
"id" : 1042 ,
"channel_id" : 5 ,
"user" : {
"id" : 1 ,
"username" : "alex" ,
"avatar" : "uuid.png"
},
"content" : "Hello!" ,
"reply_to" : null ,
"attachments" : [
{
"id" : "file-uuid" ,
"filename" : "photo.jpg" ,
"size" : 204800 ,
"mime_type" : "image/jpeg" ,
"url" : "/api/v1/files/file-uuid" ,
"width" : 1920 ,
"height" : 1080
}
],
"reactions" : [
{
"emoji" : "\ud83d\udc4d" ,
"count" : 2 ,
"me" : true
}
],
"pinned" : false ,
"edited_at" : null ,
"deleted" : false ,
2026-08-01 22:06:14 +02:00
"timestamp" : "2026-03-14T10:30:00Z" ,
"mentions" : [ 7 ],
"mentions_everyone" : false
2026-03-30 22:31:06 +02:00
}
],
"has_more" : true
}
```
2026-08-01 22:06:14 +02:00
`mentions` is the server-resolved list of mentioned user IDs (always present,
empty when the message mentions nobody) and `mentions_everyone` reports an
`@everyone` /`@here` that cleared the `MENTION_EVERYONE` permission. Both are
resolved at send time and re-resolved on edit; an `@word` that matches no
username, or an `@everyone` from a user without the bit, carries no mention
semantics and stays plain text. The same two fields appear on pinned-message
responses and on the WebSocket `chat_message` /`chat_edited` payloads.
2026-03-30 22:31:06 +02:00
#### Pagination
Use cursor-based pagination by passing the `id` of the last message as the `before` parameter:
```
GET /api/v1/channels/5/messages?before=1042&limit=50
```
When `has_more` is `false` , you have reached the beginning of the channel history.
---
2026-08-01 22:06:14 +02:00
### GET /api/v1/channels/{id}/messages/around/{messageId}
The window of channel history centred on one message, for jumping to a message
that is not in the client's loaded page — a search hit, a pinned entry, a reply
reference, or an `owncord://message/{channelId}/{messageId}` permalink.
**Auth:** Required
**Permission:** `READ_MESSAGES` on the channel (or DM participant membership) — the same gate as `GET /messages`
#### Query Parameters
| Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- |
| `limit` | int | 50 | 1-100 | Total window size, centre included |
Half the window sits before the centre and the remainder after it: `limit=50`
returns up to 25 older messages, the centre, and up to 24 newer ones. Near the
start or end of a channel the window is simply shorter — it is not re-balanced
toward the other side.
#### Response 200 OK
```json
{
"messages" : [],
"has_more_before" : true ,
"has_more_after" : true
}
```
`messages` holds the same message objects as `GET /messages` (user, attachments,
reactions with the `me` flag, `mentions` , `mentions_everyone` ), but is ordered
**oldest-first** , not newest-first like the paginated history endpoint.
`has_more_before` / `has_more_after` report whether the channel holds further
live history on each side of the returned window. A client that renders an
around-window is *detached* from the live tail while `has_more_after` is true:
newly broadcast messages belong below the window and are not part of it, so the
client should offer a "jump to present" affordance that refetches the normal
`GET /messages` tail.
#### Errors
| Status | Code | When |
|--------|------|------|
| 400 | `BAD_REQUEST` | `id` or `messageId` is not a positive integer, or `limit` is not a positive integer |
| 403 | `FORBIDDEN` | The channel exists but `READ_MESSAGES` is denied |
| 404 | `NOT_FOUND` | The channel does not exist, the caller is not a participant of the DM, or the message does not live in this channel |
Soft-deleted messages are 404 here, not an empty window: history omits deleted
rows, so there is no row to centre on. Deleted messages are also excluded from
the window itself, exactly as in `GET /messages` .
---
### POST /api/v1/channels/{id}/messages/purge
Bulk soft-delete the newest messages in a channel.
**Auth:** Required
**Permission:** `READ_MESSAGES` **and** `MANAGE_MESSAGES` on the channel (per-channel overrides apply)
Not available in DM channels — a DM has no `MANAGE_MESSAGES` gate, so those
requests are rejected with 403.
#### Request Body
```json
{
"limit" : 50 ,
"before" : 1042
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `limit` | integer | Yes | How many messages to delete, 1--100. Values above 100 are clamped; 0 or negative is a 400. |
| `before` | integer | No | Only delete messages with an id below this one. Omit or `0` to start from the newest. |
#### Response 200 OK
```json
{
"channel_id" : 5 ,
"ids" : [ 1042 , 1041 , 1040 ],
"count" : 3
}
```
`ids` is newest-first and may hold fewer than `limit` entries when the channel
has less history; already-deleted messages are skipped. Deletion is soft: the
rows stay as tombstones, exactly as with a single delete. A single
[`chat_bulk_deleted` ](protocol.md#chat_bulk_deleted-server---client-broadcast )
WebSocket event is broadcast to the channel (not one `chat_deleted` per
message), and one `message_purge` audit entry is written.
Rate limited to 5/sec per user.
---
### GET /api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users
List the users who reacted to a message with a specific emoji — the "who
reacted" tooltip behind a reaction pill.
**Auth:** Required
**Permission:** `READ_MESSAGES` on the channel (DM: participant)
The reactor list is a separate endpoint rather than `user_ids` inline on every
reaction summary, so message payloads stay small: a busy channel carries dozens
of pills per page and almost none of them are ever hovered.
`{emoji}` is a path segment and must be percent-encoded (`👍` → `%F0%9F%91%8D` ).
The message must belong to `{id}` ; a message in another channel is a 404, so the
channel in the URL is always the one the permission check ran against.
#### Response 200 OK
```json
{
"users" : [
{ "id" : 3 , "username" : "alice" , "avatar" : "" },
{ "id" : 7 , "username" : "bob" , "avatar" : "/api/v1/files/abc123" }
]
}
```
Ordered oldest reaction first and capped at **100** reactors — the list is for a
tooltip, not an audit. `users` is always an array (`[]` when nobody used that
emoji, which is also the answer for an emoji that does not exist). `avatar` is
`""` when the user has none.
| Status | Error | When |
|--------|-------|------|
| 400 | `BAD_REQUEST` | Non-positive `id` /`messageId` , or an empty / over-32-rune / control-character emoji |
| 403 | `FORBIDDEN` | No `READ_MESSAGES` on the channel |
| 404 | `NOT_FOUND` | Channel or message not found, the message lives in another channel, or a DM the caller is not in |
---
2026-03-30 22:31:06 +02:00
### GET /api/v1/channels/{id}/pins
Get all pinned messages for a channel.
**Auth:** Required
**Permission:** `READ_MESSAGES` on the channel
#### Response 200 OK
Returns `{ "messages": [...], "has_more": false }` . `has_more` is always `false` for pins (all pinned messages are returned at once).
---
### POST /api/v1/channels/{id}/pins/{messageId}
Pin a message in a channel.
**Auth:** Required
**Permission:** `MANAGE_MESSAGES` on the channel
#### Response 204 No Content
---
### DELETE /api/v1/channels/{id}/pins/{messageId}
Unpin a message from a channel.
**Auth:** Required
**Permission:** `MANAGE_MESSAGES` on the channel
#### Response 204 No Content
---
## Search
### GET /api/v1/search
Full-text search across messages in channels the user can read. Uses SQLite FTS5 for matching.
**Auth:** Required
2026-07-19 13:51:44 +00:00
**Rate limit:** 30 requests/minute
2026-03-30 22:31:06 +02:00
#### Query Parameters
| Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- |
| `q` | string | (required) | non-empty | Search query (FTS5 syntax) |
| `channel_id` | int64 | (all channels) | > 0 | Restrict search to a single channel |
| `limit` | int | 50 | 1-100 | Maximum results to return |
#### Response 200 OK
```json
{
"results" : [
{
"message_id" : 1042 ,
"channel_id" : 5 ,
"channel_name" : "general" ,
"user" : {
"id" : 1 ,
"username" : "alex"
},
"content" : "...matched text..." ,
2026-08-01 22:06:14 +02:00
"timestamp" : "2026-03-14T10:30:00Z" ,
"mentions" : [ 7 ],
"mentions_everyone" : false
2026-03-30 22:31:06 +02:00
}
]
}
```
---
2026-07-20 13:29:57 +02:00
## GIFs
The server proxies the Klipy GIF API so the provider API key stays server-side.
Clients never contact `api.klipy.com` — a key shipped in the desktop bundle
would be public by construction. The key is configured as `gif.api_key`
(see [Server Configuration ](server-configuration.md#gif-picker-gif )).
**Default-off contract:** with no key configured, both endpoints return
`503` with error code `GIF_DISABLED` . Clients must treat that as "this server
does not have GIFs" and hide/disable the GIF affordance — not retry.
The media URLs in the response point at Klipy's CDN; the client still validates
them against its `klipy.com` CDN allowlist before rendering.
### GET /api/v1/gif/search
**Auth:** Required
**Rate limit:** 30 requests/minute (dedicated per-IP bucket)
#### Query Parameters
| Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- |
| `q` | string | (required) | 1-100 chars | Search term |
| `limit` | int | 20 | 1-50 | Maximum results to return |
#### Response 200 OK
```json
{
"results" : [
{
"id" : "abc123" ,
"title" : "happy cat" ,
"media_formats" : {
"tinygif" : { "url" : "https://media.klipy.com/abc123_tiny.gif" },
"gif" : { "url" : "https://media.klipy.com/abc123.gif" }
}
}
]
}
```
Only `id` , `title` , and the two `media_formats` URLs are forwarded. Every other
field the upstream returns is dropped, so an upstream that echoed the API key
could not leak it to clients. Results missing either format are omitted.
#### Errors
| Status | Code | When |
| ------ | ---- | ---- |
| 400 | `INVALID_INPUT` | Missing/blank `q` , `q` over 100 chars, or `limit` outside 1-50 |
| 401 | `UNAUTHORIZED` | No valid session (checked before the disabled check) |
| 429 | `RATE_LIMITED` | Over 30 requests/minute |
| 502 | `BAD_GATEWAY` | Upstream error, timeout, or unparseable response |
| 503 | `GIF_DISABLED` | `gif.api_key` is not configured |
### GET /api/v1/gif/trending
Same auth, rate limit, response shape, and error codes as
`/api/v1/gif/search` , minus the `q` parameter.
| Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- |
| `limit` | int | 20 | 1-50 | Maximum results to return |
---
2026-03-30 22:31:06 +02:00
## Direct Messages
DM channels use participant-based authorization rather than role-based permissions.
### POST /api/v1/dms
Create or retrieve a 1-on-1 DM channel with another user. If a DM channel already exists, it is returned and re-opened.
**Auth:** Required
#### Request
```json
{
"recipient_id" : 2
}
```
#### Response 200 OK (existing channel) or 201 Created (new channel)
```json
{
"channel_id" : 100 ,
"recipient" : {
"id" : 2 ,
"username" : "jordan" ,
"avatar" : "uuid.png" ,
"status" : "online"
},
"created" : false
}
```
---
### GET /api/v1/dms
List all open DM channels for the authenticated user, ordered by most recent activity.
**Auth:** Required
#### Response 200 OK
```json
{
"dm_channels" : [
{
"channel_id" : 100 ,
2026-08-01 22:06:14 +02:00
"name" : "Lunch crew" ,
"is_group" : true ,
2026-03-30 22:31:06 +02:00
"recipient" : {
"id" : 2 ,
"username" : "jordan" ,
2026-08-01 22:06:14 +02:00
"display_name" : "Jo" ,
"avatar" : "/api/v1/files/uuid" ,
2026-03-30 22:31:06 +02:00
"status" : "online"
},
2026-08-01 22:06:14 +02:00
"recipients" : [
{
"id" : 2 ,
"username" : "jordan" ,
"display_name" : "Jo" ,
"avatar" : "/api/v1/files/uuid" ,
"status" : "online"
},
{
"id" : 3 ,
"username" : "sam" ,
"display_name" : "" ,
"avatar" : "" ,
"status" : "idle"
}
],
2026-03-30 22:31:06 +02:00
"last_message_id" : 5042 ,
"last_message" : "Hey, how's it going?" ,
"last_message_at" : "2026-03-28T14:30:00Z" ,
"unread_count" : 3
}
]
}
```
2026-08-01 22:06:14 +02:00
| Field | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `recipient` | The other participant of a 1:1 DM. **Backward compatibility only** — for a group it carries the first of `recipients` . |
| `recipients` | Every participant except the caller. What group-aware clients read. |
| `name` | Optional group name; `""` for a 1:1 DM and for an unnamed group. |
| `is_group` | True for a group DM. Stored, not derived from the live participant count. |
`status` is viewer-adjusted: an `invisible` participant reads as `offline` .
---
### POST /api/v1/dms/group
Create a group DM between the caller and 2– 8 other users (3– 10 total).
Unlike `POST /api/v1/dms` this **always creates** : the same set of people may
reasonably want more than one group, so there is no "the group for these users"
to look up.
Blocks are enforced in both directions, per recipient — a user may neither pull
someone they have blocked into a room with them nor use a group to reach
someone who has blocked them. The check is creation-time only; see
`docs/protocol.md` § DM Authorization for why sending into a group is not
block-checked.
**Auth:** Required
#### Request
```json
{
"recipient_ids" : [ 2 , 3 ],
"name" : "Lunch crew"
}
```
| Field | Type | Required | Description |
| --------------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `recipient_ids` | int[] | Yes | 2– 8 other users. De-duplicated; the caller is dropped if named. |
| `name` | string | No | Group name, ≤ 100 characters. HTML-stripped. Omit or `""` for an unnamed group. |
#### Response 201 Created
The same DM summary shape `GET /api/v1/dms` returns, from the creator's seat.
Every participant — the creator included — also receives a `dm_channel_open` .
#### Errors
| Status | Code | Reason |
| ------ | ------------- | --------------------------------------------------------------------- |
| 400 | `BAD_REQUEST` | Fewer than 2 or more than 8 recipients, or a name over 100 characters |
| 403 | `FORBIDDEN` | A recipient is blocked by, or has blocked, the caller |
| 404 | `NOT_FOUND` | A recipient does not exist |
---
### PATCH /api/v1/dms/{channelId}
Set or clear a group DM's name.
Any participant may rename it. That is Discord's rule and the only one that
works here: a group DM has no owner column and no roles, so "who may rename" has
exactly one answer that does not require inventing an ownership model. A 1:1 DM
refuses — its name is who is in it.
**Auth:** Required (participant)
#### Request
```json
{ "name" : "Lunch crew" }
```
An empty name clears it, and the group falls back to listing its members.
#### Response 200 OK
The DM summary shape, from the caller's seat. Every participant also receives a
`dm_channel_open` carrying the new name.
#### Errors
| Status | Code | Reason |
| ------ | ------------- | ----------------------------------------------------------- |
| 400 | `BAD_REQUEST` | The channel is a 1:1 DM, or the name exceeds 100 characters |
| 404 | `NOT_FOUND` | Not a participant of this DM |
2026-03-30 22:31:06 +02:00
---
### DELETE /api/v1/dms/{channelId}
2026-08-01 22:06:14 +02:00
Remove a DM from the caller's sidebar. What that means depends on the kind of DM,
and the route is shared because the _gesture_ is shared:
2026-03-30 22:31:06 +02:00
2026-08-01 22:06:14 +02:00
- **1:1 DM** — a hide. The channel and messages remain, the caller remains a
participant, and a new message from either side re-opens it.
- **Group DM** — a **leave** . The caller comes out of `dm_participants` , stops
receiving the group's messages, and cannot return unaided. When the last
participant leaves, the channel row is deleted (a DM nobody is in is reachable
by nobody, and its messages cascade off the channel).
The caller receives `dm_channel_close` ; after a group leave the remaining
participants receive a fresh `dm_channel_open` with the new membership.
**Auth:** Required (participant)
2026-03-30 22:31:06 +02:00
#### Response 204 No Content
2026-08-01 22:06:14 +02:00
#### Errors
| Status | Code | Reason |
| ------ | ----------- | ---------------------------- |
| 404 | `NOT_FOUND` | Not a participant of this DM |
2026-03-30 22:31:06 +02:00
---
2026-07-19 13:51:44 +00:00
## User Blocks
Blocking a user prevents DM creation and messaging in both directions
(backed by the `user_blocks` table).
### GET /api/v1/blocks
List the IDs of users the authenticated user has blocked.
**Auth:** Required
#### Response 200 OK
```json
{ "blocked_user_ids" : [ 2 , 7 ] }
```
---
### PUT /api/v1/blocks/{userId}
Block a user.
**Auth:** Required
#### Response 200 OK
```json
{ "message" : "user blocked" }
```
---
### DELETE /api/v1/blocks/{userId}
Unblock a user.
**Auth:** Required
---
2026-03-30 22:31:06 +02:00
## Invite Endpoints
All invite endpoints require authentication and the `MANAGE_INVITES` permission.
### POST /api/v1/invites
Create a new invite code.
**Auth:** Required
**Permission:** `MANAGE_INVITES`
#### Request
```json
{
"max_uses" : 5 ,
"expires_in_hours" : 48
}
```
Both fields are optional. An empty body creates an invite with unlimited uses and no expiry.
#### Response 201 Created
```json
{
"id" : 1 ,
"code" : "abc123def" ,
"max_uses" : 5 ,
"uses" : 0 ,
"expires_at" : "2026-03-30T10:30:00Z" ,
"revoked" : false ,
"created_at" : "2026-03-28T10:30:00Z"
}
```
---
### GET /api/v1/invites
List all invites (active, expired, and revoked).
**Auth:** Required
**Permission:** `MANAGE_INVITES`
#### Response 200 OK
Returns a JSON array of invite objects.
---
### DELETE /api/v1/invites/{code}
Revoke an invite by its code string.
**Auth:** Required
**Permission:** `MANAGE_INVITES`
#### Response 204 No Content
---
## File Upload and Serving
### POST /api/v1/uploads
Upload a file as multipart form data.
**Auth:** Required
2026-07-19 13:51:44 +00:00
**Rate limit:** 10 requests/minute
2026-03-30 22:31:06 +02:00
**Body size limit:** 100 MiB
**Content-Type:** `multipart/form-data`
Files are validated against blocked magic bytes (PE executables, ELF binaries, Mach-O binaries, shell scripts). Files are stored with UUID filenames.
#### Response 201 Created
```json
{
"id" : "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ,
"filename" : "photo.jpg" ,
"size" : 204800 ,
"mime" : "image/jpeg" ,
"url" : "/api/v1/files/a1b2c3d4-e5f6-7890-abcd-ef1234567890" ,
"width" : 1920 ,
"height" : 1080
}
```
`width` and `height` are only present for image files.
---
### GET /api/v1/files/{id}
Serve a previously uploaded file by its UUID.
2026-07-19 13:51:44 +00:00
**Auth:** Required (Bearer token) — downloads are access-controlled
**Caching:** `Cache-Control: private, no-cache` (never stored by shared/proxy caches; browsers must revalidate)
2026-03-30 22:31:06 +02:00
2026-07-19 13:51:44 +00:00
Supports HTTP range requests and conditional requests. MIME types that could
execute under the app origin (HTML, SVG, XML, PDF) are served with
`Content-Disposition: attachment` to force download.
2026-03-30 22:31:06 +02:00
---
2026-08-01 22:06:14 +02:00
## Custom Emoji
Server-wide custom emoji, usable as `:shortcode:` in message content and as
reaction strings.
**Permission model.** Reading the set is open to any authenticated member —
an emoji nobody can render is not an emoji, and the set is server-wide with no
per-channel scope to leak. Adding and removing require **MANAGE_SERVER** .
That is a deliberate reuse rather than a new permission bit: a bit is a
schema-visible, forever decision, and "who may change server-wide branding" is
exactly what MANAGE_SERVER already answers for the server name, icon and
settings. There is no `MANAGE_EMOJI` .
### GET /api/v1/emoji
List every custom emoji, ordered by shortcode.
**Auth:** Required
#### Response 200 OK
```json
[
{ "id" : 3 , "shortcode" : "wave" , "url" : "/api/v1/emoji/3/image" },
{ "id" : 7 , "shortcode" : "party_blob" , "url" : "/api/v1/emoji/7/image" }
]
```
`url` is server-relative and authenticated — see GET /api/v1/emoji/{id}/image.
---
### POST /api/v1/emoji
Upload one custom emoji as multipart form data.
**Auth:** Required — **MANAGE_SERVER**
**Rate limit:** 10 requests/minute per user
**Body size limit:** 1 MiB (the image itself is capped at 512 KiB)
**Content-Type:** `multipart/form-data`
| Field | Type | Notes |
| ----------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `shortcode` | string | `[a-z0-9_]{2,32}` ; surrounding colons are stripped and the value is lowercased before validation, so `:WAVE:` and `wave` are the same shortcode |
| `file` | file | PNG, JPEG, GIF or WebP |
Validation, in the order it is applied — the permission check runs before the
multipart body is read, so a member without the bit never causes a spool to
disk:
1. MANAGE_SERVER, then the rate limit, then the shortcode format.
2. At most **512 KiB** of image bytes.
3. The MIME type is **sniffed from the file's own bytes** , never taken from the
client's part header. Only `image/png` , `image/jpeg` , `image/gif` and
`image/webp` are accepted. SVG is refused outright: it is markup with script
and external-fetch capability, and an emoji is by definition rendered inline.
4. Dimensions are re-read from the sniffed image (WebP headers are parsed
directly, since the standard library has no WebP decoder) and must be at
most **128 x 128** .
5. Shortcodes are unique case-insensitively; a collision is `409 CONFLICT` .
6. A server holds at most 200 emoji.
On success the full set is broadcast as `emoji_update` (see protocol.md), so
every connected client converges without a reconnect.
#### Response 201 Created
```json
{ "id" : 3 , "shortcode" : "wave" , "url" : "/api/v1/emoji/3/image" }
```
#### Errors
| Status | Code | Cause |
| ------ | -------------- | ----------------------------------------------- |
| 400 | `BAD_REQUEST` | bad shortcode, wrong format, too large, too big |
| 403 | `FORBIDDEN` | caller lacks MANAGE_SERVER |
| 409 | `CONFLICT` | an emoji with that shortcode already exists |
| 429 | `RATE_LIMITED` | upload rate limit exceeded |
---
### GET /api/v1/emoji/{id}/image
Serve one emoji's image bytes.
**Auth:** Required (Bearer token)
**Caching:** `Cache-Control: private, max-age=86400, immutable`
Authenticated rather than public so an emoji cannot be used as an
unauthenticated tracking pixel hosted on someone else's server. There is no
per-channel ACL to apply — emoji are server-wide by construction, so
authentication is the whole check. An emoji's bytes never change for a given
id (a replacement is a new row), which is what lets the response be cached
hard. Unknown ids answer 404.
---
### DELETE /api/v1/emoji/{id}
Delete one custom emoji and unlink its stored file.
**Auth:** Required — **MANAGE_SERVER**
Messages and reactions that used the shortcode fall back to rendering the
literal `:shortcode:` text. Broadcasts `emoji_update` on success.
#### Response 204 No Content
#### Errors
| Status | Code | Cause |
| ------ | ------------- | ---------------------------- |
| 400 | `BAD_REQUEST` | id is not a positive integer |
| 403 | `FORBIDDEN` | caller lacks MANAGE_SERVER |
| 404 | `NOT_FOUND` | no emoji with that id |
---
2026-03-30 22:31:06 +02:00
## Health Check
### GET /health
### GET /api/v1/health
2026-07-19 13:51:44 +00:00
Public health check endpoint, no authentication required. The server version
is deliberately not exposed here (anti-fingerprinting hardening, C-2).
2026-03-30 22:31:06 +02:00
```json
{
"status" : "ok" ,
"uptime" : 86400 ,
"online_users" : 3
}
```
---
## Server Info
### GET /api/v1/info
2026-07-19 13:51:44 +00:00
Returns the server name. The version field was removed from this
unauthenticated endpoint (anti-fingerprinting hardening, C-2).
2026-03-30 22:31:06 +02:00
**Auth:** None
```json
{
2026-07-19 13:51:44 +00:00
"name" : "My OwnCord Server"
2026-03-30 22:31:06 +02:00
}
```
---
## Metrics
### GET /api/v1/metrics
Runtime server metrics. Restricted to admin-allowed CIDRs.
**Auth:** Admin IP restriction (not token-based)
```json
{
"uptime" : "2h30m15s" ,
"uptime_seconds" : 9015.0 ,
"goroutines" : 42 ,
"heap_alloc_mb" : 12.5 ,
"heap_sys_mb" : 24.0 ,
"num_gc" : 156 ,
"connected_users" : 8 ,
"livekit_healthy" : true
}
```
---
2026-08-01 22:06:14 +02:00
## Admin API Authorization
The admin panel API lives under `/admin/api` (not `/api/v1` ) and takes the same
`Authorization: Bearer {token}` header — a login session or an API token, which
inherits its owning user's role.
Authorization is two-layered:
1. **Perimeter.** The request is rejected with `403 FORBIDDEN` unless the
principal's role holds at least one bit of `permissions.AdminPerimeter`
(`ADMINISTRATOR` , `MANAGE_CHANNELS` , `MANAGE_ROLES` , `MANAGE_SERVER` ,
`VIEW_AUDIT_LOG` , `KICK_MEMBERS` , `BAN_MEMBERS` , `MUTE_MEMBERS` ). Banned
users are rejected here even while their session is still valid.
2. **Per-route bit.** Route groups then require the specific permission below.
`ADMINISTRATOR` bypasses every one of them; owner-only routes gate on role
*position* (`>= 100` ) instead of on a bit, so not even `ADMINISTRATOR`
substitutes for being the owner.
| Route | Requires |
| ----- | -------- |
| `GET /admin/api/me` | perimeter only |
| `GET /admin/api/stats` | perimeter only |
| `GET /admin/api/users` | perimeter only |
| `PATCH /admin/api/users/{id}` | perimeter; `BAN_MEMBERS` for `banned` , `MANAGE_ROLES` for `role_id` (checked in the service) |
| `DELETE /admin/api/users/{id}/sessions` | `KICK_MEMBERS` |
| `GET/POST/PATCH/DELETE /admin/api/channels…` (incl. `/permissions` and `/user-permissions` ) | `MANAGE_CHANNELS` |
| `GET/POST/PATCH/DELETE /admin/api/roles…` (incl. `/roles/reorder` ) | `MANAGE_ROLES` |
| `GET /admin/api/audit-log` | `VIEW_AUDIT_LOG` |
| `GET/PATCH /admin/api/settings` | `MANAGE_SERVER` |
| `POST /admin/api/logs/ticket` , `GET /admin/api/logs/stream` | `ADMINISTRATOR` |
| `/api/v1/admin/plugins…` | `ADMINISTRATOR` |
| `/admin/api/tokens…` , `/admin/api/backup(s)…` , `/admin/api/updates…` | Owner role (position 100) |
Moderation routes additionally enforce the **role hierarchy** : the actor must
strictly outrank the target (`actor.position > target.position` ), and a role
assignment may only grant a role positioned strictly below the actor's own —
so an admin cannot promote anyone to Owner, and a moderator cannot demote an
admin. Violations return `403 FORBIDDEN` .
### GET /admin/api/me
Describes the calling principal so a panel can hide what the role cannot use.
Every route still re-checks its bit server-side.
#### Response 200 OK
```json
{
"id" : 7 ,
"username" : "mod" ,
"role_id" : 3 ,
"role_name" : "Moderator" ,
"role_position" : 60 ,
"permissions" : 1048575 ,
"is_owner" : false
}
```
---
## Role Management
Create, edit, delete and reorder roles. The whole group requires
`MANAGE_ROLES` ; `RoleService` then enforces the hierarchy rules below, so a
principal that clears the bit still cannot escalate through it.
**Rules, all measured against the *actor's* role position:**
- You may only create, edit, delete or reorder roles positioned **strictly
below** your own. Equal rank is refused too, so a role cannot rewrite itself.
Nothing sits above position 100, which makes the seeded Owner role
immutable and undeletable for everyone, owner included.
- You may never **grant** a permission bit your own role lacks. Removing one is
allowed — de-escalation is always safe. `ADMINISTRATOR` bypasses this check
entirely (it is what lets the owner hand out anything).
- The default role (`is_default = 1` ) cannot be deleted: every member falls
back to it.
- Deleting a role moves its members onto the default role in one `UPDATE` ,
drops the role's `channel_overrides` rows, invalidates the moved members'
cached permissions, and broadcasts a `member_update` per member.
- Names are unique **case-insensitively** (migration `023` ), matching the
case-insensitive lookup the desktop client does. Max 32 characters.
- Colors are `#rgb` or `#rrggbb` , normalized to uppercase. `""` clears the
color. Anything else is `400` .
- Unknown permission bits are masked off rather than rejected.
- Every mutation writes an audit row (`role_create` , `role_update` ,
`role_delete` , `role_reorder` ) and broadcasts `roles_update` (see
`docs/protocol.md` ) carrying the full new list.
### GET /admin/api/roles
Roles ordered by position descending, each with its member count.
#### Response 200 OK
```json
[
{ "id" : 1 , "name" : "Owner" , "color" : "#E74C3C" , "permissions" : 2147483647 , "position" : 100 , "is_default" : false , "member_count" : 1 },
{ "id" : 4 , "name" : "Member" , "color" : null , "permissions" : 1635 , "position" : 40 , "is_default" : true , "member_count" : 12 }
]
```
### POST /admin/api/roles
#### Request
```json
{
"name" : "Helper" ,
"color" : "#5865F2" ,
"permissions" : 3 ,
"position" : 50
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | 1– 32 characters, unique case-insensitively |
| `color` | string | No | `#rgb` /`#rrggbb` , or `""` for none |
| `permissions` | integer | No | Bitfield; defaults to `0` |
| `position` | integer | No | Defaults to one below the actor's own position |
#### Response 201 Created
The created role (`id` , `name` , `color` , `permissions` , `position` ,
`is_default` — always `false` ; the default role is seeded, never created).
#### Errors
| Status | Code | When |
|--------|------|------|
| 400 | `BAD_REQUEST` | Missing/blank/over-long name, duplicate name, bad color, negative position |
| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES` , position at or above your own, or a permission bit you lack |
### PATCH /admin/api/roles/{id}
Partial update — every field is optional and an omitted one is left alone.
Same body and same errors as `POST` , plus `404 NOT_FOUND` for a missing role.
Editing a role at or above your own position is `403` .
A permission change additionally invalidates the cached permissions of that
role's members and re-syncs their channel visibility (the server sends targeted
`channel_create` /`channel_delete` ), because a role's mask is the base every
channel's effective permission derives from.
#### Response 200 OK
The updated role.
### DELETE /admin/api/roles/{id}
#### Response 204 No Content
#### Errors
| Status | Code | When |
|--------|------|------|
| 400 | `BAD_REQUEST` | The role is the default role, or is the seeded Owner role |
| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES` , or the role is at or above your own position |
| 404 | `NOT_FOUND` | No such role |
### PATCH /admin/api/roles/reorder
#### Request
```json
{ "role_ids" : [ 2 , 9 , 3 , 4 ] }
```
`role_ids` is highest-rank-first and must name **exactly** the set of roles
strictly below your own position — a partial list is refused rather than
silently leaving the omitted roles at positions that now collide. Positions are
normalized to `N…1` , so they stay unique, stay below the actor, and never
collide with the untouched roles above.
#### Response 200 OK
The full role list after the reorder, position descending.
#### Errors
| Status | Code | When |
|--------|------|------|
| 400 | `BAD_REQUEST` | Wrong number of ids, or a duplicate id |
| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES` , or an id that is unknown or not below your rank |
---
## Channel Management (admin)
`POST /admin/api/channels` takes `{name, type, category, topic, position}` ;
`PATCH /admin/api/channels/{id}` takes `{name, topic, category, slow_mode,
position, archived, nsfw, voice_max_users, voice_max_video}` and seeds every
omitted field from the current row, so a partial body is safe.
The numeric fields are bounds-checked before anything is written, and an
out-of-range value is refused with `400 INVALID_INPUT` rather than clamped —
a caller that sent `-1` meant something, and storing `0` would hide it. A
refused body writes nothing at all:
| Field | Range | Meaning |
|-------|-------|---------|
| `slow_mode` | 0…21600 | Cooldown in seconds; 0 = off (6-hour ceiling, as Discord) |
| `voice_max_users` | 0…99 | Voice capacity; 0 = unlimited |
| `voice_max_video` | 0…99 | Simultaneous cameras/screen shares; 0 = unlimited |
`nsfw` is a bool and is stored, broadcast and audited only — the server applies
no content behaviour to a flagged channel (see `GET /api/v1/channels` ). The
audit detail names the transition: `updated #foo (marked NSFW)` /
`(unmarked NSFW)` , and plain `updated #foo` when the flag did not move.
The voice limits are stored on a channel of any type but are only meaningful on
a voice one; the desktop client offers them for voice channels alone and omits
the keys entirely elsewhere, so a text-channel edit cannot wipe limits the row
happens to hold.
`type` must be `text` , `voice` or `announcement` (`400 INVALID_INPUT`
otherwise). ** `category` constrains nothing.** Categories are free text and a
channel of any type may live under any of them — a voice channel under
"Gaming", a text channel under "Voice Channels". Grouping is a display concern:
the desktop client groups by whatever category a channel carries and falls back
to a synthetic "Voice" group only for voice channels with no category at all.
(Before phase 5 the server refused any non-voice channel under a category
literally named "Voice Channels", and any voice channel outside it.)
`PATCH` accepts `category` , so moving a channel between categories is an edit
rather than a delete-and-recreate. An empty string makes it uncategorized.
---
## Channel Permission Overrides
Two override layers per channel, both gated on `MANAGE_CHANNELS` and both
audit-logged. They resolve in Discord's order:
```
base role permissions -> role override -> user override
```
The later, narrower layer wins: a **user** deny beats a **role** allow, a user
allow beats a role deny, and within one layer allow beats deny. `ADMINISTRATOR`
bypasses both layers entirely. See `docs/schema.md` ("Permission Checking
Logic") for the formula and `permissions.EffectiveChannelPerms` for the single
implementation.
Denying `READ_MESSAGES` hides the channel outright — from the WS `ready`
payload, from `GET /api/v1/channels` , from reconnect replay and from live
broadcasts. Every write below invalidates the affected permission cache entries
and then re-syncs connected clients with targeted `channel_create` /
`channel_delete` messages, so sidebars converge without a reconnect.
DM channels have no override surface: `400 INVALID_INPUT` .
### GET /admin/api/channels/{id}/permissions
Both layers for one channel. `roles` lists **every** role (zero masks when it
carries no override) so the panel can render a complete grid; `users` lists
**only** members who actually have an override row.
#### Response 200 OK
```json
{
"channel_id" : 4 ,
"roles" : [
{ "role_id" : 1 , "role_name" : "Owner" , "position" : 100 , "permissions" : 2147483647 , "allow" : 0 , "deny" : 0 },
{ "role_id" : 4 , "role_name" : "Member" , "position" : 40 , "permissions" : 1635 , "allow" : 0 , "deny" : 514 }
],
"users" : [
{ "user_id" : 12 , "username" : "alice" , "role_id" : 4 , "allow" : 2 , "deny" : 0 }
]
}
```
### PUT /admin/api/channels/{id}/permissions/{roleId}
### PUT /admin/api/channels/{id}/user-permissions/{userId}
Write one override row. Same body for both layers:
```json
{ "allow" : 2 , "deny" : 1 }
```
| Field | Type | Description |
|-------|------|-------------|
| `allow` | integer | Bits granted in this channel |
| `deny` | integer | Bits refused in this channel |
Bits outside `permissions.AllPerms` are masked off rather than rejected, so an
unknown bit can never be persisted. A row with both masks `0` is meaningless —
the admin panel sends `DELETE` for that case instead.
#### Response 200 OK
The stored row: `{role_id, role_name, position, permissions, allow, deny}` for
the role layer, `{user_id, username, role_id, allow, deny}` for the user layer.
#### Cache and fan-out
- Role layer: `InvalidateAll` (any member of that role is affected), then
`RefreshChannelVisibility` .
- User layer: `InvalidateUser(userId)` only — a per-user override cannot change
anyone else's verdict, and dropping the whole cache for one member would cost
every connected client a repopulate — then `RefreshChannelVisibility` , which
resolves visibility per user through the full order.
#### Audit
`channel_perms_update` / `channel_user_perms_update` , target `channel` .
#### Errors
| Status | Code | When |
|--------|------|------|
| 400 | `BAD_REQUEST` | Unparseable id or body |
| 400 | `INVALID_INPUT` | The channel is a DM |
| 403 | `FORBIDDEN` | Missing `MANAGE_CHANNELS` |
| 404 | `NOT_FOUND` | Unknown channel, role or user |
### DELETE /admin/api/channels/{id}/permissions/{roleId}
### DELETE /admin/api/channels/{id}/user-permissions/{userId}
Clear the override row, returning the target to the layer above it. `204 No
Content` ; deleting a row that does not exist is a no-op, not a `404` . Same
cache/fan-out behavior as the writes; audits as `channel_perms_clear` /
`channel_user_perms_clear` .
---
2026-07-19 13:51:44 +00:00
## Plugin Administration
Manage WASM plugins. These endpoints sit behind **both** the admin IP
2026-08-01 22:06:14 +02:00
restriction (allowed CIDRs) **and** admin bearer-token authentication, and
require the `ADMINISTRATOR` bit specifically (the widened admin perimeter does
not open them).
2026-07-19 13:51:44 +00:00
Plugin execution additionally requires a server built with `-tags wazero`
and `plugins.enabled: true` in config.
### GET /api/v1/admin/plugins
List installed plugins.
#### Response 200 OK
Array of plugin rows: `ID` , `Name` , `Version` , `Enabled` , `ManifestJSON` ,
`InstalledAt` .
### POST /api/v1/admin/plugins/install
Install a plugin from an uploaded zip (multipart form). The archive is
size-capped (16 MiB compressed / 64 MiB uncompressed) and hardened against
zip-slip and symlinks; installation is staged and atomically renamed.
#### Response 201 Created
```json
{ "name" : "plugin-name" }
```
### POST /api/v1/admin/plugins/{id}/enable
### POST /api/v1/admin/plugins/{id}/disable
Enable or disable an installed plugin.
### DELETE /api/v1/admin/plugins/{id}
Uninstall a plugin.
---
2026-03-30 22:31:06 +02:00
## LiveKit Endpoints
These endpoints are only registered when LiveKit voice is configured.
### POST /api/v1/livekit/webhook
LiveKit webhook receiver. Uses LiveKit JWT verification. Admin-IP-restricted. Called by the LiveKit server, not by clients.
### GET /api/v1/livekit/health
Check whether the LiveKit server is reachable.
**Auth:** Admin IP restriction
#### Response 200 OK
```json
{
"status" : "ok" ,
"livekit_reachable" : true
}
```
#### Response 503 Service Unavailable
```json
{
"status" : "degraded" ,
"livekit_reachable" : false ,
"error" : "connection refused"
}
```
### /livekit/* (Reverse Proxy)
All requests to `/livekit/*` are reverse-proxied to the LiveKit server URL. The `/livekit` prefix is stripped before forwarding. This allows the client to connect to LiveKit through OwnCord's HTTPS server, avoiding mixed-content blocks.
**Auth:** None (LiveKit handles its own JWT-based auth)
**Rate limit:** 30 requests/minute per IP
---
## Diagnostics
### GET /api/v1/diagnostics/connectivity
Returns connectivity diagnostics for debugging voice/network issues.
**Auth:** Required (any authenticated user)
**Rate limit:** 5 requests/minute per user
```json
{
"server" : {
"version" : "1.0.0" ,
"uptime_s" : 3600 ,
"go_version" : "go1.23.0" ,
"online_users" : 5
},
"voice" : {
"enabled" : true ,
"livekit_url" : "ws://localhost:7880" ,
"livekit_health" : true ,
"node_ip" : "203.0.113.1" ,
"proxy_path" : "/livekit"
},
"client" : {
"remote_addr" : "192.168.1.100" ,
"is_private_network" : true
}
}
```
---
## Client Auto-Update
### GET /api/v1/client-update/{target}/{current_version}
Tauri-compatible update endpoint. The desktop client checks this to see if a newer version is available.
**Auth:** None
#### Path Parameters
| Param | Type | Description |
| ----- | ---- | ----------- |
2026-07-23 18:38:22 +02:00
| `target` | string | Tauri updater target `{os}-{arch}-{installer}` (e.g., `windows-x86_64-nsis` , `linux-x86_64-appimage` , `linux-aarch64-appimage` ). Selects the platform's updater artifact and is echoed back as the `platforms` key. Targets without a published updater artifact (e.g., `linux-x86_64-deb` ) get 204. |
2026-03-30 22:31:06 +02:00
| `current_version` | string | Client's current semver version (e.g., `1.0.0` ) |
#### Response 200 OK (update available)
```json
{
"version" : "1.2.0" ,
"notes" : "## What's Changed\n..." ,
"pub_date" : "2026-03-28T00:00:00Z" ,
"platforms" : {
2026-07-23 18:38:22 +02:00
"windows-x86_64-nsis" : {
2026-03-30 22:31:06 +02:00
"signature" : "base64-encoded-signature" ,
"url" : "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/OwnCord_1.2.0_x64-setup.nsis.zip"
}
}
}
```
#### Response 204 No Content
2026-07-23 18:38:22 +02:00
Client is already up-to-date, or no client build is published for `target` .
2026-03-30 22:31:06 +02:00
---
## WebSocket
### GET /api/v1/ws
WebSocket upgrade endpoint. Authentication is performed in-band (first message must be an `auth` frame with the session token). See [protocol.md ](protocol.md ) for the full WebSocket message protocol.