Skip to main content

WebSocket API

Real-time bidirectional communication for live GPS updates, notifications, and platform events.

Protocol Formats

The Heimdall WebSocket API supports two message formats:

FormatUse CaseContent-Type
JSONWeb clients, debugging, general usetext messages
ProtobufDiscord bot, high-performance clientsbinary 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 (requires admin:read permission)
  • public / public:* - Public broadcast channels
  • gps - GPS data updates
  • notifications - System notifications
  • stats - Statistics updates
  • discord: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?;
Security Best Practice

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

  1. User logs in via NextAuth (credentials or OAuth)
  2. NextAuth creates a database session via POST /v1/sessions
  3. Session token is stored in session.accessToken
  4. WebSocket connection uses this token for authentication
  5. On logout, session is deleted via DELETE /v1/sessions/{token}

Channel Access Control (RBAC)

Channel subscriptions are controlled by role-based permissions:

Channel TypeAccess 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;
}
tip

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:

ValueDescription
apiHeimdall API (direct calls)
idHeimdall ID webapp
backendBackend dashboard
policiesPolicies webapp
discord_botDiscord bot
twitch_botTwitch 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

WebSocket Best Practices
  1. Implement reconnection - Handle disconnections gracefully with exponential backoff
  2. Respond to pings - Always respond to server ping messages to keep connection alive
  3. Track subscriptions - Remember subscribed channels for reconnection
  4. Handle errors - Implement proper error handling for all message types
  5. Clean up - Close connections when no longer needed
  6. Use compression - Enable WebSocket compression for bandwidth efficiency
  7. 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
}
}
}

Next Steps