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

Reference — Muster public API

Authoritative spec: proto/muster/api/v1/. This page mirrors the proto files' service methods in a browsable form. Any discrepancy — the proto files win.

Base URL: https://api.safesignals.io (prod) or https://api-sandbox.safesignals.io (sandbox).

Auth: every endpoint below requires Authorization: Bearer <jwt> unless noted (public).


Health

GET /health (public)

Returns the service's serving status. Used by Cloud Run health checks + operator smoke tests.

200 OK:

{
                "status": "SERVING_STATUS_SERVING",
                "server_version": "muster_api chunk 0",
                "api_schema_version": "1.0"
              }
              

Trust keys — TrustKeysService

GET /v1/trust/keys (public)

Returns Muster's currently-active + historical Ed25519 signing keys. Integrators verify per-event signatures against these.

200 OK:

{
                "keys": [
                  {
                    "key_id": "k1",
                    "public_key_pem": "-----BEGIN PUBLIC KEY-----\n…",
                    "active_from": "2026-01-01T00:00:00Z"
                  }
                ]
              }
              

Integration registry — IntegrationRegistryService

POST /v1/integrations/register

Register your integration. Safe Signals runs this internally when you contact integrations@safesignals.io; some Tier 3 partners with self-service tooling call it directly.

Body:

{
                "integration_id": "eso-records-v1-2",
                "display_name": "ESO Records Integration v1.2",
                "producer_capabilities": ["ingest.incident_created", "ingest.unit_checked_in"],
                "consumer_capabilities": ["read.incident_events", "read.neris_projection"],
                "contact_email": "api@eso.com"
              }
              

PATCH /v1/integrations/{integration_id}

Update capabilities or contact info. Producer capability additions take effect on the next Layer 2 batch; consumer additions take effect immediately for authorized departments.

GET /v1/integrations/{integration_id}

Fetch your registered capabilities + display info. Returns 404 if you haven't registered.

GET /v1/integrations

List every registered integration. Returns {"integrations": [...]}. Open to every api_read_v1 caller — integration metadata is not sensitive, and enumeration is expected for admin console + integrator directory pages.

DELETE /v1/integrations/{integration_id}

Unregister an integration. Ownership check: the JWT's sub must match the registrant's sub recorded at register time. Returns {"deleted": true} on success, 403 on ownership mismatch, 404 if the integration doesn't exist.

Downstream effect. After delete, the producer-capability gate rejects every ingest row from the integration's api_keys because the registry lookup returns null (fail-closed). Ingest immediately starts rejecting; any active Layer 3 WebSocket subscriptions on the integration's JWT continue until the JWT expires.

GET /v1/integrations/{integration_id}/departments/{department_id}/capabilities

Returns the intersection of your registered capabilities with this department's authorized set. Use this to check whether an event kind or projection is available before making the call.


Layer 1 — reads (IncidentReadService)

GET /v1/departments/{department_id}/incidents

List every incident under this department.

200 OK:

{
                "incidents": [
                  {
                    "incident_id": "inc-1",
                    "number": "26-0001",
                    "status": "terminated",
                    "dispatched_at": "2026-07-06T12:00:00Z",
                    "terminated_at": "2026-07-06T14:30:00Z",
                    "incident_type": "structure_fire"
                  }
                ]
              }
              

GET /v1/departments/{department_id}/incidents/{incident_id}

One incident's current projected state.

Since 1.3: the response envelope carries an optional movement_summary field — the same per-unit trace summary the dedicated /unit-movement endpoint returns. Present when the incident recorded at least one GPS fix; omitted entirely when the movement summary is empty (pre-1.3 clients ignore the missing key cleanly). Same row shape as /unit-movement; see that endpoint below.

GET /v1/departments/{department_id}/incidents/{incident_id}/events

Full HLC-ordered event log.

200 OK:

{
                "events": [
                  {
                    "event_id": "e-1",
                    "kind": "IncidentCreated",
                    "occurred_at": "2026-07-06T12:00:00Z",
                    "origin_device": "dev-1",
                    "payload_json": {"number": "26-0001", "description": "…"}
                  }
                ]
              }
              

GET /v1/departments/{department_id}/incidents/{incident_id}/archive

Signed .muster archive bytes — the authoritative record.

200 OK: application/octet-stream binary body.

GET /v1/departments/{department_id}/incidents/{incident_id}/archive.pdf

Rendered after-action PDF.

200 OK: application/pdf binary body.

GET /v1/departments/{department_id}/incidents/{incident_id}/neris

NERIS Core Schema projection of the incident. Consumers submit this to NERIS themselves as part of their own Integration Partner Program participation.

GET /v1/departments/{department_id}/incidents/{incident_id}/unit-movement

Per-unit GPS movement trace summary (since 1.3). Same aggregation the after-action PDF's Section 9b renders — programmatic access without parsing the PDF.

200 OK:

{
                "incident_id": "inc-1",
                "unit_movement": [
                  {
                    "unit_id": "u-engine-3",
                    "designation": "Engine 3",
                    "fix_count": 24,
                    "first_fix_at": "2026-07-02T14:30:00Z",
                    "last_fix_at": "2026-07-02T15:15:00Z",
                    "approximate_distance_meters": 1113.4,
                    "average_accuracy_meters": 8.2,
                    "min_latitude": 40.7128,
                    "max_latitude": 40.7228,
                    "min_longitude": -74.006,
                    "max_longitude": -74.006,
                    "max_speed_mps": 12.4
                  }
                ]
              }
              
  • approximate_distance_meters — haversine sum of consecutive fixes. Over-counts short jitter while stationary; good enough for review, not a legal odometer.
  • max_speed_mpsomitted when no fix reported speed (some platforms don't surface a speed value on stationary fixes). Preserves the distinction between "always 0" and "never reported".
  • Empty unit_movement list when the incident recorded no UnitLocationReported events. 200 with empty list, not 404; 404 is reserved for missing incidents.

Capability: incident.unit_movement.

GET /v1/departments/{department_id}/losap?from=<iso>&to=<iso>

LOSAP attribution across the date range (inclusive of both). Returns per-firefighter time-on-scene + incident participation counts.


Layer 2 — writes (IncidentIngestService)

POST /v1/departments/{department_id}/events

Push a batch of events. Each event must map to a capability you declared in producer_capabilities.

Body:

{
                "events": [
                  {
                    "event_id": "e-42",
                    "incident_id": "inc-1",
                    "kind": "IncidentCreated",
                    "occurred_at": "2026-07-06T12:00:00Z",
                    "payload_json": "{\"number\":\"26-0042\"}"
                  }
                ]
              }
              

200 OK:

{
                "accepted_count": 1,
                "rejected_count": 0,
                "rejected": []
              }
              

Per-row reject:

{
                "event_id": "e-42",
                "reason": "event kind \"UnitCheckedIn\" maps to capability \"ingest.unit_checked_in\" which is not declared in the integration's producer_capabilities. …"
              }
              

Producer capabilities

The full mapping from event kind to capability lives in apps/muster_api/lib/src/services/producer_capability_gate.dart. Current v1 set:

Event kind Capability
IncidentCreated ingest.incident_created
IncidentStatusChanged ingest.incident_status_changed
CommandTransferred ingest.command_transferred
CommandAssumed ingest.command_assumed
StrategyChanged ingest.strategy_changed
UnitCheckedIn ingest.unit_checked_in
UnitReleased ingest.unit_released
UnitLocationReported ingest.unit_location_reported
AssignmentMade ingest.assignment_made
AssignmentEnded ingest.assignment_ended
DivisionCreated ingest.division_created
BenchmarkMarked ingest.benchmark_marked

Any event kind not on this list rejects with the unknown event kind reason.


Layer 3 — subscriptions (WebSocket)

GET /v1/departments/{department_id}/incidents/{incident_id}/subscribe

Upgrade to WebSocket for real-time event streaming. Full lifecycle in the subscription guide.


Rate limits

Tier Requests / min Ingest events / min
Tier 2 300 3,000
Tier 3 1,500 30,000
Enterprise (custom) negotiated negotiated

429 responses carry Retry-After in seconds.

Common response headers

  • Retry-After — populated on 429. Seconds until the window resets.
  • Deprecation — populated when the endpoint is deprecated.
  • Sunset — HTTP date when a deprecated endpoint will be removed.

Error response shape

Every non-2xx response is JSON:

{
                "code": "MUSTER_API_ERROR_CODE_UNAUTHENTICATED",
                "auth_rejection_code": "invalidJwt",
                "message": "human-readable detail"
              }
              

Codes:

  • MUSTER_API_ERROR_CODE_UNAUTHENTICATED — 401.
  • MUSTER_API_ERROR_CODE_PERMISSION_DENIED — 403.
  • MUSTER_API_ERROR_CODE_NOT_FOUND — 404.
  • MUSTER_API_ERROR_CODE_RATE_LIMITED — 429.
  • MUSTER_API_ERROR_CODE_INTERNAL — 5xx.