API Documentation

Complete reference for the Open Relay Portal REST API and WebSocket interfaces.

Introduction

Open Relay Portal provides a unified API for managing remote access to your infrastructure. All API endpoints return JSON responses and require authentication unless otherwise noted.

Base URL: All API endpoints are relative to your Open Relay Portal installation (e.g., https://portal.example.com)

Response Format

All successful responses return JSON. Error responses include an error field:

{
  "error": "Unauthorized",
  "status": 401
}

Authentication

Open Relay Portal supports the following authentication methods:

  • JWT Tokens - Session-based authentication via cookies (used by the web UI)
  • API Keys - Long-lived keys for programmatic access (prefixed portal_)
  • RTMP Tokens - Temporary, single-use publish tokens for plain RTMP streaming (prefixed rtmp_). Generated via API or the My Streams UI. Expire after 15 minutes with a 30-second grace period for reconnects.

Using API Keys

Include your API key in the Authorization header:

Authorization: Bearer YOUR_API_KEY

Or pass it as a query parameter (not recommended for sensitive operations):

GET /api/services?token=YOUR_API_KEY
POST /api/token Create session token (login)

Authenticate with username and password to receive a JWT token.

Request Body

ParameterTypeDescription
username string Your username required
password string Your password required
totp_code string 6-digit 2FA code (if 2FA enabled)

Example

curl -X POST https://portal.example.com/api/token \
  -H "Content-Type: application/json" \
  -d '{"username": "user", "password": "pass"}'

Response

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "expires_at": "2024-01-15T12:00:00Z",
  "user": {
    "id": 1,
    "username": "user",
    "is_admin": false
  }
}

API Keys

API keys provide long-lived authentication for scripts and integrations. Keys can be scoped to limit access.

POST /api/api-keys Create new API key

Request Body

ParameterTypeDescription
name string Descriptive name for the key required
scopes string Comma-separated scopes (default: "*" for full access)
expires_in_days integer Days until expiration (null for never)
Important: The full API key is only shown once upon creation. Store it securely.

Response

{
  "id": 1,
  "name": "My Integration",
  "api_key": "portal_abc123...",
  "key_prefix": "portal_abc",
  "scopes": "*",
  "expires_at": null
}
GET /api/api-keys List your API keys

Returns all API keys for the authenticated user. Keys are shown with prefix only (not full key).

POST /api/api-keys/{id}/revoke Revoke an API key

Revokes the API key. The key will no longer work for authentication but remains in your key list.

DELETE /api/api-keys/{id} Delete an API key

Permanently delete an API key from your account.

Token Management

GET /api/tokens List active tokens

Returns all active JWT tokens for the authenticated user.

POST /api/token/revoke Revoke a token

Request Body

ParameterTypeDescription
token_id string Token ID to revoke required

Registration

POST /api/register Register a new account

Register a new account using an invite code. Rate limited to 1 account per IP per 24 hours.

Request Body

ParameterTypeDescription
username string Desired username required
password string Password (min 8 chars) required
invite_code string Daily invite code required

Response

{
  "id": 5,
  "username": "newuser",
  "message": "Registration successful"
}

Public Stats

Public statistics available to all authenticated users.

GET /api/stats/public Get public statistics

Returns public metrics visible to all authenticated users, including live stream counts and online user counts.

Response

{
  "live_streams": 3,
  "online_users": 12
}

Fields

  • live_streams - Number of currently live public streams
  • online_users - Number of unique users currently in chat

Services

Services are shared infrastructure endpoints configured by administrators. Users can list and connect to services they have access to.

GET /api/services List available services

Returns all services the authenticated user has access to based on their scopes.

Response

{
  "services": [
    {
      "id": 1,
      "name": "Home Server",
      "plugin": "ssh",
      "path": "/homeserver",
      "icon": "terminal",
      "category": "Servers",
      "enabled": true
    }
  ]
}

Try It

GET /api/services/{id} Get service details

Returns detailed information about a specific service.

GET /api/services/{id}/health Check service health

Checks if the backend service is reachable and responding.

Response

{
  "healthy": true,
  "latency_ms": 45
}

User Connections

User Connections are personal remote access points that you configure for your own use. Unlike Services (admin-configured), connections are private to each user.

Connection IDs: Connection IDs are opaque URL-safe tokens (e.g., aB3x_kLm9pQ2rStU), not sequential integers. Use the id field returned by the API for all operations.
Security: User connections cannot target localhost or local network addresses (127.x.x.x, ::1, etc.) for security reasons.
GET /api/connections/types List connection types

Returns available connection types with their plugin schemas for form generation.

Response

{
  "types": {
    "ssh": {
      "name": "SSH Terminal",
      "icon": "terminal",
      "default_port": 22,
      "plugin": "ssh",
      "config_schema": {...}
    },
    "vnc": {...},
    "rdp": {...}
  }
}
GET /api/connections List your connections

Returns all connections for the authenticated user.

Try It

POST /api/connections Create connection

Request Body

ParameterTypeDescription
name string Display name required
type string Connection type (ssh, vnc, rdp, etc.) required
host string Remote host address required
port integer Port number (uses default if omitted)
config object Type-specific configuration (username, auth_method, etc.)

Example

curl -X POST https://portal.example.com/api/connections \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Home Server",
    "type": "ssh",
    "host": "192.168.1.100",
    "port": 22,
    "config": {
      "username": "admin",
      "auth_method": "password"
    }
  }'
GET /api/connections/{id} Get connection details

Returns details for a specific connection. Sensitive config fields (passwords, private keys) are redacted and replaced with has_<field> boolean flags.

PUT /api/connections/{id} Update connection

Update a connection. Config fields are merged server-side with the existing config — sensitive fields not included in the request are preserved from the encrypted database record.

Request Body

ParameterTypeDescription
name string Updated display name
host string Updated host address
port integer Updated port number
config object Updated type-specific configuration
DELETE /api/connections/{id} Delete connection

Permanently delete a connection.

POST /api/connections/{id}/pin Toggle connection pinned status

Toggle whether a connection is pinned. Pinned connections sort to the top of the connections list for quick access.

Response

{
  "is_pinned": true
}
GET /api/connections/{id}/connect Get connection info for launching

Returns connection details and WebSocket URL needed to establish a connection. The dashboard uses this to open the appropriate viewer window.

Response

{
  "connection": {
    "id": 1,
    "type": "ssh",
    "name": "My Server",
    "host": "10.0.0.5",
    "port": 22,
    "config": {}
  },
  "ssh_public_key": "ssh-ed25519 AAAA... (if SSH key is set)",
  "websocket_url": "/ws/user-connection/1"
}

Connection Type Routing

TypeViewerURL Pattern
ssh, terminalTerminal (xterm.js)/terminal/connect?connection={id}
vnc, rdpVNC (noVNC)/vnc/connect?connection={id}
spiceSPICE Console/spice/connect?connection={id}
proxmoxProxmox VE/proxmox/connect?connection={id}
http, https, http_proxyEmbedded Browser/browser?connection={id}
mediamtx, streamMedia Player/media/connect?connection={id}
database, redis, tcp_tunnel, etc.Info ToastShows host:port (no web viewer)

All viewer pages connect via wss://portal/ws/user-connection/{id} which routes through the appropriate plugin based on the connection type.

User Streams

Stream live video using RTMPS, RTSPS, or WebRTC. Streams can be public (visible to all users) or private. Each stream gets an integrated chat channel for viewer interaction.

Architecture: Publishing uses a dedicated subdomain (stream.example.com) for direct RTMPS access with Let's Encrypt TLS. Playback is proxied through Portal on port 443 via Cloudflare.

Streaming Configuration

PurposeHostURL Format
RTMPS Publishing stream.example.com:1936 rtmps://stream.example.com:1936/live
HLS Playback portal.example.com:443 https://portal.example.com/api/stream/{stream_key}/hls/index.m3u8
WebRTC Playback portal.example.com:443 https://portal.example.com/api/stream/{stream_key}/webrtc/whep
WebRTC Publishing portal.example.com:443 https://portal.example.com/api/stream/{stream_key}/webrtc/whip
RTMP Publishing stream.example.com:1935 rtmp://stream.example.com:1935/live

OBS Studio Setup (Recommended)

To broadcast with OBS Studio using RTMPS (encrypted, recommended):

  1. Go to Settings → Stream
  2. Set Service to Custom
  3. Server: rtmps://stream.example.com:1936/live
  4. Stream Key: Your stream key (e.g., live_abc123...)
  5. Click OK and Start Streaming

Plain RTMP (Alternative)

If your encoder does not support RTMPS, you can use standard RTMP with a temporary token. This requires enabling RTMP on your stream first.

  1. Enable RTMP on your stream (My Streams tab or PUT /api/streams/{id} with rtmp_enabled: true)
  2. Generate a temporary RTMP token via the My Streams tab or POST /api/streams/{id}/rtmp-token
  3. In OBS, go to Settings → Stream
  4. Set Service to Custom
  5. Server: rtmp://stream.example.com:1935/live
  6. Stream Key: Your temporary token (e.g., rtmp_abc123...)
  7. Click OK and Start Streaming
Note: RTMP tokens are single-use, expire in 15 minutes, and have a 30-second grace period for reconnects. The global RTMP_PLAIN_ENABLED setting must also be enabled by the server administrator.

Encoder Settings

Configure your encoder in Settings → Output → Streaming (Output Mode: Advanced).

NVIDIA NVENC (Recommended for NVIDIA GPUs)

Hardware encoding with NVENC provides excellent quality at low CPU usage.

SettingRecommended ValueNotes
Encoder NVIDIA NVENC H.264 Or NVENC HEVC for better compression (requires viewer support)
Rate Control CBR Constant bitrate for stable streaming
Bitrate 4500-6000 kbps Adjust based on your upload speed
Keyframe Interval 2 Required for HLS segmentation
Preset Quality or Max Quality P5-P7 on newer drivers
Profile high Best compatibility
Look-ahead Enabled Improves quality at cost of slight latency
Psycho Visual Tuning Enabled Better perceived quality
B-frames 2 Good balance of quality and latency

AMD AMF (AMD GPUs)

SettingRecommended Value
Encoder AMD HW H.264 (AVC)
Rate Control CBR
Bitrate 4500-6000 kbps
Keyframe Interval 2
Quality Preset Quality

x264 (CPU Encoding)

SettingRecommended Value
Encoder x264
Rate Control CBR
Bitrate 4500-6000 kbps
Keyframe Interval 2
CPU Usage Preset veryfast to medium
Profile high
Tune zerolatency (optional)

Video Settings (Settings → Video)

SettingRecommended Value
Output Resolution 1920x1080 or 1280x720
FPS 30 or 60
Tip: NVENC and AMF hardware encoders allow streaming at high quality while using minimal CPU. Use x264 only if you don't have a dedicated GPU or need maximum quality at the cost of CPU usage.

REST API

GET /api/streams List your streams

Returns all streams owned by the authenticated user.

Response

{
  "streams": [
    {
      "id": 1,
      "title": "My Live Stream",
      "stream_key": "sk_abc123...",
      "is_live": 0,  // 0=offline, 1=live, 2=encoding
      "is_public": true,
      "rtmp_enabled": false,
      "viewer_count": 0,
      "chat_channel_id": 5,
      "created_at": "2024-01-15T10:00:00Z"
    }
  ]
}

Try It

POST /api/streams Create a new stream

Creates a new stream with a unique stream key. An associated chat channel is automatically created.

Request Body

ParameterTypeDescription
title string Stream title required
description string Stream description
is_public boolean Whether stream is publicly visible (default: true)
rtmp_enabled boolean Enable plain RTMP publishing with temporary tokens (default: false)

Response

{
  "id": 1,
  "title": "My Stream",
  "stream_key": "sk_abc123def456...",
  "rtmp_enabled": false,
  "chat_channel_id": 5,
  "playback_urls": {
    "hls": "https://portal.example.com/api/stream/sk_abc123/hls/index.m3u8",
    "webrtc": "https://portal.example.com/api/stream/sk_abc123/webrtc/whep"
  }
}
Important: The stream key is only shown once. Store it securely for use with OBS or other broadcast software.
GET /api/streams/public List public streams

Returns all public streams (live, encoding, and offline). Used by the community streams page. The is_live field is an integer: 0=offline, 1=live, 2=encoding (VOD finalization in progress).

Try It

GET /api/streams/open List currently live public streams

Returns all public streams that are currently live. Includes viewer counts and playback URLs.

Try It

GET /api/streams/{id} Get stream details

Returns detailed information about a specific stream including playback URLs.

PUT /api/streams/{id} Update stream settings

Update title, description, or visibility of your stream.

Request Body

ParameterTypeDescription
title string New stream title
description string New description
is_public boolean Change visibility
rtmp_enabled boolean Enable or disable plain RTMP publishing with temporary tokens
POST /api/streams/{id}/regenerate-key Regenerate stream key

Generate a new stream key, invalidating the old one. Use this if your key is compromised.

POST /api/streams/{id}/rtmp-token Generate temporary RTMP publish token

Generate a temporary token for publishing via plain RTMP (unencrypted). The stream must have rtmp_enabled: true and the server must have RTMP_PLAIN_ENABLED set globally.

Prerequisites

  • Stream must have rtmp_enabled set to true
  • Global RTMP_PLAIN_ENABLED must be enabled by the server administrator
  • Authenticated as the stream owner (Bearer token)

Response

{
  "token": "rtmp_abc123...",
  "expires_in": 900,
  "rtmp_url": "rtmp://stream.example.com:1935/live"
}
Important: The token is shown only once and is never stored in plain text. It is single-use with a 30-second grace period for reconnects. Expires in 15 minutes if unused.

Internally, the token doubles as the MediaMTX publish path. Portal maps it back to the stream automatically, so all stream features (HLS playback, dynamic thumbnails, VOD recording) work seamlessly with plain RTMP.

DELETE /api/streams/{id} Delete stream

Permanently delete a stream and its associated chat channel.

POST /api/streams/{id}/thumbnail Upload stream thumbnail

Upload a custom thumbnail image (owner only). Multipart form data with field thumbnail. Max 2MB, JPEG/PNG.

DELETE /api/streams/{id}/thumbnail Delete stream thumbnail

Remove a custom thumbnail for a stream (owner only). The stream will revert to using a dynamic thumbnail when live.

GET /api/stream/{key}/thumbnail Get stream thumbnail

Returns a dynamic JPEG thumbnail captured from a live stream via ffmpeg. Cached for 15 seconds. Returns static thumbnail (or 404) if stream is offline or encoding. Accepts live_xxx or pub_xxx key.

Stream Moderation

Stream owners can ban users from their stream's chat channel.

GET /api/streams/{id}/bans List banned users

List users banned from this stream's chat (owner only).

POST /api/streams/{id}/bans Ban user from stream chat

Request Body

ParameterTypeDescription
user_id integer User to ban required
reason string Ban reason
DELETE /api/streams/{id}/bans/{user_id} Unban user from stream chat

Remove a ban, allowing the user to chat again (owner only).

Watching Streams

All stream traffic routes through Portal on port 443. Viewers can watch streams using HLS.js in browser or any HLS-compatible player:

import Hls from 'hls.js';

const video = document.getElementById('video');
const hls = new Hls();

// All playback through Portal on port 443
hls.loadSource('https://portal.example.com/api/stream/sk_abc123/hls/index.m3u8');
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
  video.play();
});

Publishing via WHIP

Publish streams through port 443 using WebRTC WHIP (OBS 30.0+):

// OBS Settings:
// Service: WHIP
// Server: https://portal.example.com/api/stream/{stream_key}/webrtc/whip
// Bearer Token: (leave empty - key is in URL)

Stream Relays

Relay your stream to external platforms (Twitch, YouTube, Kick, other Portal instances, or custom RTMP destinations). Relays start automatically when you go live and stop when your stream ends. Maximum 10 relay destinations per stream. Relay credentials are encrypted at rest.

GET /api/relay-platforms List supported relay platforms

Returns the list of supported relay platforms with their default RTMP ingest URLs.

{
  "platforms": [
    { "id": "twitch", "name": "Twitch", "default_url": "rtmp://live.twitch.tv/app" },
    { "id": "youtube", "name": "YouTube", "default_url": "rtmp://a.rtmp.youtube.com/live2" },
    { "id": "kick", "name": "Kick", "default_url": "rtmps://fa723fc1b171.global-contribute.live-video.net/app" },
    { "id": "portal", "name": "Portal", "default_url": "" },
    { "id": "custom", "name": "Custom", "default_url": "" }
  ]
}
GET /api/streams/{id}/relays List relay destinations

Returns all relay destinations configured for this stream (owner only). Stream keys are never returned; a has_stream_key boolean indicates whether one is set.

POST /api/streams/{id}/relays Add relay destination

Request Body

ParameterTypeDescription
platform string Platform ID from /api/relay-platforms required
name string Display name for this destination required
rtmp_url string RTMP ingest URL (must start with rtmp:// or rtmps://) required
stream_key string Stream key for the destination platform required
enabled boolean Whether to relay when live (default: true)
PUT /api/streams/{id}/relays/{relay_id} Update relay destination

Update a relay destination's name, URL, stream key, or enabled state (owner only). All fields are optional; only provided fields are updated.

DELETE /api/streams/{id}/relays/{relay_id} Delete relay destination

Remove a relay destination (owner only). If the relay is currently active, it will be stopped.

VOD Storage

Manage remote SFTP storage for recorded VODs (MKV files). Each user configures their own storage server. VODs are automatically recorded as 5-minute MKV chunks during live broadcasts and continuously uploaded to SFTP. When a stream ends, it enters an Encoding state (is_live=2) while the final chunk is written and all remaining chunks are offloaded, ensuring no VOD data is lost.

GET /api/vods/storage Get storage configuration

Returns the user's SFTP storage configuration. Passwords and private keys are redacted.

Response

{
  "storage": {
    "host": "storage.example.com",
    "port": 22,
    "username": "user",
    "auth_method": "password",
    "remote_path": "/home/user/vods",
    "has_password": true,
    "has_key": false
  }
}
POST /api/vods/storage Save storage configuration

Request Body

FieldTypeDescription
hoststringSFTP server hostname
portintegerSSH port (default: 22)
usernamestringSSH username
auth_methodstring"password" or "key"
passwordstringSSH password (if auth_method=password)
private_keystringSSH private key PEM (if auth_method=key)
remote_pathstringPath to VOD directory on remote server
DELETE /api/vods/storage Remove storage configuration

Removes the SFTP storage configuration. Does not delete any remote files.

POST /api/vods/storage/test Test SFTP connection

Tests the SFTP connection with the provided credentials and reports how many MKV files were found.

GET /api/vods List VOD files

Recursively lists MKV files in the user's remote storage directory. Files are organized by recording session: StreamName/YYYY-MM-DD_HH-MM-SS/chunk_NNN.mkv. Returns 404 if no storage is configured.

Query Parameters

ParameterTypeDescription
sortstringSort by: name, size, modified (default: modified)
orderstringasc or desc (default: desc)

Response

{
  "files": [
    {
      "name": "StreamName/2026-02-08_04-24-49/chunk_000.mkv",
      "size": 233436982,
      "modified": 1770525031
    }
  ],
  "path": "/home/user/vods"
}
GET /api/vods/download/{filename} Download a VOD file

Streams the specified MKV file from remote storage. Only .mkv files are allowed. Supports subdirectory paths. Path traversal is blocked.

POST /api/vods/download-archive Download multiple VODs as zip

Downloads multiple VOD files as a single zip archive streamed from SFTP. Maximum 500 files per request. All paths must end in .mkv.

Request Body

{
  "files": [
    "StreamName/2026-02-08_04-24-49/chunk_000.mkv",
    "StreamName/2026-02-08_04-24-49/chunk_001.mkv"
  ]
}

Response

Binary zip stream with Content-Disposition: attachment header.

DELETE /api/vods/{filename} Delete a VOD file

Deletes the specified MKV file from remote storage. Only .mkv files can be deleted. Supports subdirectory paths.

User Management

GET /api/me Get current user info

Returns information about the authenticated user.

Response

{
  "id": 1,
  "username": "myuser",
  "is_admin": false,
  "scopes": "access:*",
  "created_at": "2024-01-01T00:00:00Z",
  "totp_enabled": true
}

Try It

POST /api/me/password Change password

Request Body

ParameterTypeDescription
current_password string Current password required
new_password string New password (min 8 chars) required
PUT /api/me/status Update chat status

Request Body

ParameterTypeDescription
status string online, away, busy, dnd, or offline required
status_message string Custom status message (max 100 chars)
PUT /api/me/nickname Update display nickname

Request Body

ParameterTypeDescription
nickname string Display name (2-32 chars, alphanumeric/spaces/dashes). Empty to clear.
PUT /api/me/avatar Update avatar

Request Body

ParameterTypeDescription
color string Hex color (#RRGGBB)
emoji string Emoji character (null to clear)
initials string 1-2 character initials
PUT /api/me/anonymous Toggle anonymous mode

Toggle anonymous mode in chat. Messages sent while anonymous remain anonymous in history.

Request Body

ParameterTypeDescription
anonymous boolean Enable or disable anonymous mode required

SSH Keys

Generate and manage SSH key pairs for authentication. Private keys are generated on the server and returned only once - they are never stored.

POST /api/ssh-keys Generate SSH key pair

Request Body

ParameterTypeDescription
name string Key name required
key_type string "ed25519" (default) or "rsa"
Important: The private key is only returned once. Save it immediately.

Response

{
  "id": 1,
  "name": "my-key",
  "public_key": "ssh-ed25519 AAAA...",
  "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
  "fingerprint": "SHA256:..."
}
GET /api/ssh-keys List SSH keys

Returns all SSH keys for the authenticated user (public keys only).

GET /api/ssh-keys/{id} Get SSH key details

Returns details for a specific SSH key (public key only).

DELETE /api/ssh-keys/{id} Delete SSH key

Permanently delete an SSH key pair.

GET /api/ssh-keys/authorized Get authorized_keys format

Returns all your public keys in authorized_keys format for adding to remote servers.

Two-Factor Authentication

Enable TOTP-based two-factor authentication for enhanced security.

GET /api/user/2fa/status Get 2FA status

Response

{
  "enabled": false,
  "backup_codes_remaining": 0
}
POST /api/user/2fa/setup Start 2FA setup

Generates a TOTP secret and provisioning URI for authenticator apps.

Response

{
  "secret": "JBSWY3DPEHPK3PXP",
  "provisioning_uri": "otpauth://totp/Portal:user?secret=..."
}
POST /api/user/2fa/verify Enable 2FA

Verify a TOTP code to enable 2FA. Returns backup codes.

Request Body

ParameterTypeDescription
code string 6-digit TOTP code required
POST /api/user/2fa/disable Disable 2FA

Disable two-factor authentication. Requires a valid TOTP code to confirm.

Request Body

ParameterTypeDescription
code string 6-digit TOTP code required

WebSocket Service Relay

Open Relay Portal uses WebSocket connections to relay traffic to backend services. This enables real-time bidirectional communication for terminals, VNC, and other interactive protocols.

Connection URL Format

wss://portal.example.com/ws/{plugin}/{service_id}?token=YOUR_TOKEN

Supported Plugins

PluginDescriptionData Format
terminal Local PTY terminal JSON messages
ssh SSH over WebSocket JSON messages
vnc VNC relay (for noVNC) Binary frames
spice SPICE VM console Binary frames
tcp_tunnel Generic TCP tunnel Binary frames
secure_tunnel Multiplexed secure tunnel Binary frames

Terminal WebSocket Protocol

Terminal sessions use JSON messages for communication:

Client Messages

// Send input
{"type": "input", "data": "ls -la\n"}

// Resize terminal
{"type": "resize", "cols": 120, "rows": 40}

// Ping (keep-alive)
{"type": "ping"}

Server Messages

// Terminal output
{"type": "output", "data": "total 128\ndrwxr-xr-x..."}

// Pong response
{"type": "pong"}

// Error
{"type": "error", "message": "Connection failed"}

JavaScript Example

const ws = new WebSocket('wss://portal.example.com/ws/ssh/1?token=...');

ws.onopen = () => {
  // Send terminal size
  ws.send(JSON.stringify({
    type: 'resize',
    cols: terminal.cols,
    rows: terminal.rows
  }));
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'output') {
    terminal.write(msg.data);
  }
};

// Send user input
terminal.onData(data => {
  ws.send(JSON.stringify({type: 'input', data}));
});

VNC/RDP WebSocket Protocol

VNC connections relay raw binary RFB protocol frames:

wss://portal.example.com/ws/vnc/{service_id}?token=YOUR_TOKEN

Use with noVNC client library:

import RFB from '@novnc/novnc/core/rfb';

const rfb = new RFB(
  document.getElementById('vnc-container'),
  'wss://portal.example.com/ws/vnc/1?token=...'
);

rfb.scaleViewport = true;
rfb.resizeSession = true;

TCP Tunnel Protocol

Generic TCP tunneling for databases, Redis, and other TCP services:

wss://portal.example.com/ws/tunnel/{service_id}?token=YOUR_TOKEN

Data is sent as binary WebSocket frames, directly relayed to/from the TCP backend.

User Connection WebSocket

Connect to your personal connections via:

wss://portal.example.com/ws/user-connection/{connection_id}

Chat/Forum

Real-time multi-user chat with channels. Messages are encrypted at rest and automatically deleted after 7 days.

Security: All messages are encrypted using AES-256 (Fernet) before storage. Messages older than 7 days are automatically purged.

Display Name Priority

Chat display names follow this priority order:

  1. Anonymous — If anonymous mode is enabled, displayed as "Anonymous" with no avatar or nickname
  2. Nickname — If set via PUT /api/me/nickname
  3. Username — The account username (fallback)

Anonymous state is stored per-message, so messages sent while anonymous remain anonymous even after the user turns anonymous mode off.

Message Rate Limiting

Chat messages are rate limited to 5 messages per 5 seconds per user. If exceeded, the server responds with an error and the message is not sent.

REST API

GET /api/chat/channels List chat channels

Returns all available chat channels.

Response

{
  "channels": [
    {
      "id": 1,
      "name": "general",
      "description": "General discussion",
      "is_default": 1,
      "is_stream_channel": false
    }
  ]
}
POST /api/chat/channels Create channel

Request Body

ParameterTypeDescription
name string Channel name (lowercase, hyphens allowed) required
description string Channel description
POST /api/chat/upload Upload image for chat

Upload an image to embed in a chat message. Returns a URL to include as image_url in the WS message payload.

Request

Multipart form data with field name image. Max size: 5MB. Accepted types: JPEG, PNG, GIF, WebP.

Response

{
  "url": "/static/uploads/chat/abc123.jpg"
}
GET /api/chat/link-preview Fetch OpenGraph preview for a URL

Fetches OpenGraph metadata (title, description, image) for a given URL. Used by the chat frontend to render link preview cards. Results are cached server-side for 1 hour.

Query Parameters

urlThe URL to preview (required, must be http/https)

Response

{
  "url": "https://example.com/article",
  "title": "Article Title",
  "description": "A brief description...",
  "image": "https://example.com/image.jpg",
  "site_name": "Example"
}
GET /api/chat/thread/{id} Get reply chain for a message

Returns the full reply chain (thread) for a given message, walking backwards through reply_to references up to 20 messages deep. Messages are returned in chronological order (oldest first).

Response

{
  "messages": [
    {"id": 10, "username": "alice", "message": "Original message", "created_at": "..."},
    {"id": 15, "username": "bob", "message": "Reply", "reply_to": 10, "created_at": "..."},
    {"id": 20, "username": "alice", "message": "Reply to reply", "reply_to": 15, "created_at": "..."}
  ]
}

WebSocket API

Connect to the chat WebSocket for real-time messaging:

wss://portal.example.com/ws/chat

Client Messages

// Join a channel
{"type": "join", "channel": "general"}

// Send a message
{"type": "message", "channel": "general", "message": "Hello!"}

// Send a reply (reply_to is the parent message ID)
{"type": "message", "channel": "general", "message": "Nice!", "reply_to": 42}

// Send a message with an image (upload first via POST /api/chat/upload)
{"type": "message", "channel": "general", "message": "", "image_url": "/static/uploads/chat/abc.jpg"}

// Delete a message (admins/mods: any; users: own only)
{"type": "delete", "message_id": 42}

// Toggle emoji reaction (adds if not present, removes if present)
{"type": "react", "message_id": 42, "emoji": "👍"}

// Edit own message (within 5-minute window)
{"type": "edit_message", "message_id": 42, "message": "Updated text"}

// Pin/unpin message (moderator/admin only)
{"type": "pin_message", "message_id": 42}
{"type": "unpin_message", "message_id": 42}

// Mark channel as read (for unread tracking)
{"type": "mark_read", "message_id": 50}

// Typing indicator
{"type": "typing", "channel": "general"}

// Block/unblock a user (hides their messages for you)
{"type": "block_user", "user_id": 5}
{"type": "unblock_user", "user_id": 5}

// Create a poll (2-10 options, optional duration in minutes)
{"type": "create_poll", "channel": "general", "question": "Favorite language?", "options": ["Python", "JavaScript", "Rust"], "allow_multiple": false, "anonymous_votes": false, "duration": 60}

// Vote on a poll (option_index is 0-based)
{"type": "vote_poll", "poll_id": 1, "option_index": 0}

// Close a poll (creator or moderator+)
{"type": "close_poll", "poll_id": 1}

Server Messages

// Channel info (after joining)
{"type": "channel_info", "name": "general", "topic": "..."}

// Message history
{"type": "history", "messages": [...]}

// New message (includes avatar, nickname, anonymous flag)
{"type": "message", "id": 42, "user_id": 1, "username": "alice", "nickname": "Ali", "role": "user", "anonymous": false, "avatar": {"color": "#3b82f6", "emoji": "🚀"}, "message": "Hello!"}

// Reply message (includes reply_to and reply_preview)
{"type": "message", "id": 43, "user_id": 2, "username": "bob", "message": "Nice!", "reply_to": 42, "reply_preview": {"id": 42, "username": "alice", "message": "Hello!"}}

// User joined/left
{"type": "user_joined", "username": "bob", "role": "user", "anonymous": false}
{"type": "user_left", "username": "bob"}

// Online users list (with profile data)
{"type": "users", "users": [{"user_id": 1, "username": "alice", "nickname": "Ali", "role": "user", "anonymous": false, "avatar": {...}}]}

// Message deleted
{"type": "message_deleted", "message_id": 42, "deleted_by": 1}

// Channel renamed (admin action via REST)
{"type": "channel_renamed", "old_name": "dev-talk", "new_name": "engineering"}

// Channel deleted (admin action via REST)
{"type": "channel_deleted", "channel": "old-channel"}

// Reaction update (add or remove)
{"type": "reaction_update", "message_id": 42, "emoji": "👍", "user_id": 1, "username": "alice", "action": "add"}

// Message edited
{"type": "message_edited", "message_id": 42, "new_text": "Updated message", "edited_at": "2026-02-10T12:00:00"}

// Message pinned (mod/admin action)
{"type": "message_pinned", "message_id": 42, "pinned_by": 1, "pinned_by_username": "alice"}

// Message unpinned (mod/admin action)
{"type": "message_unpinned", "message_id": 42}

// Pinned messages list (sent on channel join)
{"type": "pinned_messages", "messages": [{"id": 42, "username": "alice", "message": "Important info", "pinned_by": 1, "pinned_at": "..."}]}

// User blocked/unblocked confirmation
{"type": "user_blocked", "user_id": 5, "username": "bob"}
{"type": "user_unblocked", "user_id": 5, "username": "bob"}

// Poll created (includes poll data inline with the message)
{"type": "poll_created", "message": {...}, "poll": {"id": 1, "question": "Favorite language?", "options": ["Python", "JavaScript", "Rust"], "votes": {}, "total_votes": 0}}

// Poll updated (after a vote)
{"type": "poll_updated", "poll_id": 1, "votes": {"0": 3, "1": 5, "2": 2}, "total_votes": 10}

// Poll closed (by creator or moderator)
{"type": "poll_closed", "poll_id": 1}

// Rate limit error (5 messages per 5 seconds)
{"type": "error", "message": "Slow down! You're sending messages too fast."}

DM Client Messages (via /ws/chat)

Direct message operations share the same WebSocket connection as channel chat.

// Open/load a DM conversation
{"type": "dm_open", "conversation_id": 1}

// Create a group DM
{"type": "dm_create_group", "user_ids": [2, 3], "name": "Project Team"}

// Send a DM
{"type": "dm_message", "conversation_id": 1, "message": "Hello!"}

// Typing indicator in DM
{"type": "dm_typing", "conversation_id": 1}

// Delete a DM message
{"type": "dm_delete", "conversation_id": 1, "message_id": 42}

// React to a DM message
{"type": "dm_react", "conversation_id": 1, "message_id": 42, "emoji": "👍"}

// Edit a DM message (within 5-minute window)
{"type": "dm_edit", "conversation_id": 1, "message_id": 42, "message": "Updated text"}

// Mark DM conversation as read
{"type": "dm_mark_read", "conversation_id": 1}

// Load DM history (pagination)
{"type": "dm_history", "conversation_id": 1, "before_id": 100}

DM Server Messages

// Conversation opened (with participant details)
{"type": "dm_conversation_opened", "conversation": {...}}

// Conversations list (on connect or group creation)
{"type": "dm_conversations_list", "conversations": [...]}

// New DM message
{"type": "dm_message", "conversation_id": 1, "id": 42, "user_id": 1, "username": "alice", "message": "Hello!", "created_at": "..."}

// DM typing indicator
{"type": "dm_typing", "conversation_id": 1, "user_id": 1, "username": "alice"}

// DM message deleted
{"type": "dm_message_deleted", "conversation_id": 1, "message_id": 42}

// DM reaction update
{"type": "dm_reaction_update", "conversation_id": 1, "message_id": 42, "emoji": "👍", "action": "add"}

// DM message edited
{"type": "dm_message_edited", "conversation_id": 1, "message_id": 42, "new_text": "Updated", "edited_at": "..."}

// DM message history (paginated)
{"type": "dm_history", "conversation_id": 1, "messages": [...], "has_more": true}

JavaScript Example

const ws = new WebSocket('wss://portal.example.com/ws/chat');

ws.onopen = () => {
  // Join the general channel
  ws.send(JSON.stringify({type: 'join', channel: 'general'}));
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  if (data.type === 'message') {
    console.log(`${data.username}: ${data.message}`);
  }
};

// Send a message
ws.send(JSON.stringify({
  type: 'message',
  channel: 'general',
  message: 'Hello everyone!'
}));

Voice Chat

Live voice chat using WebRTC P2P mesh (2-10 users per channel). The server acts purely as a signaling relay — no audio processing or storage. Audio is encrypted via WebRTC DTLS-SRTP. Voice signaling piggybacks on the existing /ws/chat WebSocket.

GET /api/voice/ice-servers Get ICE server configuration

Returns STUN and optional TURN server configuration for WebRTC peer connections.

Response

{
  "ice_servers": [
    {"urls": "stun:stun.l.google.com:19302"},
    {"urls": "turn:turn.example.com:3478", "username": "user", "credential": "pass"}
  ]
}

Voice Signaling (via /ws/chat)

Voice commands are sent as JSON messages on the existing chat WebSocket. Supported client-to-server message types:

TypeFieldsDescription
voice_join room (optional) Join voice in the current text channel, or pass room: "dm:{conversation_id}" for DM voice
voice_leave Leave voice chat
voice_signal target_user_id, signal Forward WebRTC offer/answer/ICE candidate to a specific user
voice_mute muted (bool) Toggle microphone mute state
voice_deafen deafened (bool) Toggle deafen (mute all incoming audio)
voice_speaking speaking (bool) Voice activity indicator (rate-limited to 1/100ms)

Voice Modes

Users can choose between two voice activation modes (stored in browser localStorage):

  • VAD (Voice Activity Detection) — Automatically detects speech using AudioContext analysis with configurable dBFS threshold
  • PTT (Push-to-Talk) — Transmit only while holding a configurable key (default: Space)

Direct Messages

Private messaging between users with support for 1:1 conversations and group DMs (up to 10 participants). All DM messages are encrypted at rest using AES-256 (Fernet), the same encryption used for channel messages.

Security: All DM messages are encrypted at rest (Fernet). Only conversation participants can access messages. Search results are filtered to the user's own DM conversations. An FTS5 search index (plaintext) is maintained alongside encrypted storage for search functionality.

REST API

GET /api/dm/conversations List conversations

Returns all DM conversations for the authenticated user, ordered by most recent activity. Includes the last message preview and unread count for each conversation.

Response

{
  "conversations": [
    {
      "id": 1,
      "type": "direct",
      "name": null,
      "participants": [
        {"user_id": 1, "username": "alice"},
        {"user_id": 2, "username": "bob"}
      ],
      "last_message": {
        "message": "Hey, are you around?",
        "username": "alice",
        "created_at": "2026-02-11T14:30:00"
      },
      "unread_count": 2,
      "muted": false,
      "created_at": "2026-02-11T10:00:00"
    },
    {
      "id": 2,
      "type": "group",
      "name": "Project Team",
      "participants": [
        {"user_id": 1, "username": "alice"},
        {"user_id": 2, "username": "bob"},
        {"user_id": 3, "username": "charlie"}
      ],
      "last_message": {
        "message": "Meeting at 3pm",
        "username": "charlie",
        "created_at": "2026-02-11T13:00:00"
      },
      "unread_count": 0,
      "muted": false,
      "created_at": "2026-02-10T09:00:00"
    }
  ]
}
POST /api/dm/conversations Create conversation

Create a new DM conversation. For 1:1 conversations, provide user_id. For group DMs, provide user_ids array and an optional name. If a 1:1 conversation already exists with the given user, the existing conversation is returned.

Request Body (1:1)

ParameterTypeDescription
user_id integer Target user ID required

Request Body (Group)

ParameterTypeDescription
user_ids array Array of user IDs to include (max 10 total participants) required
name string Group conversation name required

Response

{
  "id": 3,
  "type": "direct",
  "name": null,
  "participants": [
    {"user_id": 1, "username": "alice"},
    {"user_id": 4, "username": "dave"}
  ],
  "created_at": "2026-02-11T15:00:00"
}
GET /api/dm/conversations/{id} Get conversation details

Returns details for a specific DM conversation. Only accessible by conversation participants.

Response

{
  "id": 1,
  "type": "direct",
  "name": null,
  "participants": [
    {"user_id": 1, "username": "alice", "nickname": "Ali", "role": "user", "avatar": {"color": "#3b82f6", "emoji": "🚀"}},
    {"user_id": 2, "username": "bob", "nickname": null, "role": "admin", "avatar": {"color": "#ef4444", "emoji": "🔥"}}
  ],
  "muted": false,
  "created_at": "2026-02-11T10:00:00"
}
GET /api/dm/conversations/{id}/messages Get conversation messages

Returns messages for a DM conversation with pagination support. Messages are returned in reverse chronological order (newest first). Only accessible by conversation participants.

Query Parameters

ParameterTypeDescription
limit integer Number of messages to return (default: 100)
before_id integer Return messages before this message ID (for pagination)

Response

{
  "messages": [
    {
      "id": 150,
      "user_id": 1,
      "username": "alice",
      "nickname": "Ali",
      "avatar": {"color": "#3b82f6", "emoji": "🚀"},
      "message": "Hey, are you around?",
      "created_at": "2026-02-11T14:30:00"
    },
    {
      "id": 149,
      "user_id": 2,
      "username": "bob",
      "nickname": null,
      "avatar": {"color": "#ef4444", "emoji": "🔥"},
      "message": "Sure, what's up?",
      "created_at": "2026-02-11T14:29:00"
    }
  ],
  "has_more": true
}
POST /api/dm/conversations/{id}/mute Toggle conversation mute

Mute or unmute a DM conversation. Muted conversations do not generate notifications. Only accessible by conversation participants.

Request Body

ParameterTypeDescription
muted boolean Set to true to mute, false to unmute required

Response

{
  "success": true,
  "muted": true
}
POST /api/dm/conversations/{id}/leave Leave group conversation

Leave a group DM conversation. Only available for group conversations (not 1:1). The conversation remains for other participants. Only accessible by conversation participants.

Response

{
  "success": true
}
POST /api/dm/conversations/{id}/participants Add participants to group

Add users to an existing group DM conversation. Only available for group conversations. Total participants cannot exceed 10. Only accessible by existing conversation participants.

Request Body

ParameterTypeDescription
user_ids array Array of user IDs to add (max 10 total participants) required

Response

{
  "success": true,
  "participants": [
    {"user_id": 1, "username": "alice"},
    {"user_id": 2, "username": "bob"},
    {"user_id": 3, "username": "charlie"},
    {"user_id": 5, "username": "eve"}
  ]
}

Full-text search across chat channel messages and DM conversations. Powered by SQLite FTS5 for fast, ranked results. Search respects access control — users can only find messages in channels they have access to and DM conversations they participate in.

Privacy Note: An FTS5 plaintext search index is maintained alongside the encrypted message storage to enable search functionality. The search index contains message text in plaintext for indexing purposes. Search results are always filtered to the authenticated user's accessible messages.
GET /api/chat/search Search messages

Search across channel messages and DM conversations. Rate limited to 10 requests per minute per user.

Query Parameters

ParameterTypeDescription
q string Search query (2-200 characters) required
scope string Search scope: all (default), channels, or dms
channel_id integer Limit search to a specific channel
conversation_id integer Limit search to a specific DM conversation
from string Filter by sender username
has string Filter by content type: image
before string Filter messages before this date (ISO 8601)
after string Filter messages after this date (ISO 8601)
limit integer Results per page (default: 25, max: 50)
offset integer Pagination offset (default: 0)

Response

{
  "results": [
    {
      "id": 42,
      "source": "channel",
      "channel_id": 1,
      "channel_name": "general",
      "user_id": 1,
      "username": "alice",
      "message": "Has anyone tried the new deployment script?",
      "created_at": "2026-02-11T12:00:00"
    },
    {
      "id": 88,
      "source": "dm",
      "conversation_id": 3,
      "conversation_name": "bob",
      "user_id": 2,
      "username": "bob",
      "message": "I deployed it yesterday, works great",
      "created_at": "2026-02-11T12:05:00"
    }
  ],
  "total": 2,
  "limit": 25,
  "offset": 0
}

Notifications

Real-time notification system for stream events, service alerts, and security events. Notifications are delivered via polling and displayed in the navbar bell icon.

GET /api/notifications Get user notifications

Returns notifications for the authenticated user with an unread count.

Query Parameters

ParameterTypeDescription
unread string Set to 1 to return only unread notifications

Response

{
  "notifications": [
    {
      "id": 1,
      "type": "stream_live",
      "title": "Stream Live",
      "message": "alice started streaming",
      "is_read": false,
      "created_at": "2026-02-08T12:00:00"
    }
  ],
  "unread_count": 3
}
POST /api/notifications/{id}/read Mark notification as read

Mark a single notification as read. Only affects notifications owned by the authenticated user.

Response

{
  "success": true
}
POST /api/notifications/read-all Mark all notifications read

Mark all notifications as read for the authenticated user.

Response

{
  "success": true,
  "count": 5
}

User Blocking

Block other users to hide their messages and prevent them from sending you DMs. Blocking is bidirectional for DMs — neither party can message the other while a block is active.

GET /api/users/blocked List your blocked users

Returns the list of users you have blocked. Scoped to the authenticated user.

Response

{
  "blocked_users": [
    {"id": 5, "username": "bob", "blocked_at": "2026-02-13T10:00:00"}
  ]
}
POST /api/users/{id}/block Block a user

Block a user by their ID. You cannot block yourself. Blocked users' messages are hidden in chat and DMs are prevented bidirectionally.

Response

{
  "success": true
}
DELETE /api/users/{id}/block Unblock a user

Remove a user from your block list.

Response

{
  "success": true
}

WebSocket Integration

Blocking can also be managed via the /ws/chat WebSocket. Send block_user or unblock_user messages and receive confirmation events. Blocked users' messages are filtered client-side.

Polls

Create inline polls in chat channels. Polls support 2-10 options, optional multi-vote, anonymous voting, and time limits. All poll operations use the /ws/chat WebSocket.

Poll Constraints

FieldLimit
Question length500 characters max
Option count2-10 options
Option length200 characters max per option
DurationOptional, in minutes (poll auto-closes when expired)

WebSocket Messages

Polls are created, voted on, and closed via the chat WebSocket. See the Chat WebSocket API section for the full message format. Key message types:

Client TypeDescriptionAuth
create_pollCreate a new poll in a channelAny user (timeout/mute checked)
vote_pollCast a vote (option_index is 0-based)Any user
close_pollClose a poll earlyPoll creator or Moderator+
Server TypeDescription
poll_createdNew poll broadcast with message and poll data
poll_updatedUpdated vote counts after a vote
poll_closedPoll has been closed

Channel Permissions

Control channel visibility and send permissions. Channels can be public (visible to all) or private (members only), and open (everyone can send) or readonly (only moderators and channel moderators can send).

Channel Properties

FieldValuesDescription
visibilitypublic / privateWho can see and join the channel
modeopen / readonlyWho can send messages

Chat User Search

GET /api/chat/users Search users for DMs and member management

Lightweight user search accessible to all authenticated users. Returns basic info only (id, username, nickname). Used for DM creation and channel member management.

Query Parameters

qSearch query (matches username or nickname, case-insensitive)

Response

{
  "users": [
    {"id": 2, "username": "alice", "nickname": "Ali"},
    {"id": 3, "username": "bob", "nickname": null}
  ]
}

Channel Members

GET /api/chat/channels/{id}/members List channel members

List members of a channel. Visible to channel members and moderators+.

Response

{
  "members": [
    {"user_id": 2, "username": "alice", "role": "moderator", "added_at": "2026-02-13T10:00:00"},
    {"user_id": 3, "username": "bob", "role": "member", "added_at": "2026-02-13T10:05:00"}
  ]
}
POST /api/chat/channels/{id}/members Add a channel member

Add a user as a member of a private channel. Requires admin, channel creator, or channel moderator role.

Request Body

{
  "user_id": 5
}

Response

{
  "success": true
}
PUT /api/chat/channels/{id}/members/{user_id} Update member role

Change a channel member's role (member or moderator). Requires admin or channel creator.

Request Body

{
  "role": "moderator"
}
DELETE /api/chat/channels/{id}/members/{user_id} Remove a channel member

Remove a user from a private channel. Requires admin, channel creator, or channel moderator role.

Response

{
  "success": true
}

Webhooks

Webhooks let you receive real-time HTTP notifications when events happen in your portal (outgoing), or post messages into chat channels from external services (incoming). Outgoing webhooks sign payloads with HMAC-SHA256 for verification. Webhook URLs, secrets, and delivery log payloads are encrypted at rest. Admin+ required for management.

POST /api/webhooks Create webhook

Create an outgoing or incoming webhook. Maximum 25 webhooks per user. The webhook secret is returned only once on creation.

Request Body

ParameterTypeDescription
name string Webhook name (1-100 chars) required
type string outgoing or incoming required
url string Delivery URL (outgoing only, HTTPS required, SSRF-protected) required for outgoing
events array Events to subscribe to (outgoing only): message.created, message.deleted, stream.live, stream.offline, user.joined, user.connected, user.disconnected required for outgoing
channel_id integer Target channel for incoming webhook messages required for incoming

Response

{
  "id": 1,
  "name": "My Webhook",
  "type": "outgoing",
  "url": "https://example.com/hook",
  "secret": "abc123...",
  "events": ["message.created"],
  "enabled": true,
  "token": null
}

Note: The secret field is only returned on creation. Store it securely for HMAC verification.

GET /api/webhooks List webhooks

List your webhooks. Admins see all webhooks with owner usernames. Secrets are never returned in list responses.

{
  "webhooks": [
    {
      "id": 1,
      "name": "My Webhook",
      "type": "outgoing",
      "url": "https://example.com/hook",
      "has_secret": true,
      "events": ["message.created"],
      "enabled": true,
      "consecutive_failures": 0,
      "created_at": "2026-02-19 12:00:00"
    }
  ]
}
GET /api/webhooks/{id} Get webhook details

Get details for a specific webhook. Owner or admin required.

PUT /api/webhooks/{id} Update webhook

Update webhook name, URL, events, channel, or enabled status. URL changes are SSRF-validated. Owner or admin required.

Request Body

ParameterTypeDescription
namestringNew name
urlstringNew delivery URL (outgoing only)
eventsarrayUpdated event list
channel_idintegerNew target channel (incoming only)
enabledbooleanEnable/disable webhook
DELETE /api/webhooks/{id} Delete webhook

Delete a webhook and all its delivery logs. Owner or admin required.

GET /api/webhooks/{id}/logs Delivery logs

View delivery logs for a webhook. Includes request payload, response status, success/failure, attempt number, and duration. Logs are encrypted at rest and auto-cleaned after 7 days.

Query Parameters

ParameterTypeDescription
limitintegerMax results (default 50, max 100)
offsetintegerPagination offset
POST /api/webhooks/{id}/test Send test event

Send a webhook.test event to verify your webhook endpoint is working. Owner or admin required.

POST /api/webhooks/{id}/regenerate-secret Regenerate HMAC secret

Generate a new HMAC signing secret. The new secret is returned only once. The old secret is immediately invalidated.

POST /api/webhooks/incoming/{token} Post incoming webhook message

Post a message to a chat channel via an incoming webhook. No authentication required — the token in the URL acts as the credential. Rate limited to 10 messages per 60 seconds per token.

Request Body

ParameterTypeDescription
content string Message content (1-4000 chars) required
username string Display name override (max 50 chars, defaults to webhook name)

Outgoing Webhook Payload Format

Outgoing webhooks receive a signed JSON POST with this structure:

{
  "event": "message.created",
  "timestamp": "2026-02-19T12:00:00Z",
  "data": {
    "message_id": 123,
    "channel_id": 1,
    "channel_name": "general",
    "user_id": 10,
    "username": "alice",
    "content": "Hello world"
  }
}

Headers:
  X-Portal-Signature: sha256=abc123...
  X-Portal-Event: message.created
  Content-Type: application/json

Verify the signature by computing HMAC-SHA256(secret, request_body) and comparing with the X-Portal-Signature header value.


Embedded Browser

Portal's embedded browser provides a tabbed browsing experience for HTTP/HTTPS connections (http_proxy plugin type). It includes navigation controls (back, forward, refresh), an address bar, tabbed browsing (Ctrl+T/W/Tab), and multi-site navigation for browser-mode connections. A default "Web Browser" connection with DuckDuckGo as homepage is created for all users. Connection IDs use opaque string tokens.

GET /browser?connection={id} Open embedded browser for HTTP proxy connection

Opens the embedded browser page for browsing a remote web service through Portal. Requires authentication. The connection must use the http_proxy plugin type (includes http, https, http_proxy, and all web panel connection types such as Home Assistant, Grafana, Portainer, etc.). Connections with browser_mode: true support multi-site navigation to any website.

Query Parameters

ParameterTypeDescription
connectionstringConnection ID (opaque token) required

Notes

  • This is the user-facing page for HTTP/HTTPS connections. When users click Connect on a web-type connection, they are directed here.
  • Supports tabbed browsing (Ctrl+T new tab, Ctrl+W close, Ctrl+Tab switch), keyboard shortcuts (Alt+arrows, Ctrl+L, Ctrl+R), and middle-click to close tabs.
  • Browser-mode connections can navigate to any website. The address bar accepts full URLs, bare hostnames (auto-prefixed with https://), and relative paths.
  • All proxied traffic is routed internally through /proxy/{connection_id}/{path}. Users should use /browser?connection={id} rather than accessing /proxy/ directly.
  • The embedded browser rewrites links and intercepts navigation (forms, fetch, XHR, WebSocket, Location.assign/replace, history API) to keep all traffic within Portal's proxy layer.
GET /proxy/{connection_id}/{path} Reverse proxy passthrough (internal)

Proxies HTTP requests to the backend service configured for the given connection. This endpoint is used internally by the embedded browser and should not be accessed directly by users. Use /browser?connection={id} instead for the full browsing experience with navigation controls.

Path Parameters

ParameterTypeDescription
connection_idstringConnection ID (opaque token) required
pathstringPath to proxy to the backend service

Behavior

  • Forwards all HTTP methods (GET, POST, PUT, DELETE, etc.) to the backend
  • Proxies WebSocket upgrade requests for real-time features
  • Enforces TLS for connections to HTTPS backends
  • In browser mode, the path encodes the full target URL (e.g., /proxy/{id}/https://example.com/page) for multi-site navigation
  • Localhost and private IPs are blocked for browser-mode targets
  • Portal session cookies and Authorization headers are stripped from upstream requests
  • Requires authentication — only the connection owner can access their proxy routes

SFTP Browser

Browse and manage files on remote servers via your existing SSH/SFTP connections. Per-user access -- only connections you own are accessible. Ephemeral connections are opened per-request.

GET /api/sftp/{conn_id}/list List remote directory

List remote directory contents.

Query Parameters

ParameterTypeDescription
pathstringRemote directory path (default: /)
GET /api/sftp/{conn_id}/read Read remote text file

Read remote text file for editing. Maximum file size: 5MB.

Query Parameters

ParameterTypeDescription
pathstringRemote file path required
GET /api/sftp/{conn_id}/download Download remote file

Download a remote file as a streaming attachment.

Query Parameters

ParameterTypeDescription
pathstringRemote file path required
POST /api/sftp/{conn_id}/upload Upload to remote server

Upload a file to the remote server via multipart form data.

Form Fields

ParameterTypeDescription
pathstringDestination directory path required
filefileFile to upload required
POST /api/sftp/{conn_id}/write Write remote text file

Request Body

ParameterTypeDescription
pathstringRemote file path required
contentstringFile content required
POST /api/sftp/{conn_id}/mkdir Create remote directory

Request Body

ParameterTypeDescription
pathstringRemote directory path to create required
POST /api/sftp/{conn_id}/rename Rename or move remote path

Request Body

ParameterTypeDescription
old_pathstringCurrent remote path required
new_pathstringNew remote path required
DELETE /api/sftp/{conn_id}/delete Delete remote file or directory

Delete a remote file or empty directory.

Query Parameters

ParameterTypeDescription
pathstringRemote path to delete required

SFTP VOD Storage

Manage VOD files on your configured SFTP VOD storage server. These endpoints operate on your personal VOD storage connection (configured via the VOD Storage settings in the dashboard). Any authenticated user with VOD storage configured can use these endpoints.

GET /api/sftp/vod/list List VOD storage directory

List contents of a directory on the VOD SFTP storage.

Query Parameters

ParameterTypeDescription
pathstringRemote directory path (default: /)
GET /api/sftp/vod/read Read VOD storage file

Read a text file from VOD SFTP storage.

Query Parameters

ParameterTypeDescription
pathstringRemote file path required
GET /api/sftp/vod/download Download VOD storage file

Download a file from VOD SFTP storage as a streaming attachment.

Query Parameters

ParameterTypeDescription
pathstringRemote file path required
POST /api/sftp/vod/mkdir Create VOD storage directory

Request Body

ParameterTypeDescription
pathstringRemote directory path to create required
POST /api/sftp/vod/upload Upload to VOD storage

Upload a file to VOD SFTP storage via multipart form data.

Form Fields

ParameterTypeDescription
pathstringDestination directory path required
filefileFile to upload required
POST /api/sftp/vod/write Write file to VOD storage

Request Body

ParameterTypeDescription
pathstringRemote file path required
contentstringFile content required
POST /api/sftp/vod/rename Rename VOD storage file

Request Body

ParameterTypeDescription
old_pathstringCurrent remote path required
new_pathstringNew remote path required
DELETE /api/sftp/vod/delete Delete VOD storage file

Delete a file or empty directory from VOD SFTP storage.

Query Parameters

ParameterTypeDescription
pathstringRemote path to delete required

System Health ADMIN

Detailed system resource monitoring including CPU, memory, disk, and Portal process metrics.

Activity Feed ADMIN

Recent activity log. Admins see all activity; regular users see only their own actions.

GET /api/activity Get recent activity

Returns recent activity entries. Admins see all activity; regular users see only their own.

Query Parameters

ParameterTypeDescription
limit integer Max entries to return, 1-50 (default: 20)

Response

{
  "activities": [
    {
      "id": 1,
      "user_id": 10,
      "username": "john",
      "action": "stream_started",
      "details": "Started streaming",
      "created_at": "2026-02-08T12:00:00"
    }
  ]
}

Certificate Management ADMIN

Manage TLS certificates via the admin panel or API. Supports custom uploads, self-signed generation, and Let's Encrypt automation.

Server Settings ADMIN




Data Retention ADMIN

Configure automatic cleanup policies for chat messages, notifications, activity logs, and service logs. Protects against database bloat while preserving recent data.

Audit Log MODERATOR+

View a chronological record of all moderation actions performed on the server. Includes message deletions, user timeouts/mutes/bans, channel operations, role changes, and automod triggers. Available to moderators and above.

Auto-Moderation ADMIN

Configure automatic message filtering rules that run before messages are saved. Moderators and above bypass all automod rules. Triggers are logged to the audit log.


Rate Limits

API requests are rate limited to prevent abuse:

  • Authentication endpoints: 10 requests per minute
  • General API: 60 requests per minute
  • WebSocket connections: 10 concurrent per user
  • Chat messages: 5 messages per 5 seconds per user
  • Outgoing webhooks: 30 deliveries per webhook per 60 seconds
  • Incoming webhooks: 10 messages per token per 60 seconds

Rate limit headers are included in responses:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1704067200

Error Codes

CodeDescription
400Bad Request - Invalid parameters
401Unauthorized - Missing or invalid authentication
403Forbidden - Insufficient permissions or blocked action
404Not Found - Resource doesn't exist
429Too Many Requests - Rate limit exceeded
500Internal Server Error