WebSocket API
Real-time bidirectional communication for live GPS updates, notifications, and platform events.
The reusable WebSocket building blocks (message types, the WsServer pub/sub actor, AIS vessel tracking) live in the heimdall-websocket crate (crates/heimdall-websocket/). The live connection endpoint that clients connect to is the websocket handler in crates/heimdall-rest/src/handlers/ws.rs, registered as GET /ws inside the /v1 scope.
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 |
JSON is recommended for web applications and is the format used for all data broadcasts. Protobuf is the binary control-plane format optimized for service-to-service communication (e.g., the Discord bot).
Inbound control messages (subscribe, unsubscribe, audit event creation) may be sent as Protobuf binary by service-to-service clients. However, the outbound data broadcasts — gpsUpdate, vesselsUpdate, and geofenceEvent — are always delivered as JSON text messages. Do not expect GPS/vessels/geofence data over the Protobuf channel.
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). Only the owning user may subscribe.admin:*- Admin broadcast channels (requiresadmin:readpermission or super admin)public/public:*- Public broadcast channels (any authenticated client)gps/gps:*- GPS data updates (requiresgps:readpermission)geofence/geofence:*- Geofence enter/exit events (requiresgeofences:readpermission)
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
Generic channel message. Used for subscribe confirmations and relayed text. The data field is a string, not an object.
{
"type": "message",
"channel": "gps",
"data": "Subscribed to gps"
}
GPS Update
Real-time GPS position broadcast to subscribers of the gps channel. The data field is a JSON object with snake_case fields and ISO-8601 string timestamps. Note: the message does not carry a channel field — match on type === "gpsUpdate".
{
"type": "gpsUpdate",
"data": {
"id": "71eebc99-9c0b-4ef8-bb6d-6bb9bd380a24",
"device_id": "boat-01",
"trip_id": "8f1c9e2a-3b4d-4f6a-9c7e-1a2b3c4d5e6f",
"latitude": 53.5396,
"longitude": 8.5809,
"altitude": 0.0,
"heading": 182.4,
"timestamp": "2026-06-27T06:00:00Z",
"speed_kmh": 12.3,
"speed_mps": 3.42,
"speed_mph": 7.64,
"speed_knots": 6.64,
"pdop": 1.2,
"hdop": 0.8,
"vdop": 0.9,
"created_at": "2026-06-27T06:00:00Z"
}
}
device_id and trip_id may be null. Match on message.type === "gpsUpdate".
Vessels Update (AIS)
Nearby AIS vessel positions from the aisstream.io feed. The data field is an object containing a vessels array (snake_case fields). These messages are broadcast on the internal vessels channel by the AIS tracker.
{
"type": "vesselsUpdate",
"data": {
"vessels": [
{
"mmsi": 211234560,
"name": "MS Example",
"ship_type": 70,
"ship_type_label": "Cargo",
"nav_status": 0,
"nav_status_label": "Under way using engine",
"length": 180,
"beam": 25,
"lat": 53.54,
"lng": 8.58,
"heading": 182,
"course": 180.0,
"speed_kmh": 18.5
}
]
}
}
ship_type, nav_status, length, beam, heading, and course may be null. ship_type_label / nav_status_label are human-readable strings derived from the numeric codes (ITU-R M.1371).
Geofence Event
Geofence enter/exit event broadcast to subscribers of the geofence channel.
{
"type": "geofenceEvent",
"data": {
"geofence_id": "41eebc99-9c0b-4ef8-bb6d-6bb9bd380a21",
"geofence_name": "Harbor",
"event": "enter",
"device_id": "boat-01",
"distance_km": 0.12,
"metadata": null
}
}
Error
Error response when a request fails.
{
"type": "error",
"message": "Access denied to channel: gps"
}
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/heimdall-proto/.
WsEnvelope Structure
All protobuf messages are wrapped in a WsEnvelope. The excerpt below is abbreviated — the proto also defines audit (create_audit_event = 100, create_audit_event_response = 101), Discord sync (110/111), Discord settings (120/121), and moderation (140/141) payloads. See platform/proto/heimdall.proto for the full definition.
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 a WebSocket protocol-level Ping frame every 5 seconds (HEARTBEAT_INTERVAL). If the client does not reply with a protocol Pong frame within 10 seconds (CLIENT_TIMEOUT), the server closes the connection.
These are control frames at the WebSocket protocol layer, not JSON {"type":"ping"} messages. Standard clients respond to them automatically and require no application code:
- Browsers (
WebSocket) reply to Ping frames automatically. - Python
websocket-client(run_forever) and Rusttokio-tungstenitereply automatically.
Application-level ping (optional)
Separately, a client may send a JSON {"type":"ping"} message and the server replies with {"type":"pong"}. This is purely optional request/response sugar — it does not drive the heartbeat (only the protocol-level Pong frame resets the timeout), and the server never initiates a JSON ping.
For protobuf clients, sending a Ping envelope likewise yields a Pong envelope:
// Send an application-level protobuf ping; server replies with a Pong envelope.
let ping = WsEnvelope {
r#type: WsMessageType::WsMessageTypePing as i32,
payload: Some(Payload::Ping(Ping {})),
};
ws.send(Message::Binary(ping.encode_to_vec())).await?;
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);
// GPS broadcasts arrive as `gpsUpdate` with an object `data` (no `channel` field)
if (message.type === 'gpsUpdate') {
const gpsData = message.data;
console.log('New GPS location:', gpsData.latitude, gpsData.longitude);
// Update map, UI, etc.
}
};
Geofence Events
Subscribe to geofence enter/exit events (requires geofences:read):
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'geofence'
}));
Geofence events arrive as geofenceEvent messages with an object data payload.
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 geofence events
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'geofence'
}));
};
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 'gpsUpdate':
// `data` is an object, not a stringified JSON string
this.notifySubscribers(message.data);
break;
case 'message':
// Generic channel message (e.g. subscribe confirmation); `data` is a string
console.log('Channel message:', message.channel, message.data);
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 (API keys cannot) |
admin:* | Requires admin:read permission or super_admin role |
public / public:* | Anyone authenticated can subscribe |
gps / gps:* | Requires gps:read permission |
geofence / geofence:* | Requires geofences:read permission |
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
GPS updates are delivered to web clients as JSON text. The envelope type is camelCase (gpsUpdate), but the fields inside the data object are snake_case, and timestamps are ISO-8601 strings:
JSON (wire format):
{
"type": "gpsUpdate",
"data": {
"id": "71eebc99-9c0b-4ef8-bb6d-6bb9bd380a24",
"device_id": "boat-01",
"trip_id": "8f1c9e2a-3b4d-4f6a-9c7e-1a2b3c4d5e6f",
"latitude": 53.5396,
"longitude": 8.5809,
"altitude": 0.0,
"heading": 182.4,
"timestamp": "2026-06-27T06:00:00Z",
"speed_kmh": 12.3,
"speed_mps": 3.42,
"speed_mph": 7.64,
"speed_knots": 6.64,
"pdop": 1.2,
"hdop": 0.8,
"vdop": 0.9,
"created_at": "2026-06-27T06:00:00Z"
}
}
device_id and trip_id may be null.
Protobuf (control plane only): The Protobuf GpsUpdate message exists for the service-to-service control plane. It is not the format used for web client data broadcasts (those are always JSON text, as shown above).
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 {
string user_id = 1; // Heimdall user ID (empty string = system event)
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 {
bool success = 1; // Whether the event was created
optional string audit_event_id = 2; // Created audit event ID (on success)
optional string error = 3; // Error message (on failure)
}
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::CreateAuditEvent(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
- Keep the heartbeat alive - The server pings at the WebSocket protocol level every 5s; standard clients (browsers, tokio-tungstenite, python websocket-client) auto-reply with Pong frames. Avoid blocking the client event loop so these replies are sent within the 10s timeout
- 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
}
}
}