rdio-to-discord/rdio-docs.md

1171 lines
25 KiB
Markdown

# Rdio Scanner WebSocket API Documentation
**Last Updated:** January 2026
**Status:** Proprietary Service (See [API_ACCESS_POLICY.md](../API_ACCESS_POLICY.md) for licensing)
---
## Table of Contents
1. [Overview](#overview)
2. [Connection](#connection)
3. [Message Format](#message-format)
4. [Client Commands (Request)](#client-commands-request)
5. [Server Responses & Events](#server-responses--events)
6. [Message Flow Examples](#message-flow-examples)
7. [Client Event Handlers](#client-event-handlers)
8. [Practical Examples](#practical-examples)
9. [Error Handling](#error-handling)
10. [Protocol Details](#protocol-details)
---
## Overview
The Rdio Scanner WebSocket API provides real-time bidirectional communication between the server and connected clients. Messages are transmitted as JSON arrays with a command code, payload, and optional flags.
### Access Restrictions
⚠️ **Important:** The WebSocket API is a proprietary service provided exclusively by Saubeo Solutions:
- Available on **Saubeo Solutions' hosted service** for native applications and integrated web applications
- Available on **self-hosted deployments** with a valid license agreement
- See [API_ACCESS_POLICY.md](../API_ACCESS_POLICY.md) for full terms
The **HTTP REST API** is fully available under GPL terms for all uses.
---
## Connection
### Establishing a WebSocket Connection
```
ws://[host]:[port]/
wss://[host]:[port]/ (TLS)
```
### Connection Flow
1. Client initiates WebSocket connection to the server
2. Server upgrades the connection using gorilla/websocket
3. Server waits for configuration before registering the client
4. Client sends `VER` (version) and `CFG` (config request) commands
5. Upon receiving `CFG` response, client is registered and can receive live updates
### Connection Parameters
- **Read Buffer Size:** 1024 bytes
- **Write Buffer Size:** 1024 bytes
- **Read Timeout (Pong Deadline):** 60 seconds
- **Ping Period:** 54 seconds (90% of pong wait)
- **Write Timeout:** 10 seconds
---
## Message Format
### Structure
All WebSocket messages are JSON arrays with the following structure:
```json
[command, payload, flag]
```
- **command** (string, required): Three-letter command code
- **payload** (any, optional): Command-specific data
- **flag** (string, optional): Additional flags or metadata
### Key Convention
📤 **CLIENT → SERVER:** Messages you send to request data or perform actions
📥 **SERVER → CLIENT:** Messages the server sends in response or as broadcasts
### Example Messages
```javascript
// CLIENT → SERVER: Request a call
["CAL", "12345", "DL"]
// SERVER → CLIENT: Send configuration
["CFG", {...config object...}]
// CLIENT → SERVER: Simple command with no payload
["VER"]
```
---
## Client Commands (Request)
**Legend:** 📤 = You send this to the server
### 📤 VER (Version)
**Description:** Request the server version.
```javascript
["VER"]
```
**Parameters:** None
**Server Response:**
```javascript
["VER", "7.0.0"]
```
---
### 📤 CFG (Config)
**Description:** Request the current server configuration including systems, groups, tags, and client options. This must be sent during connection initialization.
```javascript
["CFG"]
```
**Parameters:** None
**Server Response:**
```javascript
["CFG", {
"alerts": {...},
"branding": {...},
"dimmerDelay": number,
"email": string,
"groups": {...},
"groupsData": [...],
"keypadBeeps": {...},
"playbackGoesLive": boolean,
"showListenersCount": boolean,
"systems": {...},
"tags": {...},
"tagsData": [...],
"time12hFormat": boolean
}]
```
---
### 📤 CAL (Call)
**Description:** Request a specific call by ID, optionally with a flag for download or playback.
```javascript
["CAL", "callId"]
["CAL", "callId", "DL"]
["CAL", "callId", "PY"]
```
**Parameters:**
- **callId** (string, required): Numeric call ID
- **flag** (string, optional):
- `"DL"` - Download the call audio file
- `"PY"` - Play the call audio
**Server Response:**
```javascript
["CAL", {
"id": 12345,
"audioName": "call_20260128.wav",
"audioType": "audio/wav",
"audio": Uint8Array,
"dateTime": 1705372800000,
"frequencies": [461.125],
"system": {
"id": 1,
"label": "Police"
},
"talkgroup": {
"id": 101,
"label": "Dispatch",
"tagId": 5,
"name": "Dispatch (Police)"
},
"units": ["Unit-5", "Unit-12"],
"patches": [],
...
}, "DL"]
```
---
### 📤 LCL (List Calls)
**Description:** Request a list of calls with filtering, sorting, and pagination options.
```javascript
["LCL", {
"offset": 0,
"limit": 50,
"sort": -1,
"filters": {
"system": 1,
"group": 2,
"talkgroup": 101,
"tag": 5,
"unit": 3,
"date": "2026-01-28",
"keyword": "emergency"
}
}]
```
**Parameters:**
- **offset** (number): Starting position in results
- **limit** (number): Maximum number of results to return
- **sort** (number): Sort order
- `1` = ascending (oldest first)
- `-1` = descending (newest first)
- **filters** (object, optional):
- `system` (number): Filter by system ID
- `group` (number): Filter by group ID
- `talkgroup` (number): Filter by talkgroup ID
- `tag` (number): Filter by tag ID
- `unit` (number): Filter by unit ID
- `date` (string): Filter by date
- `keyword` (string): Text search in call metadata
**Server Response:**
```javascript
["LCL", {
"results": [
{ ...call object... },
{ ...call object... }
],
"count": 250,
"offset": 0,
"limit": 50
}]
```
---
### 📤 PIN (PIN Authentication)
**Description:** Send a PIN for user authentication and restricted system access.
```javascript
["PIN", "1234"]
```
**Parameters:**
- **pin** (string, required): User's access PIN code
**Server Response (on success):**
```javascript
["CFG", {...full config...}]
```
**Server Response (on failure):**
```javascript
["PIN", null]
```
---
### 📤 LFM (Livefeed Map)
**Description:** Configure which systems, groups, and talkgroups to receive live call updates for. Only send updates for enabled feeds.
```javascript
["LFM", {
"1": {
"2": {
"101": true,
"102": true
},
"3": {
"201": true
}
}
}]
```
**Parameters:**
- **filters** (object): Nested object structure
- Key: System ID
- Value: Object with Group ID keys
- Key: Group ID
- Value: Object with Talkgroup ID keys
- Key: Talkgroup ID
- Value: `true` to enable, `false` to disable
**Example:** Subscribe to live calls from System 1 (Police), Groups 2 & 3, specific talkgroups:
```javascript
["LFM", {
"1": { // System 1
"2": { // Group 2
"101": true, // Talkgroup 101
"102": true // Talkgroup 102
},
"3": { // Group 3
"201": true // Talkgroup 201
}
}
}]
```
**Server Response:** None (fire-and-forget command, but server will start sending filtered `CAL` events)
---
### 📤 PID (Push ID)
**Description:** Register a push notification device ID for mobile platforms.
```javascript
["PID", "firebase_device_token_abc123xyz"]
```
**Parameters:**
- **pushId** (string, required): Device push notification identifier
**Server Response:** None (acknowledgment is implicit)
---
### 📤 IOS (iOS Metadata)
**Description:** Send iOS-specific metadata and capabilities.
```javascript
["IOS", {
"version": "1.0",
"platform": "iOS",
"osVersion": "16.1"
}]
```
**Parameters:** iOS-specific metadata object
**Server Response:** None
---
## Server Responses & Events
**Legend:** 📥 = Server sends this to you
### 📥 CAL (Call - Broadcast)
**Description:** Server broadcasts a new call or call update. Can be sent in response to a `CAL` request or as a live feed broadcast.
```javascript
["CAL", {
"id": 12345,
"audioName": "call_20260128.wav",
"audioType": "audio/wav",
"audio": Uint8Array,
"dateTime": 1705372800000,
"frequencies": [461.125, 462.500],
"system": {
"id": 1,
"label": "Police Dispatch"
},
"talkgroup": {
"id": 101,
"label": "Dispatch",
"tagId": 5,
"name": "Police Dispatch"
},
"units": ["Unit-5", "Unit-12"],
"patches": ["Tg-102"],
"source": "trunk_recorder",
"duration": 45000
}, "flag"]
```
**Possible Flags:**
- `"DL"` - Audio data included, client should download/save
- `"PY"` - Audio data included, client should play
- `null`/undefined - Call metadata broadcast (no audio)
---
### 📥 CFG (Configuration - Broadcast/Response)
**Description:** Server sends configuration updates when settings change. Also sent in response to `CFG` request during connection.
```javascript
["CFG", {
"alerts": {
"email": true,
"browser": true
},
"branding": {
"color": "#FF6B00",
"name": "My Scanner"
},
"dimmerDelay": 5000,
"email": "admin@example.com",
"groups": {
"1": {
"id": 1,
"label": "Emergency Services",
"systems": [1, 2, 3]
}
},
"groupsData": [...],
"keypadBeeps": {
"enabled": true,
"volume": 0.5
},
"playbackGoesLive": false,
"showListenersCount": true,
"systems": {
"1": {
"id": 1,
"label": "Police",
"talkgroups": [...]
}
},
"tags": {
"5": "Emergency"
},
"tagsData": [...],
"time12hFormat": false
}]
```
---
### 📥 VER (Version - Response)
**Description:** Server responds with version information.
```javascript
["VER", "7.0.0"]
```
---
### 📥 LSC (Listeners Count - Broadcast)
**Description:** Server broadcasts the current number of connected listeners (if enabled in config).
```javascript
["LSC", 42]
```
---
### 📥 LFM (Livefeed Map - Broadcast)
**Description:** Server sends livefeed configuration or updates.
```javascript
["LFM", {
"1": {
"2": {
"101": true,
"102": true
}
}
}]
```
---
### 📥 MAX (Maximum - Broadcast)
**Description:** Server-side limit notification (e.g., max concurrent connections reached).
```javascript
["MAX", {
"type": "clients",
"current": 1000,
"limit": 1000
}]
```
---
### 📥 PIN (PIN Required - Broadcast)
**Description:** Server requests PIN authentication. Client must respond with `["PIN", "code"]`.
```javascript
["PIN", null]
```
**Meaning:** Client is connected but restricted content requires PIN authentication. Use the `PIN` command to authenticate.
---
### 📥 XPR (Expired - Broadcast)
**Description:** Session has expired; client should disconnect and reconnect. This may be due to authentication timeout or server policy change.
```javascript
["XPR", null]
```
**Action Required:** Close connection and reconnect
---
### 📥 SRV (Server - Broadcast)
**Description:** Server status and metadata.
```javascript
["SRV", {
"version": "7.0.0",
"uptime": 86400000,
"timestamp": 1705372800000
}]
```
---
## Message Flow Examples
### Example 1: Complete Connection Sequence
```javascript
// CLIENT → SERVER: Request version
CLIENT SENDS: ["VER"]
SERVER SENDS: ["VER", "7.0.0"]
// CLIENT → SERVER: Request configuration
CLIENT SENDS: ["CFG"]
SERVER SENDS: ["CFG", {
"systems": {...},
"groups": {...},
"tags": {...},
...
}]
// After CFG received, client is registered and can receive live calls
SERVER SENDS: ["CAL", {...call data...}] // Live broadcast
SERVER SENDS: ["CAL", {...call data...}] // Live broadcast
SERVER SENDS: ["LSC", 42] // Listeners count update
```
---
### Example 2: Requesting and Playing a Specific Call
```javascript
// CLIENT → SERVER: Request call playback
CLIENT SENDS: ["CAL", "12345", "PY"]
// SERVER → CLIENT: Send call data with play flag
SERVER SENDS: ["CAL", {
"id": 12345,
"audio": Uint8Array,
"dateTime": 1705372800000,
...
}, "PY"]
// Client receives the flag "PY" and should play the audio
```
---
### Example 3: Searching with Filters
```javascript
// CLIENT → SERVER: Search for recent police dispatch calls
CLIENT SENDS: ["LCL", {
"offset": 0,
"limit": 50,
"sort": -1,
"filters": {
"system": 1,
"talkgroup": 101
}
}]
// SERVER → CLIENT: Send filtered results
SERVER SENDS: ["LCL", {
"results": [
{ "id": 12345, "system": {...}, "talkgroup": {...}, ... },
{ "id": 12344, "system": {...}, "talkgroup": {...}, ... },
...
],
"count": 250,
"offset": 0,
"limit": 50
}]
```
---
### Example 4: Authentication with PIN
```javascript
// SERVER → CLIENT: Server requires PIN (restricted access)
SERVER SENDS: ["PIN", null]
// CLIENT → SERVER: Send authentication PIN
CLIENT SENDS: ["PIN", "1234"]
// SERVER → CLIENT: Authentication successful, send config
SERVER SENDS: ["CFG", {...restricted config...}]
```
---
### Example 5: Setting Up Livefeed Filter
```javascript
// CLIENT → SERVER: Subscribe to specific talkgroups
CLIENT SENDS: ["LFM", {
"1": {
"2": {
"101": true,
"102": true
}
}
}]
// SERVER → CLIENT: Acknowledgment (implicit, no response)
// Now client will only receive CAL events for System 1, Group 2, TGs 101-102
// SERVER SENDS (filtered): ["CAL", {...call...}] // Only if matches filter
// SERVER SENDS (no): ["CAL", {...call...}] // Filtered out
```
---
## Client Event Handlers
### Connection Events
#### onopen
Fired when the WebSocket connection is successfully established.
```javascript
websocket.onopen = () => {
console.log("Connected to Rdio Scanner");
// Send initial commands
websocket.send(JSON.stringify(["VER"]));
websocket.send(JSON.stringify(["CFG"]));
};
```
---
#### onmessage
Fired when the server sends a message. Parse the message array and dispatch to appropriate handlers.
```javascript
websocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
const [command, payload, flag] = message;
switch (command) {
case "CAL": // 📥 Call broadcast or response
handleNewCall(payload, flag);
break;
case "CFG": // 📥 Configuration
handleConfigUpdate(payload);
break;
case "VER": // 📥 Version response
console.log("Server version:", payload);
break;
case "LSC": // 📥 Listeners count
handleListenersCount(payload);
break;
case "PIN": // 📥 PIN required
promptForPIN();
break;
case "XPR": // 📥 Session expired
handleSessionExpired();
break;
default:
console.log("Unknown command:", command);
}
} catch (error) {
console.warn("Invalid message received:", error);
}
};
```
---
#### onclose
Fired when the connection is closed.
```javascript
websocket.onclose = (event) => {
console.log("Disconnected from server");
console.log("Close code:", event.code); // 1000 = normal, other = error
// Attempt reconnection if not a normal close
if (event.code !== 1000) {
setTimeout(() => reconnectWebsocket(), 2000);
}
};
```
---
#### onerror
Fired when an error occurs.
```javascript
websocket.onerror = (error) => {
console.error("WebSocket error:", error);
};
```
---
## Practical Examples
### Basic Connection Setup with Auto-Reconnect
```javascript
const WEBSOCKET_URL = 'ws://localhost:3000/';
class RdioScannerClient {
constructor() {
this.websocket = null;
this.connected = false;
this.config = null;
}
connect() {
this.websocket = new WebSocket(WEBSOCKET_URL);
this.websocket.onopen = () => {
console.log('Connected to Rdio Scanner');
this.connected = true;
// 📤 Send version request
this.send(['VER']);
// 📤 Send config request (enables live feeds)
this.send(['CFG']);
};
this.websocket.onmessage = (event) => {
this.handleMessage(JSON.parse(event.data));
};
this.websocket.onclose = (event) => {
console.log('Disconnected');
this.connected = false;
// Reconnect on abnormal closure
if (event.code !== 1000) {
console.log('Reconnecting in 2 seconds...');
setTimeout(() => this.connect(), 2000);
}
};
this.websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
send(message) {
if (this.connected && this.websocket) {
this.websocket.send(JSON.stringify(message));
} else {
console.warn('Not connected, cannot send:', message);
}
}
handleMessage(message) {
const [command, payload, flag] = message;
switch (command) {
case 'VER': // 📥 Version response
console.log('Server version:', payload);
break;
case 'CFG': // 📥 Configuration response
console.log('Configuration received');
this.config = payload;
this.onConfigReceived(payload);
break;
case 'CAL': // 📥 Call broadcast
console.log('New call:', payload.id);
this.onCallReceived(payload, flag);
break;
case 'LSC': // 📥 Listeners count
console.log('Active listeners:', payload);
break;
case 'PIN': // 📥 PIN required
console.log('Authentication required');
this.promptForPIN();
break;
case 'XPR': // 📥 Session expired
console.log('Session expired');
this.handleSessionExpired();
break;
default:
console.log('Unknown command:', command);
}
}
// 📤 Request specific call
requestCall(callId, action = null) {
if (action) {
this.send(['CAL', callId, action]);
} else {
this.send(['CAL', callId]);
}
}
// 📤 Download call
downloadCall(callId) {
this.requestCall(callId, 'DL');
}
// 📤 Play call
playCall(callId) {
this.requestCall(callId, 'PY');
}
// 📤 Search calls
searchCalls(filters = {}, offset = 0, limit = 50) {
this.send(['LCL', {
offset,
limit,
sort: -1, // Newest first
filters
}]);
}
// 📤 Authenticate with PIN
sendPIN(pin) {
this.send(['PIN', pin]);
}
// 📤 Set livefeed filter
setLivefeedFilter(filterObject) {
this.send(['LFM', filterObject]);
}
onConfigReceived(config) {
// Handle configuration update
// Store systems, groups, tags, options
}
onCallReceived(call, flag) {
if (flag === 'DL') {
// Audio included for download
this.downloadAudio(call);
} else if (flag === 'PY') {
// Audio included for playback
this.playAudio(call);
} else {
// Metadata only
this.displayCall(call);
}
}
promptForPIN() {
const pin = prompt('Enter PIN:');
if (pin) {
this.sendPIN(pin);
}
}
handleSessionExpired() {
console.log('Session expired, reconnecting...');
this.disconnect();
setTimeout(() => this.connect(), 1000);
}
downloadAudio(call) {
// Convert Uint8Array to Blob and download
const blob = new Blob([call.audio], { type: call.audioType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = call.audioName || 'call.wav';
link.click();
URL.revokeObjectURL(url);
}
playAudio(call) {
// Play audio using HTML5 audio
const blob = new Blob([call.audio], { type: call.audioType });
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play();
}
displayCall(call) {
console.log(`New call: ${call.system.label} > ${call.talkgroup.label}`);
}
disconnect() {
if (this.websocket) {
this.websocket.close();
}
}
}
// Usage
const client = new RdioScannerClient();
client.connect();
// Later...
client.downloadCall('12345');
client.playCall('12346');
client.searchCalls({ system: 1, talkgroup: 101 });
```
---
### Advanced: Filtered Livefeed Example
```javascript
class FilteredLivefeeds {
constructor(client) {
this.client = client;
this.filters = {};
}
// 📤 Subscribe to System 1, all talkgroups in Group 2
subscribeToSystemGroup(systemId, groupId) {
if (!this.filters[systemId]) {
this.filters[systemId] = {};
}
this.filters[systemId][groupId] = { '*': true };
this.updateFilters();
}
// 📤 Subscribe to specific talkgroup
subscribeToTalkgroup(systemId, groupId, talkgroupId) {
if (!this.filters[systemId]) {
this.filters[systemId] = {};
}
if (!this.filters[systemId][groupId]) {
this.filters[systemId][groupId] = {};
}
this.filters[systemId][groupId][talkgroupId] = true;
this.updateFilters();
}
// 📤 Unsubscribe from talkgroup
unsubscribeFromTalkgroup(systemId, groupId, talkgroupId) {
if (this.filters[systemId]?.[groupId]) {
delete this.filters[systemId][groupId][talkgroupId];
this.updateFilters();
}
}
updateFilters() {
this.client.send(['LFM', this.filters]);
}
}
// Usage
const livefeeds = new FilteredLivefeeds(client);
livefeeds.subscribeToTalkgroup(1, 2, 101); // System 1, Group 2, TG 101
livefeeds.subscribeToTalkgroup(1, 2, 102); // System 1, Group 2, TG 102
```
---
## Error Handling
### Common Issues
#### 1. Invalid JSON
**Problem:** Message is not valid JSON
**Server:** Logs warning: `"Invalid control message received"`
```javascript
// ❌ Wrong
websocket.send("VER");
// ✅ Correct
websocket.send(JSON.stringify(["VER"]));
```
---
#### 2. Session Expired
**Problem:** 📥 Received `["XPR", null]`
```javascript
if (command === 'XPR') {
console.log('Session expired, reconnecting...');
this.disconnect();
setTimeout(() => this.connect(), 1000);
}
```
---
#### 3. Maximum Clients Reached
**Problem:** Connection is immediately closed
**Solution:** Retry after delay or check max client limit
```javascript
websocket.onclose = (event) => {
if (event.code !== 1000) {
setTimeout(() => this.connect(), 5000); // Backoff
}
};
```
---
#### 4. Authentication Required
**Problem:** 📥 Received `["PIN", null]`
```javascript
if (command === 'PIN' && payload === null) {
// Prompt user for PIN
const pin = prompt('Enter your PIN:');
client.send(['PIN', pin]); // 📤 Send PIN
}
```
---
#### 5. Read/Write Timeouts
**Problem:** No pong received within 60 seconds
**Cause:** Network latency, firewall, or dead connection
```javascript
// Server sends pings every 54 seconds
// You should auto-respond with pong (most WebSocket libraries do this)
```
---
## Protocol Details
### Keep-Alive (Ping/Pong)
The server sends WebSocket PING frames every 54 seconds to detect dead connections. Most WebSocket implementations automatically respond with PONG.
**Server Side:**
```go
pingPeriod := pongWait / 10 * 9 // 54 seconds
pongWait := 60 * time.Second
ticker.NewTicker(pingPeriod)
client.Conn.WriteMessage(websocket.PingMessage, nil)
```
**Client Side:**
```javascript
// Automatically handled by browser WebSocket API
// No action needed unless using low-level library
```
---
### Message Serialization
Messages use a compact array format for bandwidth efficiency:
```go
// Server Go code
type Message struct {
Command any
Payload any
Flag any
}
func (message *Message) ToJson() ([]byte, error) {
str := []any{message.Command}
if message.Payload != nil && message.Payload != "" {
str = append(str, message.Payload)
}
if message.Flag != nil && message.Flag != "" {
str = append(str, message.Flag)
}
return json.Marshal(str)
}
// Produces:
// ["VER"] (command only)
// ["CFG", {...}] (command + payload)
// ["CAL", {...}, "DL"] (command + payload + flag)
```
---
### Admin Configuration WebSocket
For administrative functions, use a separate WebSocket endpoint with token authentication:
```
wss://[host]:[port]/api/admin/config?token=[token]
```
**Flow:**
```javascript
// 1. Connect to admin config endpoint
const adminWs = new WebSocket('wss://localhost:3000/api/admin/config?token=eyJ...');
// 2. Send token on open
adminWs.onopen = () => {
adminWs.send(token);
};
// 3. Receive config updates
adminWs.onmessage = (event) => {
const config = JSON.parse(event.data);
// Admin configuration received
};
```
---
## Performance Considerations
- **Max Clients:** Configurable; typical default is 1000
- **Message Queue per Client:** 8192 messages
- **Read Buffer:** 1024 bytes
- **Write Buffer:** 1024 bytes
- **Keepalive Interval:** 54 seconds
- **Connection Timeout:** 60 seconds (pong wait)
### Optimization Tips
1. **Livefeed Filtering** - Use `LFM` to reduce message volume
2. **Batch Requests** - Don't spam individual `CAL` requests
3. **Limit Search Results** - Use `offset` and `limit` in `LCL` commands
4. **Connection Pooling** - Use a single connection, don't reconnect frequently
5. **Message Throttling** - If client receives many messages, process in batches
---
## Reference
### Command Summary
| Command | Direction | Purpose |
|---------|-----------|---------|
| `VER` | 📤→ 📥 | Request/receive server version |
| `CFG` | 📤→ 📥 | Request/receive configuration |
| `CAL` | 📤→ 📥 | Request/receive call data |
| `LCL` | 📤→ 📥 | Search calls with filters |
| `PIN` | 📤→ 📥 | Authenticate with PIN |
| `LFM` | 📤 | Set livefeed filter |
| `PID` | 📤 | Register push device ID |
| `IOS` | 📤 | Send iOS metadata |
| `LSC` | 📥 | Listeners count broadcast |
| `MAX` | 📥 | Maximum limit notification |
| `XPR` | 📥 | Session expired notification |
| `SRV` | 📥 | Server status |
---
## Licensing & Support
For questions about WebSocket API usage:
- **Commercial Licensing:** [rdio-scanner@saubeo.solutions](mailto:rdio-scanner@saubeo.solutions)
- **Self-Hosted Instances:** A license agreement is required
- **Official Service:** Available through Saubeo Solutions
See [API_ACCESS_POLICY.md](../API_ACCESS_POLICY.md) for full terms.
---
**Happy Rdio scanning!**