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 broadcastsgpsUpdate, 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