EARLY ACCESS Muster is being validated by pilot departments. Schedule a walk-through →

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_end before 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_clock field 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']}');
                  }
                }
              }