Skip to main content

WebSocket API

The Apollon API provides a WebSocket endpoint for real-time data broadcasts from the Peplink GPS router. Clients subscribe to named channels and receive structured JSON messages.

Endpoint

GET /ws?token=<token>

Upgrades to a WebSocket connection. Requires a valid API token as query parameter. Returns 503 Service Unavailable if the server is shutting down.

Channels

ChannelModeDescription
speedPolledSpeed in 4 units (km/h, m/s, mph, knots)
gpsPolledLocation, heading, and DOP (dilution of precision)
tripsEvent-drivenTrip lifecycle events (started, completed, cancelled)
vesselsEvent-drivenAIS vessel positions and static data (per-vessel delta protocol: snapshot/spawn/update/remove/bbox)
watchlistEvent-drivenWatchlist proximity alerts for tracked vessels

Connection Flow

Client Server
| |
| --- WebSocket Upgrade (?token=xxx) --> |
| <-- 101 Switching Protocols ---------- |
| |
| --- Subscribe ["speed","gps"] -------> |
| <-- Ack { subscribed: [...] } -------- |
| |
| <-- Speed message ------------------- | (on change, at interval)
| <-- GPS message --------------------- | (on change, at interval)
| <-- Trip event ---------------------- | (when trip state changes)
| |
| <-- Ping ---------------------------- | (every 30 seconds)
| --- Pong ---------------------------> |
| |
| --- Unsubscribe ["speed"] ----------> |
| <-- Ack { unsubscribed: [...] } ----- |
| |
| --- Close --------------------------> |
| <-- Close --------------------------- |

Subscribe / Unsubscribe Protocol

Subscribe

Send a JSON message to subscribe to one or more channels:

{
"action": "subscribe",
"channels": ["speed", "gps"]
}

The server responds with an acknowledgement:

{
"type": "subscribed",
"channels": ["speed", "gps"]
}

Wildcard Subscribe

Subscribe to all available channels at once:

{
"action": "subscribe",
"channels": ["*"]
}

Unsubscribe

{
"action": "unsubscribe",
"channels": ["speed"]
}

The server responds:

{
"type": "unsubscribed",
"channels": ["speed"]
}

Error Handling

If a client sends an invalid message or references an unknown channel, the server responds with an error:

{
"type": "error",
"message": "Unknown channel: invalid_channel"
}

Errors do not close the connection. The client can continue sending valid messages.

Message Formats

Speed Channel

Broadcast when the speed value changes.

{
"type": "speed",
"data": {
"speed_kmh": 45.2,
"speed_mps": 12.56,
"speed_mph": 28.09,
"speed_knots": 24.41
}
}
FieldTypeDescription
speed_kmhf64Current speed in km/h
speed_mpsf64Current speed in meters per second
speed_mphf64Current speed in miles per hour
speed_knotsf64Current speed in knots

GPS Channel

Broadcast when the GPS position or heading changes.

{
"type": "gps",
"data": {
"latitude": 52.5200,
"longitude": 13.4050,
"altitude": 34.5,
"heading": 182.3,
"pdop": 1.2,
"hdop": 0.9,
"vdop": 0.8,
"timestamp": "2026-06-10T12:34:56Z"
}
}
FieldTypeDescription
latitudef64Latitude in decimal degrees
longitudef64Longitude in decimal degrees
altitudef64Altitude in meters above sea level
headingf64Heading in degrees (0-360)
pdopf64Position dilution of precision
hdopf64Horizontal dilution of precision
vdopf64Vertical dilution of precision
timestampstringISO 8601 timestamp of the GPS fix

Trips Channel

Event-driven messages when trip state changes.

{
"type": "trips",
"data": {
"event": "started",
"tripId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"trip": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "ACTIVE",
"senderId": "...",
"receiverId": "..."
}
}
}
FieldTypeDescription
eventstringEvent type: started, completed, cancelled
tripIdstringUUID of the affected trip
tripobjectTrip snapshot at the time of the event

Vessels Channel

The vessels channel is a per-vessel delta protocol. Instead of re-sending the full vessel list on every change, the backend broadcasts a small delta for the one vessel that changed (spawn/update/remove), a bbox delta when the map circle relocates, and a full snapshot as the greeting for new subscribers. This lets the frontend move, add, and remove individual markers (and play a spawn fade-in) instead of re-rendering everything on each tick.

Fed by two independently toggled sources — AISstream.io ([ais.aisstream]) and/or a local NMEA/AIS receiver ([ais.local]). Both converge on the same vessels channel and payload shape, so subscribers don't need to know which source produced a vessel — though each vessel carries a source field ("local" / "aisstream") if they want to distinguish. When both sources run (dual mode), the local receiver takes priority for vessels in antenna range and aisstream is secondary; see Dual-source handoff below.

Breaking change — flat envelope

The message shape changed from nested ({"type":"vessels","data":{"vessels":[…],"bbox":…}}) to flat, discriminated by an action field ({"type":"vessels","action":"snapshot","vessels":[…],"bbox":…}). There is no backward compatibility — an old client reading msg.data.vessels sees nothing. Backend and frontend must deploy together, and long-lived overlay / OBS / kiosk tabs must be reloaded (cache-busted) to pick up the new client.

Actions

Every vessels message carries an action discriminator:

actionWhenPayload
snapshotGreeting on subscribe/reconnectfull vessels[] + bbox
spawnA new vessel appearsone vessel object
updateAn existing vessel changed (new position, static/name enrichment, or a manual edit via PUT /v1/vessels/{mmsi} / updateVessel)one vessel object
removeA vessel went stale / left range (from cleanup), or was manually deleted via DELETE /v1/vessels/{mmsi} / deleteVesselmmsi only
bboxThe map circle relocated (GPS center moved)bbox only, no vessel
{"type":"vessels","action":"snapshot","vessels":[ … Vessel objects … ],"bbox":{}}
{"type":"vessels","action":"spawn","vessel":{ … Vessel object … }}
{"type":"vessels","action":"update","vessel":{ … Vessel object … }}
{"type":"vessels","action":"remove","mmsi":211234567}
{"type":"vessels","action":"bbox","bbox":{}}

Each delta carries the full vessel object (not a field diff) — the client replaces the vessel keyed by mmsi. An update for an unknown MMSI is treated as an add (self-heal for a missed spawn).

Snapshot / spawn example

{
"type": "vessels",
"action": "snapshot",
"vessels": [
{
"mmsi": 211234567,
"name": "MS Harmonie",
"callsign": "DA1234",
"ship_type": "Cargo",
"ship_type_code": 70,
"nav_status": "Under Way",
"nav_status_code": 0,
"length": 110,
"beam": 11,
"latitude": 48.5734,
"longitude": 7.7521,
"heading": 182.3,
"course": 180.1,
"speed_kmh": 18.52,
"speed_mps": 5.14,
"speed_mph": 11.51,
"speed_knots": 10.0,
"source": "local",
"last_seen": "2026-07-27T12:34:56+00:00"
}
],
"bbox": {
"sw": [48.4734, 7.6521],
"ne": [48.6734, 7.8521]
}
}
FieldTypeDescription
actionstringMessage discriminator: snapshot / spawn / update / remove / bbox
vesselsarray(snapshot) all currently tracked vessels near the GPS position
vesselobject(spawn/update) the single vessel that changed
mmsinumber(remove) MMSI of the vessel that left / went stale
bboxobject|null(snapshot/bbox) map bbox circle bounds (sw/ne corners), or null when no bbox applies

Vessel Fields

FieldTypeDescription
mmsinumberMaritime Mobile Service Identity
namestringVessel name (empty if not yet received)
callsignstring|nullRadio call sign, if known (from static enrichment)
ship_typestringShip type label (e.g. Cargo), Unknown if not available
ship_type_codenumber|nullRaw numeric AIS ship-type code (e.g. 70 = Cargo, 80 = Tanker, 99 = Other), alongside the ship_type label. null if unknown
nav_statusstringNavigational status label (e.g. Under Way, At Anchor), Unknown if not available. Speed-corroborated: a stationary status like Moored/At Anchor is reported as Under Way when the vessel's SOG is ≥ ~1 knot (0.514 m/s) — AIS nav_status is manually set and often stale, so a reliable GPS-derived speed overrides it.
nav_status_codenumber|nullRaw ITU-R M.1371 navigational-status code (0–15), alongside the nav_status label. Not speed-corrected — this is the value straight off the air. null if unknown
lengthnumber|nullLength in meters, if known
beamnumber|nullBeam in meters, if known
latitude / longitudef64Current position
headingf64|nullTrue heading in degrees
coursef64|nullCourse over ground in degrees
speed_kmh / speed_mps / speed_mph / speed_knotsf64Speed in all 4 units (same rounding/conversion factors as the speed channel)
sourcestringEffective tracking source for this vessel: local (physical NMEA/AIS receiver) or aisstream (AISstream.io). In dual mode the value can change when a vessel is handed off between sources. Lets the frontend distinguish / style vessels by origin.
last_seenstringRFC 3339 timestamp of when the vessel was last heard from (any AIS message). Drives staleness / removal from the map.
staleboolPresent and true only for cold-start-seeded vessels whose position may be minutes old; omitted otherwise. Cleared by the first live position (update).

Display labels vs. raw codes: ship_type / nav_status are coarse English category labels for convenience — clients should derive their own display labels from the raw codes (ship_type_code / nav_status_code), which carry the full ITU-R M.1371 granularity (Hazardous A–D, Reserved, HSC/WIG, …). The Apollon web frontend does this via shipTypeLabel(code) / navStatusLabel(code) in @elcto/apollon-api (German, full 0–99 / 0–15 coverage); the live map uses shipTypeLabelMap(code), a map-specific variant with shorter/colloquial labels. nav_status is additionally speed-corrected (see above), the raw nav_status_code is not.

Speed units: vessel speed is decoded once as AIS-native knots and converted once to m/s in the vessel tracker; the channel then derives all 4 units (speed_kmh, speed_mps, speed_mph, speed_knots) from that, mirroring the speed channel's GpsData conversion factors.

bbox: the aisstream source's bbox reflects its [ais.aisstream] radius_km subscription area. The local source derives bbox from the optional [ais.local] reception_range_km — if unset, no bbox is broadcast (bbox is null) and received vessels are never filtered by range (a physical VHF receiver has no fixed radius). In dual mode the aisstream subscription bbox is used.

Greeting (snapshot): When a client subscribes (or reconnects), the current full state is sent immediately as one snapshot message — the client doesn't wait for the next delta.

Dual-source handoff

When both [ais.aisstream] and [ais.local] are enabled, the local receiver has priority. A vessel heard live via the local antenna is tracked from local and is not double-tracked from aisstream — while its local feed is fresh, incoming aisstream positions for that MMSI are suppressed. Local always wins for in-range vessels.

Once a vessel leaves antenna range and goes silent on the local feed for longer than [ais] local_priority_ttl_secs (default 120 s), the local hold expires and aisstream resumes tracking it (handoff) — the vessel's source flips to aisstream on the next update. Vessels aisstream covers that were never heard locally are tracked from aisstream throughout. Each vessel's source field always reflects the source currently driving its position, so a handoff is visible to subscribers.

Cold-Start Persistence

The greeting snapshot is also persisted to Redis (key ais:vessels:snapshot, TTL [redis] vessel_snapshot_ttl_secs, default 900 s / 15 min) and re-seeded on startup. After a backend restart the map is therefore populated immediately from the last known snapshot — moored or slow vessels don't sit invisible for minutes waiting for fresh AIS. Seeded vessels are marked "stale": true (their position may be minutes old); the first live position for each clears stale via an update delta. Redis save/load errors are logged and swallowed — a missing or unreachable Redis simply means an empty start (the previous behavior), never a broken AIS loop. See the [redis] configuration.

AIS Static Enrichment

Over the raw AIS air (the local receiver), position frames (every 2–10 s) and static frames (name, ship type, dimensions, call sign — only every ~6 min, often split across fragments) arrive separately. A freshly seen vessel can otherwise sit for minutes as a nameless dot, and static learned before its first position — or across restarts — used to be lost. A two-tier static registry fixes this by accumulating the static fields per MMSI and enriching every position from it.

Two-tier store

TierBackendRole
Source of truthPostgreSQL vessels tableDurable, MMSI-keyed registry — survives restarts, SQL-queryable
Cache (L1)Redis (ais:static:{mmsi}, TTL)Read-through / write-through accelerator in front of Postgres
AuditTimescaleDB vessels_history hypertableAppend-only log of every real static-field change

Only the static fields are cached (name, ship_type, length, beam, callsign). Dynamic values (lat/lon/speed/course/heading) are never cached — they are seconds-fresh and a stale copy would be wrong.

Flow

  • On a static frame: the registry is always written (even before any position for that MMSI — this fixes the static-before-position drop). Fields merge field-by-field, so a fragment carrying only the name never clobbers one carrying type/dimensions, and vice versa.
  • On a position frame: if the vessel is not yet enriched, the registry is read once (Redis hit → else Postgres → then Redis is populated) to fill name/ship type/dimensions/call sign; live position data is never overwritten.

Compare-then-write — writes are not blind. Gaps are filled (no audit), identical values are skipped, and a real change is handled by two policies:

  • Policy A (audit): every real field change is appended to vessels_history (best-effort — the audit insert never blocks the registry update).
  • Policy B (clear-on-name-change): when the name changes (typical of an MMSI being reassigned to a different vessel), the other static fields are reset instead of merged — and the Redis entry is cleared — to avoid a "Frankenstein" record mixing the old and new vessel.

Graceful degradation: Postgres is always present, so the registry is always functional. If Redis is unreachable, enrichment runs Postgres-only (slower, still complete); any registry error is logged and never propagated into the AIS/position path. See the [redis] configuration.

AIS Forwarding to Heimdall

Apollon can forward its local-receiver vessels to a sister project (Heimdall) so Heimdall's map gains the near-/inland-range coverage a physical A027+ antenna sees but AISstream may miss. Enabled via [ais.forward] — Apollon opens one persistent outbound WebSocket to Heimdall (as a client) and pushes vessel deltas onto it.

  • Local only. Only vessels with source == "local" are forwarded. aisstream vessels are never forwarded — Heimdall runs its own aisstream client, so the two coexist: Apollon's local feed supplements Heimdall's global map (local-priority merge recommended on Heimdall's side).
  • Same wire protocol. Frames reuse the vessels delta protocol — a subset: spawn / update / remove only (no snapshot, no bbox), each {"type":"vessels","action":…,"vessel"|"mmsi":…}. The vessel object is the same snake_case shape broadcast to Apollon's own map, including ship_type_code and nav_status_code.
  • Auth. Authorization: Bearer {token} on the upgrade request, plus an optional X-Device-ID header ([ais.forward] token / device_id).
  • Fire-and-forget. No queue and no snapshot/greeting on connect — Apollon just starts streaming deltas. On disconnect it reconnects with exponential backoff; a fresh connection simply resumes streaming.
ConfigEnvDefaultDescription
ais.forward.enabledAPOLLON__AIS__FORWARD__ENABLEDfalseEnable forwarding of local vessels
ais.forward.urlAPOLLON__AIS__FORWARD__URL(none)Heimdall forward-AIS WebSocket endpoint (e.g. wss://<heimdall>/v1/ws)
ais.forward.tokenAPOLLON__AIS__FORWARD__TOKEN(none)Bearer token
ais.forward.device_idAPOLLON__AIS__FORWARD__DEVICE_ID(none)Optional X-Device-ID header

The full wire contract (frame shapes, exact field schema, nullability) lives in docs/superpowers/specs/2026-07-28-heimdall-forward-ais-receiver-brief.md.

Watchlist Channel

Broadcast when a tracked vessel's proximity status changes. The message contains all currently active alerts, sorted by highest stars first, then closest distance.

{
"type": "watchlist",
"data": {
"alerts": [
{
"mmsi": 211234567,
"name": "MS Harmonie",
"category": "Freighter",
"stars": 3,
"distanceKm": 4.2,
"starColor": "#EF4444",
"soundTrigger": {
"type": "alert",
"soundKey": "sonar",
"volume": 80
},
"displayType": "stars",
"soundEnabled": true,
"overlayEnabled": true,
"overlayIcon": null
}
],
"topAlert": {
"mmsi": 211234567,
"name": "MS Harmonie",
"category": "Freighter",
"stars": 3,
"distanceKm": 4.2,
"starColor": "#EF4444",
"soundTrigger": null,
"displayType": "stars",
"soundEnabled": true,
"overlayEnabled": true,
"overlayIcon": null
}
}
}
FieldTypeDescription
alertsarrayAll active alerts, sorted by stars desc then distance asc
topAlertobject|nullHighest-priority alert (most stars, closest)

Alert Fields

FieldTypeDescription
mmsinumberMMSI number of the vessel
namestringDisplay name
categorystring|nullCategory name (if assigned)
starsnumberStar count (0-5) based on distance thresholds
distanceKmnumberCurrent distance in km
starColorstringHex color for star display
soundTriggerobject|nullSound to play (type: alert/danger, soundKey, volume)
displayTypestringDisplay mode: stars or custom
soundEnabledbooleanWhether sound is enabled for this alert
overlayEnabledbooleanWhether overlay display is enabled
overlayIconstring|nullLucide icon name (for custom display type)

Greeting: When a client subscribes, the last known watchlist state is sent immediately with soundTrigger set to null on all alerts (no sounds on reconnect).

Configuration

WebSocket behavior is configured via the websocket section in the config file or APOLLON__WEBSOCKET__* environment variables:

Config KeyEnv VarDefaultDescription
websocket.pathAPOLLON__WEBSOCKET__PATH/wsWebSocket endpoint path
websocket.speed_interval_msAPOLLON__WEBSOCKET__SPEED_INTERVAL_MS1000Speed broadcast interval in milliseconds
websocket.gps_interval_msAPOLLON__WEBSOCKET__GPS_INTERVAL_MS1000GPS broadcast interval in milliseconds

JavaScript Client Example

const ws = new WebSocket('ws://localhost:3000/ws?token=YOUR_API_TOKEN');

ws.onopen = () => {
console.log('Connected to Apollon WebSocket');

// Subscribe to channels
ws.send(JSON.stringify({
action: 'subscribe',
channels: ['speed', 'gps', 'trips', 'watchlist'],
}));
};

ws.onmessage = (event) => {
const message = JSON.parse(event.data);

switch (message.type) {
case 'subscribed':
console.log('Subscribed to:', message.channels);
break;
case 'speed':
console.log(`Speed: ${message.data.speed_kmh} km/h`);
break;
case 'gps':
console.log(`Position: ${message.data.latitude}, ${message.data.longitude}`);
break;
case 'trips':
console.log(`Trip ${message.data.event}: ${message.data.tripId}`);
break;
case 'watchlist':
console.log(`Watchlist: ${message.data.alerts.length} active alerts`);
break;
case 'error':
console.error('Server error:', message.message);
break;
}
};

ws.onclose = (event) => {
console.log(`Disconnected: code=${event.code}`);
};

ws.onerror = (error) => {
console.error('WebSocket error:', error);
};

Architecture

PeplinkRouter / MockProvider
|
v
GPS Provider (shared)
| |
v v
SpeedBroadcaster GpsBroadcaster (poll GPS at interval)
| |
v v
speed::broadcast gps::broadcast (tokio broadcast channels, capacity: 256)
| |
+------+------+ TripBroadcaster (event-driven)
| |
v v
WsServer ---- trips::broadcast (tokio broadcast channel)
| |
Client Client (each subscribes to selected channels)
^
|
MergeConsumer (single tracker owner) ---> vessels::broadcast + watchlist::broadcast
|
run_aisstream_producer (WebSocket) AND/OR run_local_producer (apollon-ais-nmea, TCP)
| (each toggled independently; both = dual mode, local priority)
WatchlistMatcher (distance thresholds → star alerts)

SpeedBroadcaster

Runs as a tokio task that polls the GPS provider at the configured interval, compares the new speed to the last broadcast, and only sends if the value changed. Stores the last broadcast for greeting new subscribers.

GpsBroadcaster

Runs as a tokio task that polls the GPS provider at the configured interval, compares the new position/heading/DOP to the last broadcast, and only sends if any value changed. Stores the last broadcast for greeting new subscribers.

TripBroadcaster

Publishes trip lifecycle events (started, completed, cancelled) to the trips broadcast channel. Events are fired by the trip management endpoints when trip state changes.

WsServer

Manages client connections, channel subscriptions, and broadcast distribution. Rejects new connections during shutdown (503). Each client connection spawns a tokio task that listens for broadcasts on subscribed channels, pings, and client messages concurrently via tokio::select!.