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_mps— omitted 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_movementlist when the incident recorded noUnitLocationReportedevents. 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.