WebSocket API
Real-time bidirectional communication for live GPS updates, notifications, and platform events.
Protocol Formats
The Heimdall WebSocket API supports two message formats:
| Format | Use Case | Content-Type |
|---|---|---|
| JSON | Web clients, debugging, general use | text messages |
| Protobuf | Discord bot, high-performance clients | binary messages |
Both formats support the same message types and semantics. JSON is recommended for web applications; Protobuf is optimized for service-to-service communication (e.g., Discord bot).
Endpoint
ws://localhost:3000/v1/ws
wss://api.elcto.com/v1/ws
Use ws:// for local development and wss:// (secure WebSocket) for production.
JSON Protocol
All JSON messages have a type field identifying the message type.
Connection
JavaScript
const ws = new WebSocket('wss://api.elcto.com/v1/ws');
ws.onopen = () => {
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
};
Python
import websocket
import json
def on_message(ws, message):
data = json.loads(message)
print(f"Received: {data}")
def on_open(ws):
print("WebSocket connected")
# Subscribe to GPS channel
ws.send(json.dumps({
"type": "Subscribe",
"channel": "gps"
}))
ws = websocket.WebSocketApp(
"wss://api.elcto.com/v1/ws",
on_open=on_open,
on_message=on_message
)
ws.run_forever()
Message Types
Client → Server Messages
Subscribe to Channel
Subscribe to receive updates from a specific channel.
{
"type": "Subscribe",
"channel": "gps"
}
Available Channels:
user:{userId}- User-specific updates (permissions, roles, account status)admin:*- Admin broadcast channels (requiresadmin:readpermission)public/public:*- Public broadcast channelsgps- GPS data updatesnotifications- System notificationsstats- Statistics updatesdiscord:events- Discord bot events (user linked/unlinked, permission changes)
Unsubscribe from Channel
Stop receiving updates from a channel.
{
"type": "Unsubscribe",
"channel": "gps"
}
Ping
Send a heartbeat ping to keep the connection alive.
{
"type": "Ping"
}
Server → Client Messages
Pong
Response to a ping message.
{
"type": "Pong"
}
Message
Data update from a subscribed channel.
{
"type": "Message",
"channel": "gps",
"data": "{\"id\":\"abc-123\",\"latitude\":51.5074,\"longitude\":-0.1278}"
}
Error
Error response when a request fails.
{
"type": "Error",
"message": "Channel not found"
}
Protobuf Protocol
For high-performance communication, the API supports Protocol Buffers (protobuf) as a binary message format.
Proto Definition
The proto file is located at platform/proto/heimdall.proto.
Automatic Generation: Proto code is generated automatically at build time via build.rs in the heimdall-proto crate. Simply run cargo build and the proto types are regenerated if the .proto file changed.
# Proto generation is automatic, but you can force a rebuild with:
just proto
All services (API, Discord bot, Twitch bot) use the shared heimdall-proto crate at crates/proto/.
WsEnvelope Structure
All protobuf messages are wrapped in a WsEnvelope:
message WsEnvelope {
WsMessageType type = 1;
oneof payload {
// Connection management
Ping ping = 10;
Pong pong = 11;
// Channel subscriptions
Subscribe subscribe = 20;
Unsubscribe unsubscribe = 21;
// Error messages
Error error = 30;
// User account events
AccountDeleted account_deleted = 40;
AccountBanned account_banned = 41;
SessionRevoked session_revoked = 42;
ForceLogout force_logout = 43;
OAuthConsentRevoked oauth_consent_revoked = 44;
// Permission/role events
RolesUpdated roles_updated = 50;
PermissionsUpdated permissions_updated = 51;
RolePermissionsChanged role_permissions_changed = 52;
// Account link events
EmailLinkVerified email_link_verified = 60;
EmailChangeVerified email_change_verified = 61;
// Data payloads
GpsUpdate gps_update = 70;
// Discord bot specific events
DiscordUserLinked discord_user_linked = 80;
DiscordUserUnlinked discord_user_unlinked = 81;
DiscordPermissionChanged discord_permission_changed = 82;
}
}
Message Type Enum
enum WsMessageType {
WS_MESSAGE_TYPE_UNSPECIFIED = 0;
// Connection management
WS_MESSAGE_TYPE_PING = 1;
WS_MESSAGE_TYPE_PONG = 2;
// Subscriptions
WS_MESSAGE_TYPE_SUBSCRIBE = 10;
WS_MESSAGE_TYPE_UNSUBSCRIBE = 11;
// Errors
WS_MESSAGE_TYPE_ERROR = 20;
// User account events
WS_MESSAGE_TYPE_ACCOUNT_DELETED = 30;
WS_MESSAGE_TYPE_ACCOUNT_BANNED = 31;
WS_MESSAGE_TYPE_SESSION_REVOKED = 32;
WS_MESSAGE_TYPE_FORCE_LOGOUT = 33;
WS_MESSAGE_TYPE_OAUTH_CONSENT_REVOKED = 34;
// Permission/role events
WS_MESSAGE_TYPE_ROLES_UPDATED = 40;
WS_MESSAGE_TYPE_PERMISSIONS_UPDATED = 41;
WS_MESSAGE_TYPE_ROLE_PERMISSIONS_CHANGED = 42;
// Account link events
WS_MESSAGE_TYPE_EMAIL_LINK_VERIFIED = 50;
WS_MESSAGE_TYPE_EMAIL_CHANGE_VERIFIED = 51;
// Data payloads
WS_MESSAGE_TYPE_GPS_UPDATE = 60;
// Discord bot specific
WS_MESSAGE_TYPE_DISCORD_USER_LINKED = 70;
WS_MESSAGE_TYPE_DISCORD_USER_UNLINKED = 71;
WS_MESSAGE_TYPE_DISCORD_PERMISSION_CHANGED = 72;
}
Rust Usage Example
use prost::Message;
use crate::proto::{WsEnvelope, WsMessageType, Subscribe};
// Create a subscribe message
let envelope = WsEnvelope {
r#type: WsMessageType::WsMessageTypeSubscribe as i32,
payload: Some(ws_envelope::Payload::Subscribe(Subscribe {
channel: "gps".to_string(),
})),
};
// Encode to bytes
let bytes = envelope.encode_to_vec();
// Send as binary WebSocket message
ws.send(Message::Binary(bytes)).await?;
Decoding Messages
use prost::Message;
use crate::proto::{WsEnvelope, WsMessageType, ws_envelope::Payload};
// Decode incoming binary message
let envelope = WsEnvelope::decode(bytes.as_ref())?;
match envelope.payload {
Some(Payload::GpsUpdate(update)) => {
println!("GPS: {}, {}", update.latitude, update.longitude);
}
Some(Payload::RolesUpdated(roles)) => {
println!("User {} roles updated", roles.user_id);
}
Some(Payload::Error(err)) => {
eprintln!("Error: {}", err.message);
}
_ => {}
}
Protocol Detection
The server automatically detects the message format:
- Text messages → Parsed as JSON
- Binary messages → Parsed as Protobuf
Clients can mix formats in the same connection, though this is not recommended.
Heartbeat
The server sends automatic ping messages every 5 seconds to keep the connection alive. Clients should respond with pong messages, or the connection will be closed after 10 seconds of inactivity.
Automatic Heartbeat Handling (JSON)
const ws = new WebSocket('wss://api.elcto.com/v1/ws');
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
// Auto-respond to ping
if (message.type === 'Ping') {
ws.send(JSON.stringify({ type: 'Pong' }));
return;
}
// Handle other messages
handleMessage(message);
};
Automatic Heartbeat Handling (Protobuf)
match envelope.payload {
Some(Payload::Ping(_)) => {
let pong = WsEnvelope {
r#type: WsMessageType::WsMessageTypePong as i32,
payload: Some(Payload::Pong(Pong {})),
};
ws.send(Message::Binary(pong.encode_to_vec())).await?;
}
// ... handle other messages
}
Channel Subscriptions
GPS Updates
Subscribe to real-time GPS location updates:
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'Subscribe',
channel: 'gps'
}));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'Message' && message.channel === 'gps') {
const gpsData = JSON.parse(message.data);
console.log('New GPS location:', gpsData);
// Update map, UI, etc.
}
};
Notifications
Subscribe to system notifications:
ws.send(JSON.stringify({
type: 'Subscribe',
channel: 'notifications'
}));
Multiple Subscriptions
You can subscribe to multiple channels on the same connection:
ws.onopen = () => {
// Subscribe to GPS updates
ws.send(JSON.stringify({
type: 'Subscribe',
channel: 'gps'
}));
// Subscribe to notifications
ws.send(JSON.stringify({
type: 'Subscribe',
channel: 'notifications'
}));
};
Connection Management
Reconnection Strategy
Implement automatic reconnection for production applications:
class WebSocketClient {
constructor(url) {
this.url = url;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectDelay = 1000;
this.resubscribe();
};
this.ws.onclose = () => {
console.log('Disconnected, reconnecting...');
setTimeout(() => {
this.reconnectDelay = Math.min(
this.reconnectDelay * 2,
this.maxReconnectDelay
);
this.connect();
}, this.reconnectDelay);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onmessage = (event) => {
this.handleMessage(JSON.parse(event.data));
};
}
resubscribe() {
// Resubscribe to channels after reconnection
this.subscriptions.forEach(channel => {
this.subscribe(channel);
});
}
subscribe(channel) {
this.subscriptions.add(channel);
this.ws.send(JSON.stringify({
type: 'Subscribe',
channel
}));
}
handleMessage(message) {
if (message.type === 'Ping') {
this.ws.send(JSON.stringify({ type: 'Pong' }));
return;
}
// Handle other message types
console.log('Received:', message);
}
}
// Usage
const client = new WebSocketClient('wss://api.elcto.com/v1/ws');
client.subscribe('gps');
Complete Example (JSON)
class GpsTracker {
constructor() {
this.ws = null;
this.connected = false;
this.subscribers = new Set();
this.connect();
}
connect() {
this.ws = new WebSocket('wss://api.elcto.com/v1/ws');
this.ws.onopen = () => {
console.log('WebSocket connected');
this.connected = true;
// Subscribe to GPS updates
this.send({
type: 'Subscribe',
channel: 'gps'
});
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.connected = false;
// Attempt reconnection after 3 seconds
setTimeout(() => this.connect(), 3000);
};
}
handleMessage(message) {
switch (message.type) {
case 'Ping':
this.send({ type: 'Pong' });
break;
case 'Message':
if (message.channel === 'gps') {
const gpsData = JSON.parse(message.data);
this.notifySubscribers(gpsData);
}
break;
case 'Error':
console.error('Server error:', message.message);
break;
}
}
send(data) {
if (this.connected && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
subscribe(callback) {
this.subscribers.add(callback);
}
unsubscribe(callback) {
this.subscribers.delete(callback);
}
notifySubscribers(data) {
this.subscribers.forEach(callback => callback(data));
}
}
// Usage
const tracker = new GpsTracker();
tracker.subscribe((gpsData) => {
console.log('New location:', gpsData.latitude, gpsData.longitude);
// Update your map, UI, etc.
});
Authentication
WebSocket connections require authentication via a session token. The token can be passed as a query parameter or via cookie.
Query Parameter Authentication
const sessionToken = 'sess_abc123...'; // From NextAuth session
const ws = new WebSocket(`wss://api.elcto.com/v1/ws?token=${sessionToken}`);
API Key Authentication (Service-to-Service)
For service-to-service communication (e.g., Discord bot), use an API key via the Authorization header during the WebSocket handshake:
use tokio_tungstenite::{connect_async, tungstenite::http::Request};
// Build request with Authorization header (more secure than URL token)
let request = Request::builder()
.uri("wss://api.elcto.com/v1/ws")
.header("Authorization", format!("Bearer {}", api_key))
.header("Host", "api.elcto.com")
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.body(())
.unwrap();
let (ws_stream, _) = connect_async(request).await?;
Using the Authorization header is more secure than URL query parameters because tokens don't appear in server logs, proxy logs, or browser history.
Using the Heimdall API Client
import { createWebSocket } from '@/lib/api/websocket';
const ws = createWebSocket('/v1/ws', {
accessToken: session.accessToken, // From NextAuth session
autoReconnect: true,
reconnectDelay: 5000,
maxReconnectAttempts: 10,
});
ws.onMessage((message) => {
console.log('Received:', message);
});
Authentication Flow
- User logs in via NextAuth (credentials or OAuth)
- NextAuth creates a database session via
POST /v1/sessions - Session token is stored in
session.accessToken - WebSocket connection uses this token for authentication
- On logout, session is deleted via
DELETE /v1/sessions/{token}
Channel Access Control (RBAC)
Channel subscriptions are controlled by role-based permissions:
| Channel Type | Access Rule |
|---|---|
user:{userId} | Only the user can subscribe to their own channel |
admin:* | Requires admin:read permission or super_admin role |
public / public:* | Anyone authenticated can subscribe |
Example: User Channel
// Auto-subscribed when authenticated - receives permission/role updates
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'permissionsUpdated') {
// User's permissions changed
updateLocalPermissions(message.permissions);
}
if (message.type === 'rolesUpdated') {
// User's roles changed
updateLocalRoles(message.roles);
}
};
User Status Events
The WebSocket automatically delivers user status events for account management:
Account Deleted
Sent when a user's account is deleted (scheduled deletion completed).
JSON:
{
"type": "accountDeleted",
"userId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"reason": "Account deleted",
"timestamp": "2025-01-25T10:00:00Z"
}
Protobuf:
message AccountDeleted {
string user_id = 1;
optional string reason = 2;
google.protobuf.Timestamp timestamp = 3;
}
Account Banned
Sent when a user is banned by an administrator.
JSON:
{
"type": "accountBanned",
"userId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"reason": "Terms of service violation",
"expiresAt": "2025-02-25T10:00:00Z",
"isPermanent": false,
"timestamp": "2025-01-25T10:00:00Z"
}
Protobuf:
message AccountBanned {
string user_id = 1;
optional string reason = 2;
optional google.protobuf.Timestamp expires_at = 3;
bool is_permanent = 4;
google.protobuf.Timestamp timestamp = 5;
}
Session Revoked
Sent when a specific session is revoked.
JSON:
{
"type": "sessionRevoked",
"userId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"sessionId": "e0eebc99-9c0b-4ef8-bb6d-6bb9bd380a15",
"reason": "Password changed",
"timestamp": "2025-01-25T10:00:00Z"
}
Force Logout
Sent when all sessions should be terminated (e.g., security concern).
JSON:
{
"type": "forceLogout",
"userId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"reason": "Security concern",
"timestamp": "2025-01-25T10:00:00Z"
}
Handling User Status Events
ws.onMessage((message) => {
// Handle user account status events
if (
message.type === 'accountDeleted' ||
message.type === 'accountBanned' ||
message.type === 'sessionRevoked' ||
message.type === 'forceLogout'
) {
// Close WebSocket and sign out
ws.close();
const errorMap = {
accountDeleted: 'AccountDeleted',
accountBanned: 'AccountBanned',
sessionRevoked: 'SessionRevoked',
forceLogout: 'ForceLogout',
};
signOut({ callbackUrl: `/login?error=${errorMap[message.type]}` });
return;
}
// Handle other messages...
});
Permission & Role Updates
Real-time permission and role changes are broadcast to affected users:
Permissions Updated
JSON:
{
"type": "permissionsUpdated",
"userId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"permissions": ["users:read", "gps:read", "gps:write"]
}
Protobuf:
message PermissionsUpdated {
string user_id = 1;
repeated string permissions = 2;
google.protobuf.Timestamp timestamp = 3;
}
Roles Updated
JSON:
{
"type": "rolesUpdated",
"userId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"roles": ["Admin", "Developer"],
"roleIds": ["role_admin", "role_developer"],
"timestamp": "2025-01-25T10:00:00Z"
}
Protobuf:
message RolesUpdated {
string user_id = 1;
repeated string roles = 2; // Role names for display
repeated string role_ids = 3; // Role IDs for programmatic checks
google.protobuf.Timestamp timestamp = 4;
}
Use roleIds for programmatic checks instead of roles (names). Role IDs are immutable and stable.
Role Permissions Changed
Sent when a role's permissions are modified (triggers refresh):
{
"type": "rolePermissionsChanged",
"roleId": "41eebc99-9c0b-4ef8-bb6d-6bb9bd380a21",
"roleName": "viewer"
}
Discord Bot Events (Protobuf Only)
These events are designed for the Discord bot to maintain permission cache:
Discord User Linked
Sent when a Discord account is linked to a Heimdall user.
message DiscordUserLinked {
string discord_id = 1; // Discord user ID (snowflake)
string user_id = 2; // Heimdall user ID
string discord_username = 3; // Discord username
optional string avatar_url = 4; // Discord avatar URL
google.protobuf.Timestamp timestamp = 5;
}
Discord User Unlinked
Sent when a Discord account is unlinked from a Heimdall user.
message DiscordUserUnlinked {
string discord_id = 1; // Discord user ID (snowflake)
string user_id = 2; // Heimdall user ID
google.protobuf.Timestamp timestamp = 3;
}
Discord Permission Changed
Sent when a Discord user's permissions change (invalidate bot cache).
message DiscordPermissionChanged {
string discord_id = 1; // Discord user ID (snowflake)
string user_id = 2; // Heimdall user ID
repeated string permissions = 3; // New permission list
google.protobuf.Timestamp timestamp = 4;
}
GPS Update Events
GPS Update Structure
JSON:
{
"type": "gpsUpdate",
"trackerId": "71eebc99-9c0b-4ef8-bb6d-6bb9bd380a24",
"latitude": 51.5074,
"longitude": -0.1278,
"altitude": 45.2,
"speed": 12.5,
"heading": 180.0,
"timestamp": "2025-01-25T10:00:00Z"
}
Protobuf:
message GpsUpdate {
string tracker_id = 1;
double latitude = 2;
double longitude = 3;
optional double altitude = 4;
optional double speed = 5;
optional double heading = 6;
google.protobuf.Timestamp timestamp = 7;
}
Audit Event Logging (Service-to-Service)
Services like the Discord bot can create audit events directly via WebSocket using Protobuf messages. This is more efficient than REST API calls for high-frequency event logging.
Create Audit Event
Request (Client → Server):
message CreateAuditEventRequest {
optional string user_id = 1; // Heimdall user ID (if known)
string event_type = 2; // e.g., "bot_command_executed"
optional string resource_type = 3; // e.g., "discord_command"
optional string resource_id = 4; // e.g., command name
optional string actor_id = 5; // Actor performing action (if different)
optional string ip_address = 6;
optional string user_agent = 7;
optional string description = 8;
optional string metadata_json = 9; // JSON-encoded metadata
optional string status = 10; // "success" or "failure"
optional string error_message = 11;
optional string source_service = 12; // Service creating the event
}
Response (Server → Client):
message CreateAuditEventResponse {
string id = 1; // Created audit event ID
string event_type = 2;
optional string user_id = 3;
google.protobuf.Timestamp created_at = 4;
}
Source Service Tracking
The source_service field identifies which service created the audit event:
| Value | Description |
|---|---|
api | Heimdall API (direct calls) |
id | Heimdall ID webapp |
backend | Backend dashboard |
policies | Policies webapp |
discord_bot | Discord bot |
twitch_bot | Twitch bot |
Rust Example (Discord Bot)
use heimdall_proto::{
WsEnvelope, WsMessageType, CreateAuditEventRequest,
ws_envelope::Payload,
};
// Create audit event for bot command
let event = CreateAuditEventRequest {
user_id: Some(heimdall_user_id), // From platform lookup
event_type: "bot_command_executed".to_string(),
resource_type: Some("discord_command".to_string()),
resource_id: Some(command_name.to_string()),
description: Some(format!("Executed /{} command", command_name)),
metadata_json: Some(serde_json::json!({
"discord_id": discord_user_id,
"guild_id": guild_id,
"command": command_name,
}).to_string()),
status: Some("success".to_string()),
source_service: Some("discord_bot".to_string()),
..Default::default()
};
let envelope = WsEnvelope {
r#type: WsMessageType::WsMessageTypeCreateAuditEvent as i32,
payload: Some(Payload::CreateAuditEventRequest(event)),
};
ws.send(Message::Binary(envelope.encode_to_vec())).await?;
Bot Command Event Type
The bot_command_executed event type is used for tracking bot command usage:
{
"eventType": "bot_command_executed",
"resourceType": "discord_command",
"resourceId": "help",
"description": "Executed /help command",
"metadata": {
"discord_id": "123456789012345678",
"guild_id": "987654321098765432",
"command": "help"
},
"sourceService": "discord_bot"
}
User ID Resolution
For Discord/Twitch bots, the Heimdall user ID must be resolved from the platform user ID:
query FindUserByPlatform($platformSlug: String!, $platformUserId: String!) {
findUserByPlatform(platformSlug: $platformSlug, platformUserId: $platformUserId)
}
This returns the Heimdall user ID if the platform account is linked, allowing audit events to be properly associated with user accounts.
Rate Limiting
WebSocket connections are not rate-limited, but excessive message sending may result in connection closure.
Best Practices
- Implement reconnection - Handle disconnections gracefully with exponential backoff
- Respond to pings - Always respond to server ping messages to keep connection alive
- Track subscriptions - Remember subscribed channels for reconnection
- Handle errors - Implement proper error handling for all message types
- Clean up - Close connections when no longer needed
- Use compression - Enable WebSocket compression for bandwidth efficiency
- Choose the right format - Use JSON for web clients, Protobuf for high-performance services
Monitoring
Track WebSocket connection status via the health endpoint:
curl https://api.elcto.com/health
Response:
{
"status": "healthy",
"services": {
"websocket": {
"status": "up",
"connections": 42,
"subscriptions": 128
}
}
}