Skip to main content

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:

FormatUse CaseContent-Type
JSONWeb clients, debugging, general usetext messages
ProtobufDiscord bot, high-performance clientsbinary 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).

Data broadcasts are JSON text

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​

Browsers authenticate with a short-lived, single-use ticket carried in the Sec-WebSocket-Protocol header (the browser WebSocket API cannot set an Authorization header). Non-browser clients send a normal Authorization: Bearer header on the upgrade. Connecting with no credential yields an anonymous session (may subscribe only to public* / vessels). See Authentication for the full flow — the ?token= query parameter and cookie auth have been removed.

JavaScript (browser — ticket flow)​

// 1. Mint a fresh single-use ticket over the user's bearer token.
// (Or use the GraphQL `mintWsTicket` mutation — same result.)
const res = await fetch('https://api.elcto.com/v1/ws/ticket', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
const { ticket } = await res.json(); // { ticket, expiresIn }

// 2. Carry the ticket as a subprotocol. The sentinel "heimdall-ticket" tells the
// server the next value is a ticket; the server echoes back only the sentinel.
const ws = new WebSocket('wss://api.elcto.com/v1/ws', ['heimdall-ticket', ticket]);

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');
};

Tickets are single-use: mint a fresh one before every (re)connect. @elcto/api's createWebSocket does this for you.

Python (non-browser — header auth)​

Non-browser clients (bots, servers) skip the ticket and send the bearer credential directly as an Authorization header on the handshake:

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",
header=["Authorization: Bearer <API_KEY_OR_TOKEN>"],
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 — the {userId} must equal the authenticated user's own id.
  • session:{sessionId} - Session-specific updates. Only the owning session may subscribe — the {sessionId} must equal the connection's own database session id.
  • public / public:* - Public broadcast channels (open — no authentication check beyond the connection itself)
  • vessels - Nearby AIS vessel positions (open, same as public)
  • admin:* - Admin broadcast channels (requires admin:read permission or super admin)
  • gps / gps:* - GPS data updates (requires gps:read permission)
  • geofence / geofence:* - Geofence enter/exit events (requires geofences:read permission)
  • discord:* - Discord bot control channels (system API key only — see Channel Access Control)
  • Anything else (including device:*) - denied by default (default-closed authorization)

A subscribe to a channel the caller isn't authorized for is rejected with an Error frame ({"type":"error","message":"Access denied to channel: <channel>"}) and the subscription is not registered. See Channel Access Control for the full authorization model, which applies identically to this JSON path and the Protobuf path.

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. The type tag is lowercase "error" (camelCase of the Rust WsMessage::Error variant).

{
"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 Rust tokio-tungstenite reply 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​

A WebSocket connection is authenticated at the GET /v1/ws upgrade by exactly one of, in order:

  1. Ticket subprotocol (browsers) — a single-use ticket carried as a Sec-WebSocket-Protocol entry. The server consumes the ticket atomically and re-resolves the raw credential it wraps via the normal token authentication path.
  2. Authorization: Bearer header (non-browser clients — bots, servers).
  3. No credential → anonymous. This is not a 401: the connection is accepted as an anonymous session that may subscribe only to public / public:* / vessels (any other channel is denied). See Channel Access Control.

The legacy ?token= query parameter and the cookie path have been removed — browsers now mint a ticket instead.

Ticket Authentication (Browsers)​

The browser WebSocket API cannot set request headers, so credentials are carried as a subprotocol. First mint a short-lived, single-use ticket over the user's own bearer token, then open the socket offering heimdall-ticket plus the ticket as subprotocols:

  • REST: POST /v1/ws/ticket with Authorization: Bearer <token> → { "ticket": "wst_...", "expiresIn": 30 }
  • GraphQL: mintWsTicket mutation → { ticket, expiresIn }

Both require a user context (a session or OAuth access token); an API-key-only credential is rejected 403 (tickets exist to keep a browser session's credential off the WS URL/log surface, not to proxy API-key auth).

// Mint (REST). expiresIn mirrors [websocket].ticket_ttl_secs (default 30s).
const { ticket } = await fetch('https://api.elcto.com/v1/ws/ticket', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
}).then((r) => r.json());

// The sentinel "heimdall-ticket" marks the following value as a ticket; the server
// echoes back only the sentinel to complete the handshake (the ticket is never echoed).
const ws = new WebSocket('wss://api.elcto.com/v1/ws', ['heimdall-ticket', ticket]);

Tickets are single-use and short-lived (ticket_ttl_secs, default 30s): mint a fresh one before every connect and every reconnect. Never reuse a ticket.

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.

User Identity Relay (?userId=)​

A system API key may additionally pass a ?userId=<uuid> query parameter on the GET /v1/ws upgrade to connect as that user — their user:{id} channel access, and the identity recorded as the audit actor for anything sent over that connection. This is the mechanism the Discord bot / webapp relay uses to act on behalf of a specific Heimdall user without holding that user's own session token.

wss://api.elcto.com/v1/ws?userId=6ba7b810-9dad-11d1-80b4-00c04fd430c8

(sent with Authorization: Bearer <SYSTEM_API_KEY>)

  • Only a system API key may use ?userId=. Any other credential — a non-system API key or a user session — presenting ?userId= is rejected 403 Forbidden ("userId override requires a system API key"); it must not be able to forge another user's identity.
  • The target user must exist and be neither deleted nor banned, or the connection is rejected (401 Unauthorized / 403 Forbidden).
  • A successful override is recorded as a ws_identity_override audit event carrying both identities: user_id is the impersonated user, actor_id is the system key that performed the relay.
  • A system key with no ?userId= connects as the raw system context, unchanged.
  • /v1/ws is on the system-key route allowlist, so the system key can reach this upgrade at all; the ?userId= check above is a second, independent gate inside the handler itself.

Using the Heimdall API Client​

@elcto/api's createWebSocket hides the ticket flow: given an accessToken, it mints a fresh single-use ticket before every (re)connect and carries it via the subprotocol. Pass the API config as the first argument (no URL argument).

import { getApiConfig, createWebSocket } from '@/lib/api';

const ws = createWebSocket(getApiConfig(), {
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); the session token is stored in session.accessToken.
  2. Before opening the socket, the client mints a single-use ticket (POST /v1/ws/ticket / mintWsTicket) over that token.
  3. The client opens wss://.../v1/ws offering ['heimdall-ticket', ticket] as subprotocols; the server consumes the ticket and resolves the wrapped credential.
  4. On reconnect (including after the max-lifetime cutoff), the client mints a fresh ticket and repeats — tickets are never reused.
  5. On logout, the session is deleted via DELETE /v1/sessions/{token}.

Connection Limits & Lifetime​

  • Per-IP connection cap. Concurrent connections are capped per client IP (keyed on the real TCP peer address). Authenticated sessions use max_connections_per_ip (default 20); anonymous sessions use the smaller max_anon_connections_per_ip (default 5). A handshake that would exceed the cap is rejected 429 Too Many Requests.
  • Max connection lifetime. The server stops each socket after max_connection_lifetime_secs (default 3600s = 1h), even if healthy. The client should re-handshake with a fresh ticket; @elcto/api's createWebSocket auto-reconnects and re-mints.

These limits are configured under [websocket] — see Configuration → [websocket].


Channel Access Control (RBAC)​

Every Subscribe is authorized by a single, central, default-closed gate (authorize_channel_subscribe in crates/heimdall-rest/src/handlers/ws.rs) before the channel is registered. This gate is shared by both the JSON Subscribe message path and the Protobuf Subscribe payload — a client cannot bypass channel authorization by switching wire formats.

Channel TypeAccess Rule
user:{userId}Own-only: only the authenticated user (not an API key's user_id) whose id equals {userId} may subscribe
session:{sessionId}Own-only: only the connection's own database session id may subscribe
public / public:*Open — no permission check
vesselsOpen — no permission check (AIS public data)
admin:*Requires admin:read permission or super_admin role
gps / gps:*Requires gps:read permission
geofence / geofence:*Requires geofences:read permission
discord:*System API key only (is_system = true) — used by the Discord bot's discord:bot / discord:events channels
Anything else (e.g. device:*)Denied — unknown channel prefixes are closed by default

A denied Subscribe is rejected and never registered — the channel is not added to the connection's subscription set, whether sent as JSON or Protobuf:

  • JSON path: an Error text frame, {"type":"error","message":"Access denied to channel: <channel>"} (no machine-readable code).
  • Protobuf path: a WsEnvelope of type = WS_MESSAGE_TYPE_ERROR built via WsEnvelope::error(..), carrying message = "Access denied to channel" and code = "channel_forbidden".

The ?userId= identity relay is a separate, earlier gate (evaluated once at connection time, before any Subscribe) and is independently audited — see ws_identity_override.

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"
}

Sent by the logoutEverywhere mutation / POST /v1/auth/logout-everywhere, which revoke all of the user's OAuth tokens (consent kept). A client should sign out on any forceLogout.

App Logout​

Sent when the user is logged out of a single OAuth client (consent is kept — the app can silently re-login without re-consent). Triggered by the logoutApp mutation / POST /v1/auth/logout-app. Distinct from oAuthConsentRevoked, which additionally deletes consent.

JSON:

{
"type": "appLogout",
"userId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"clientId": "e0eebc99-9c0b-4ef8-bb6d-6bb9bd380a15",
"timestamp": "2025-01-25T10:00:00Z"
}
FieldTypeDescription
typestringAlways "appLogout".
userIdstringThe user being logged out.
clientIdstringThe OAuth client (UUID) the user is logged out of.
timestampstring (ISO 8601)When the logout occurred.

How to react: an app should sign out only if clientId matches its own OAuth client id; messages for other clients are ignored. The @elcto/api resolveSsoLogout helper implements this filtering (returns LoggedOut when it matches).

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;
}

// `appLogout` / `oAuthConsentRevoked` are client-scoped: only sign out when
// `message.clientId` matches this app's OAuth client id. Prefer the
// `resolveSsoLogout` helper from @elcto/api (see Global SSO Logout) which
// applies this filtering for you.

// Handle other messages...
});

For the full server ↔ client single-sign-out flow (both the instant WebSocket push and the on-launch fallback), see the Global SSO Logout kit.


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​

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:

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::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​

Individual WebSocket messages are not rate-limited, but excessive message sending may result in connection closure. Concurrent connections are capped per client IP (see Connection Limits & Lifetime): max_connections_per_ip for authenticated sessions, the smaller max_anon_connections_per_ip for anonymous ones; over-cap handshakes are rejected 429.


Best Practices​

WebSocket Best Practices
  1. Implement reconnection - Handle disconnections gracefully with exponential backoff
  2. 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
  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​