25 KiB
Rdio Scanner WebSocket API Documentation
Last Updated: January 2026
Status: Proprietary Service (See API_ACCESS_POLICY.md for licensing)
Table of Contents
- Overview
- Connection
- Message Format
- Client Commands (Request)
- Server Responses & Events
- Message Flow Examples
- Client Event Handlers
- Practical Examples
- Error Handling
- 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 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
- Client initiates WebSocket connection to the server
- Server upgrades the connection using gorilla/websocket
- Server waits for configuration before registering the client
- Client sends
VER(version) andCFG(config request) commands - Upon receiving
CFGresponse, 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:
[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
// 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.
["VER"]
Parameters: None
Server Response:
["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.
["CFG"]
Parameters: None
Server Response:
["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.
["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:
["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.
["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 IDgroup(number): Filter by group IDtalkgroup(number): Filter by talkgroup IDtag(number): Filter by tag IDunit(number): Filter by unit IDdate(string): Filter by datekeyword(string): Text search in call metadata
Server Response:
["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.
["PIN", "1234"]
Parameters:
- pin (string, required): User's access PIN code
Server Response (on success):
["CFG", {...full config...}]
Server Response (on failure):
["PIN", null]
📤 LFM (Livefeed Map)
Description: Configure which systems, groups, and talkgroups to receive live call updates for. Only send updates for enabled feeds.
["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:
trueto enable,falseto disable
Example: Subscribe to live calls from System 1 (Police), Groups 2 & 3, specific talkgroups:
["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.
["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.
["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.
["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 playnull/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.
["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.
["VER", "7.0.0"]
📥 LSC (Listeners Count - Broadcast)
Description: Server broadcasts the current number of connected listeners (if enabled in config).
["LSC", 42]
📥 LFM (Livefeed Map - Broadcast)
Description: Server sends livefeed configuration or updates.
["LFM", {
"1": {
"2": {
"101": true,
"102": true
}
}
}]
📥 MAX (Maximum - Broadcast)
Description: Server-side limit notification (e.g., max concurrent connections reached).
["MAX", {
"type": "clients",
"current": 1000,
"limit": 1000
}]
📥 PIN (PIN Required - Broadcast)
Description: Server requests PIN authentication. Client must respond with ["PIN", "code"].
["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.
["XPR", null]
Action Required: Close connection and reconnect
📥 SRV (Server - Broadcast)
Description: Server status and metadata.
["SRV", {
"version": "7.0.0",
"uptime": 86400000,
"timestamp": 1705372800000
}]
Message Flow Examples
Example 1: Complete Connection Sequence
// 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
// 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
// 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
// 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
// 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.
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.
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.
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.
websocket.onerror = (error) => {
console.error("WebSocket error:", error);
};
Practical Examples
Basic Connection Setup with Auto-Reconnect
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
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"
// ❌ Wrong
websocket.send("VER");
// ✅ Correct
websocket.send(JSON.stringify(["VER"]));
2. Session Expired
Problem: 📥 Received ["XPR", null]
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
websocket.onclose = (event) => {
if (event.code !== 1000) {
setTimeout(() => this.connect(), 5000); // Backoff
}
};
4. Authentication Required
Problem: 📥 Received ["PIN", null]
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
// 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:
pingPeriod := pongWait / 10 * 9 // 54 seconds
pongWait := 60 * time.Second
ticker.NewTicker(pingPeriod)
client.Conn.WriteMessage(websocket.PingMessage, nil)
Client Side:
// 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:
// 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:
// 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
- Livefeed Filtering - Use
LFMto reduce message volume - Batch Requests - Don't spam individual
CALrequests - Limit Search Results - Use
offsetandlimitinLCLcommands - Connection Pooling - Use a single connection, don't reconnect frequently
- 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
- Self-Hosted Instances: A license agreement is required
- Official Service: Available through Saubeo Solutions
See API_ACCESS_POLICY.md for full terms.
Happy Rdio scanning!