WebSocket
WebSocket Connection Lifecycle
Manage the full lifecycle of a Rhombus WebSocket connection — perform STOMP handshakes, send heartbeats, handle reconnections, and close gracefully.
On this page
- Lifecycle overview
- Step 1: WebSocket handshake
- Step 2: STOMP CONNECT
- CONNECT frame
- Expected response: CONNECTED frame
- Building STOMP frames
- Step 3: Subscribe to a topic
- SUBSCRIBE frame
- Step 4: Message loop and heartbeats
- Heartbeat mechanism
- Parsing incoming frames
- Message loop
- Step 5: Graceful disconnection
- Delivery guarantees
- When the server closes the connection
- Reconnection strategy
- Full reconnection loop
- Connection timeouts
- Common issues
This guide covers the full lifecycle of a Rhombus WebSocket connection: establishing the connection, performing the STOMP handshake, maintaining the connection with heartbeats, and handling disconnections gracefully.
Lifecycle overview
┌─────────────────┐
│ 1. WebSocket │
│ Handshake │──── WSS + Auth Headers
└────────┬────────┘
▼
┌─────────────────┐
│ 2. STOMP │
│ CONNECT │──── Version + Heartbeat negotiation
└────────┬────────┘
▼
┌─────────────────┐
│ 3. SUBSCRIBE │──── Topic subscription
└────────┬────────┘
▼
┌─────────────────┐
│ 4. MESSAGE │◄─── Real-time events
│ Loop │───► Heartbeat ping/pong
└────────┬────────┘
▼
┌─────────────────┐
│ 5. DISCONNECT │──── Graceful shutdown
└─────────────────┘Step 1: WebSocket handshake
Initiate a secure WebSocket connection to the Rhombus endpoint with your credential. This example uses an API key; OAuth access tokens and federated tokens work too (see WebSocket authentication):
import websocket
ws = websocket.create_connection(
"wss://ws.rhombussystems.com:8443/websocket?x-auth-scheme=api-token",
header={"x-auth-apikey": API_TOKEN},
timeout=10 # 10-second handshake timeout
)A successful handshake upgrades the HTTP connection to WebSocket. If authentication fails, the server answers before the upgrade completes with an HTTP error and a JSON body whose errorCode says why: 401 when the credential is missing, unknown, expired or revoked, 403 when it's valid but not allowed (for example, a partner credential without x-auth-org). See WebSocket authentication.
Step 2: STOMP CONNECT
After the WebSocket connection is established, send a STOMP CONNECT frame to initialize the messaging protocol:
CONNECT frame
CONNECT
accept-version:1.2
heart-beat:10000,10000
\x00| Header | Value | Description |
|---|---|---|
accept-version | 1.2 | STOMP protocol version |
heart-beat | 10000,10000 | Client send/receive heartbeat interval in milliseconds |
Expected response: CONNECTED frame
CONNECTED
version:1.2
heart-beat:10000,10000
\x00The server echoes back the negotiated version and heartbeat intervals.
Building STOMP frames
STOMP frames follow a simple text-based format:
COMMAND\n
header1:value1\n
header2:value2\n
\n
body (optional)
\x00- Each frame starts with a command name
- Headers are key-value pairs separated by colons, one per line
- An empty line separates headers from the body
- The frame ends with a null byte (
\x00)
def build_stomp_frame(command, headers=None, body=""):
"""Construct a STOMP protocol frame."""
frame = command + "\n"
if headers:
for key, value in headers.items():
frame += f"{key}:{value}\n"
frame += "\n"
frame += body
frame += "\x00"
return frame
# Send CONNECT
connect_frame = build_stomp_frame("CONNECT", {
"accept-version": "1.2",
"heart-beat": "10000,10000"
})
ws.send(connect_frame)
# Read CONNECTED response
response = ws.recv()function buildStompFrame(command, headers = {}, body = "") {
let frame = command + "\n";
for (const [key, value] of Object.entries(headers)) {
frame += `${key}:${value}\n`;
}
frame += "\n";
frame += body;
frame += "\x00";
return frame;
}
// Send CONNECT
ws.send(
buildStompFrame("CONNECT", {
"accept-version": "1.2",
"heart-beat": "10000,10000",
})
);func buildStompFrame(command string, headers map[string]string, body string) string {
var b strings.Builder
b.WriteString(command + "\n")
for k, v := range headers {
b.WriteString(fmt.Sprintf("%s:%s\n", k, v))
}
b.WriteString("\n")
b.WriteString(body)
b.WriteByte(0x00)
return b.String()
}
// Send CONNECT
connectFrame := buildStompFrame("CONNECT", map[string]string{
"accept-version": "1.2",
"heart-beat": "10000,10000",
}, "")
conn.WriteMessage(websocket.TextMessage, []byte(connectFrame))Step 3: Subscribe to a topic
After the STOMP connection is established, subscribe to the organization change topic:
SUBSCRIBE frame
SUBSCRIBE
id:sub-0
destination:/topic/change/{orgUuid}
\x00| Header | Value | Description |
|---|---|---|
id | sub-0 | A client-defined subscription identifier |
destination | /topic/ | The topic to subscribe to |
subscribe_frame = build_stomp_frame("SUBSCRIBE", {
"id": "sub-0",
"destination": f"/topic/change/{org_uuid}"
})
ws.send(subscribe_frame)Replace {orgUuid} with your organization's UUID. Retrieve it via the REST API endpoint POST /api/org/getOrgV2.
Step 4: Message loop and heartbeats
Once subscribed, your application enters a message loop where it:
- Receives MESSAGE frames containing event data
- Sends heartbeats to keep the connection alive
- Receives heartbeats from the server
Heartbeat mechanism
Heartbeats are single newline characters (\n) sent at the negotiated interval (10 seconds). Both the client and server send heartbeats. If either side stops receiving heartbeats, the connection is considered dead.
import threading
import time
def send_heartbeats(ws, stop_event):
"""Send STOMP heartbeats every 10 seconds."""
while not stop_event.is_set():
try:
ws.send("\n")
except Exception:
break
stop_event.wait(10)
# Start heartbeat sender in background
stop_heartbeat = threading.Event()
heartbeat_thread = threading.Thread(
target=send_heartbeats,
args=(ws, stop_heartbeat),
daemon=True
)
heartbeat_thread.start()Parsing incoming frames
def parse_stomp_frame(raw):
"""Parse a raw STOMP frame into command, headers, and body."""
# Handle heartbeats (empty frames)
raw = raw.lstrip("\n\r")
if not raw or raw == "\x00":
return None # Heartbeat
raw = raw.rstrip("\x00")
parts = raw.split("\n\n", 1)
lines = parts[0].split("\n")
command = lines[0]
headers = {}
for line in lines[1:]:
if ":" in line:
key, value = line.split(":", 1)
headers[key] = value
body = parts[1] if len(parts) > 1 else ""
return {"command": command, "headers": headers, "body": body}Message loop
try:
while True:
raw = ws.recv()
frame = parse_stomp_frame(raw)
if frame is None:
continue # Heartbeat, skip
if frame["command"] == "MESSAGE":
payload = json.loads(frame["body"])
handle_event(payload)
except KeyboardInterrupt:
print("Shutting down...")
finally:
stop_heartbeat.set()
heartbeat_thread.join()Step 5: Graceful disconnection
Before closing the WebSocket, send a STOMP DISCONNECT frame:
# Send DISCONNECT frame
disconnect_frame = build_stomp_frame("DISCONNECT")
ws.send(disconnect_frame)
# Close WebSocket
ws.close()This signals to the server that the client is intentionally disconnecting, allowing it to clean up resources immediately rather than waiting for a heartbeat timeout.
Delivery guarantees
The event WebSocket delivers each message at most once: messages aren't acknowledged, stored or replayed, so anything published while you are disconnected is lost, and there's no ordering guarantee. After every reconnect, read what you missed from the REST API (see Build an event pipeline), and order events by their timestamps. The event reference has the details.
When the server closes the connection
| Close | Cause | What to do |
|---|---|---|
STOMP ERROR, then close code 1008 | The credential was deleted or revoked, or its role changed. The server re-checks the credential about once a minute, so this happens within 60 seconds | Check the credential before reconnecting; after a delete or revoke every handshake fails with 401 |
STOMP ERROR, then close code 1002 | A subscription to another organization's topic, or a frame the server doesn't accept | Fix the subscription; reconnecting with the same frames fails the same way |
Close with no ERROR frame | Missed heartbeats, no CONNECT within 60 seconds, a slow client, or a network or server restart | Reconnect with backoff |
Reconnection strategy
Network interruptions are inevitable. Implement automatic reconnection with backoff:
import time
def connect_with_retry(api_token, org_uuid, max_retries=10):
"""Connect to Rhombus WebSocket with exponential backoff."""
for attempt in range(max_retries):
try:
ws = websocket.create_connection(
"wss://ws.rhombussystems.com:8443/websocket"
"?x-auth-scheme=api-token",
header={"x-auth-apikey": api_token},
timeout=10
)
stomp_connect(ws)
stomp_subscribe(ws, org_uuid)
print("Connected successfully")
return ws
except Exception as e:
delay = min(5 * (2 ** attempt), 60)
print(f"Connection failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
raise ConnectionError("Max retries exceeded")Full reconnection loop
while True:
ws = connect_with_retry(API_TOKEN, ORG_UUID)
try:
run_message_loop(ws)
except websocket.WebSocketConnectionClosedException:
print("Connection lost. Reconnecting...")
except KeyboardInterrupt:
print("User interrupted. Exiting.")
break
finally:
try:
ws.close()
except Exception:
passConnection timeouts
| Timeout | Value | Description |
|---|---|---|
| Handshake | 10 seconds | Maximum time to complete the WebSocket upgrade |
| Heartbeat | 10 seconds | Interval between keep-alive pings |
| Dead connection | ~30 seconds | Approximate time before a missing heartbeat triggers disconnection |
| First frame | 60 seconds | Send the STOMP CONNECT frame within this time of opening the socket, or it's closed |
| Slow client | 10 seconds or 512 KiB | A client the server can't send to for 10 seconds, or that falls 512 KiB behind, is disconnected |
Common issues
| Symptom | Likely Cause | Solution |
|---|---|---|
| Connection drops every ~30s | Heartbeats not being sent | Implement the heartbeat sender thread |
CONNECTED frame never received | STOMP CONNECT frame malformed | Verify frame format includes null terminator \x00 |
| Messages not received | Wrong topic or missing org UUID | Verify the org UUID matches your credential's organization (or the x-auth-org client) |
| Reconnection loop | The credential was deleted, revoked or expired (401 on every handshake), or the organization is rate limited (429) | Check the credential with a REST call; on 429 wait Retry-After and back off |