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
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 aspublic)admin:*- Admin broadcast channels (requiresadmin:readpermission or super admin)gps/gps:*- GPS data updates (requiresgps:readpermission)geofence/geofence:*- Geofence enter/exit events (requiresgeofences:readpermission)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 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
A WebSocket connection is authenticated at the GET /v1/ws upgrade by exactly one of,
in order:
- Ticket subprotocol (browsers) — a single-use ticket carried as a
Sec-WebSocket-Protocolentry. The server consumes the ticket atomically and re-resolves the raw credential it wraps via the normal token authentication path. Authorization: Bearerheader (non-browser clients — bots, servers).- No credential → anonymous. This is not a
401: the connection is accepted as an anonymous session that may subscribe only topublic/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/ticketwithAuthorization: Bearer <token>→{ "ticket": "wst_...", "expiresIn": 30 } - GraphQL:
mintWsTicketmutation →{ 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?;
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 rejected403 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_overrideaudit event carrying both identities:user_idis the impersonated user,actor_idis the system key that performed the relay. - A system key with no
?userId=connects as the raw system context, unchanged. /v1/wsis 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
- User logs in via NextAuth (credentials or OAuth); the session token is stored in
session.accessToken. - Before opening the socket, the client mints a single-use ticket
(
POST /v1/ws/ticket/mintWsTicket) over that token. - The client opens
wss://.../v1/wsoffering['heimdall-ticket', ticket]as subprotocols; the server consumes the ticket and resolves the wrapped credential. - On reconnect (including after the max-lifetime cutoff), the client mints a fresh ticket and repeats — tickets are never reused.
- 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 smallermax_anon_connections_per_ip(default 5). A handshake that would exceed the cap is rejected429 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'screateWebSocketauto-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 Type | Access 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 |
vessels | Open — 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
Errortext frame,{"type":"error","message":"Access denied to channel: <channel>"}(no machine-readable code). - Protobuf path: a
WsEnvelopeoftype = WS_MESSAGE_TYPE_ERRORbuilt viaWsEnvelope::error(..), carryingmessage = "Access denied to channel"andcode = "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"
}
| Field | Type | Description |
|---|---|---|
type | string | Always "appLogout". |
userId | string | The user being logged out. |
clientId | string | The OAuth client (UUID) the user is logged out of. |
timestamp | string (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;
}
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
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
- 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
}
}
}