Layer 3 real-time subscription guide
Transport: WebSocket over TLS.
Endpoint: GET wss://api.safesignals.io/v1/departments/{department_id}/incidents/{incident_id}/subscribe
Auth: JWT in Authorization header or ?token= query
param (browsers only).
1. What it's for
The subscription API delivers live [IncidentEvents]
as they land in Firestore. Use it when your integrator needs
to react to fireground events in real time — dispatch-panel
mirroring, alerting on Maydays or evacuations, driving a
real-time chief-facing dashboard on top of Muster's data.
Not for polling. If you need occasional snapshots, use the
Layer 1 read listIncidents + listIncidentEvents. Layer 3
opens a long-lived connection per (department, incident) —
inefficient if you subscribe + immediately disconnect.
2. Connection lifecycle
Client Server
────── ──────
1. HTTPS Upgrade → wss://…/subscribe
Header: Authorization: Bearer <jwt> ─────▶ Verify JWT
─────▶ Verify dept
2. WebSocket opens ◀─────
3. Server replays history:
◀── {"type":"event", event_id:"h-1", …}
◀── {"type":"event", event_id:"h-2", …}
◀── {"type":"event", event_id:"h-3", …}
4. Boundary marker:
◀── {"type":"snapshot_end"}
5. Server tails live events:
◀── {"type":"event", event_id:"live-1", …}
◀── {"type":"event", event_id:"live-2", …}
…
6. Client sends WebSocket Close (1000)
────▶ Server tears down subscription
3. Frame types
Every frame is a JSON object with a type field.
event
One historical or live event.
{
"type": "event",
"event_id": "e-1",
"kind": "IncidentCreated",
"occurred_at": "2026-07-06T12:00:00Z",
"origin_device": "dev-1",
"logical_clock": 1
}
The full payload_json is not included — Layer 3's role is
real-time notification. When you need the full payload, call
Layer 1's getIncident or listIncidentEvents with the
event_id.
snapshot_end
Boundary between the history replay + the live tail. Emitted exactly once per connection.
{"type":"snapshot_end"}
Use this to switch your caller's UI from "loading" to "live" mode.
error
Terminal frame — server closes immediately after sending. The
code names the failure; see close codes below.
{"type":"error","code":"missingBearer","message":"…"}
4. Close codes
WebSocket close codes 4000-4999 are private per RFC 6455 §7.4. Muster's usage:
| Code | Reason | Meaning |
|---|---|---|
| 1000 | stream ended |
Normal close (client disconnected or server drained) |
| 1011 | internal error |
Server-side failure — retry after backoff |
| 4401 | <auth_rejection_code> |
Missing / bad JWT — refresh + reconnect |
| 4403 | wrong_department |
JWT valid but authorizes a different department |
5. Authenticating from a browser
Browsers can't set Authorization on new WebSocket(url).
Pass the JWT as ?token=:
const ws = new WebSocket(
`wss://api.safesignals.io/v1/departments/dept-a/incidents/inc-1/subscribe?token=${jwt}`
);
Trade-off: query params get logged by proxies + browser
history. Server-side callers should prefer the Authorization
header. Both paths verify the same JWT.
6. Reconnect strategy
Networks drop. Muster's subscription substrate is stateless per (subscribed dept, incident) — a fresh subscribe always replays history from the start. Recommended reconnect logic:
onclose(event):
if event.code == 4401 or event.code == 4403:
stop # unrecoverable without operator action
if event.code >= 5000:
exponential_backoff # transient server issue
else:
linear_backoff # network hiccup, likely quick
reconnect
# Server will replay history; dedupe by event_id on your side.
Dedupe: track the highest logical_clock you've seen and
discard frames with logical_clock <= that value.
7. Multiple concurrent subscribers
The same (department, incident) supports multiple concurrent subscribers. Server-side, they share one Firestore-Listen watch upstream (broadcast fan-out). No per-subscriber rate limits below the tier-level rate ceilings.
8. What you don't get from Layer 3
- Cross-incident subscriptions. Layer 3 is scoped to one incident per subscription. Subscribe to N incidents → open N connections.
- Backfill beyond incident creation. Snapshots replay the
incident's full log; there's no way to "start from now."
If your caller only wants new events, wait for
snapshot_endbefore acting. - Query-shape filtering. Layer 3 emits every event on the incident. Filter kinds client-side.
- Guaranteed HLC order across a partition heal. Server
emits in the order Firestore Listen delivers, which
matches HLC order in a healthy mesh. During a mesh partition
heal, events can arrive slightly out of order — the
logical_clockfield lets you re-sort if needed.
9. Example — Dart
import 'package:web_socket_channel/io.dart';
import 'dart:convert';
Future<void> main() async {
final jwt = '<your JWT>';
final channel = IOWebSocketChannel.connect(
Uri.parse('wss://api.safesignals.io/v1/departments/dept-a/incidents/inc-1/subscribe'),
headers: {'authorization': 'Bearer $jwt'},
);
await for (final data in channel.stream) {
final frame = jsonDecode(data as String) as Map;
switch (frame['type']) {
case 'event':
print('event: ${frame['kind']} @ ${frame['occurred_at']}');
case 'snapshot_end':
print('▶ live tail begins');
case 'error':
print('✗ ${frame['code']}: ${frame['message']}');
}
}
}