Real-time event stream (WebSocket) — integrator guide
Status: describes what the servers do today. Anything marked unverified could not be confirmed from the server's behavior and may change.
Changes 11/03/26
For integrations already connected:
- A subscription to the
presencetopic now also receivespresence.online,presence.offlineandpresence.radios, live and in the snapshot. Nothing else in the subscription changes. If your client only handlespresence.battery,presence.channelandpresence.accessory, ignore the new types, or subscribe to those three types by name instead ofpresence. The new types are in section 5.1. - On a talkgroup another agency shares with you, the
floor.grantedandfloor.releasedyou receive for one of that agency's talkers carry only itscallsign. Itsimei,unit_number,device_labelandemailare not sent to you. Events about your own agency's devices are unchanged. - A token without
events:identityno longer receivesemailanywhere in a payload, including inside nested objects. No event produced today nests it.
1. What it is
A per-site WebSocket that pushes events from the MCPTT core as they happen: who is talking on which talkgroup, device battery, accessories (RSM), selected channel and scanning, bearer and signal, per-transmission audio quality, and positions. It is for CAD vendors, our own dispatcher developers and other integrations that need live state without polling.
It carries six commands: clear an emergency, ask a callsign's devices for a position, send a call alert, and start, stop and watch remote monitoring of a radio (section 6). Any other command is refused. There are no webhooks (section 7).
2. Connecting
2.1 Endpoints
| Site | URL |
|---|---|
| Site 1 (loc1) | wss://events.loc1.server.mcptt.com/v1/stream |
| Site 2 (loc2) | wss://events.loc2.server.mcptt.com/v1/stream |
- TLS 1.3 only. Only the path
/v1/streamis served on these names; anything else is 404. - Do not use
loc1.server.mcptt.com/loc2.server.mcptt.comfor the stream; they do not route/v1/stream. - Availability: on at both sites. A plain
GETanswers426 Upgrade Required(the upgrade must be WebSocket version 13); open a WebSocket. A site where the stream is turned off answers 404. Tokens are issued per site by an admin at/admin/event-tokens.
2.2 Two sites
The system runs two active sites. Each site runs its own stream with its own tokens, its own sequence numbers and its own replay buffer. What the code supports:
- Tokens are per site and are not copied between sites. You need one token from each site's portal.
- Sequence numbers (
seq) of the two sites are unrelated. A resume cursor from site 1 is meaningless at site 2; presenting it there getssystem.resync_required(section 4.4). - Which events appear at which site: each site publishes the events of the radios and floor activity its own core handles. Floor events for a talkgroup may include talkers at the other site, since talkgroups span both sites. Whether every event a CAD needs appears at both sites, or only at the site the radio is attached to, is unverified; assume a radio's presence, signal and location events appear only at the site it is connected to.
Recommendation, given the above: connect to both sites at once, each with its own token and its own cursor, and merge. De-duplicate floor events by correlation.transmission_id plus type, and emergencies by payload.emergency_id. Do not de-duplicate by envelope id or seq across sites; they are per site. If you can hold only one connection, fail over to the other site and treat the switch as a fresh start (subscribe with last_value: true, take the snapshot as the new state).
2.3 Getting a token
Tokens are created by an agency admin in the system-admin portal:
- Sign in to the portal of the site,
https://loc1.server.mcptt.com/admin/event-tokensorhttps://loc2.server.mcptt.com/admin/event-tokens. It is also linked as "Event stream tokens" at the foot of the Sites page. The page exists only when the stream is enabled at that site. - The site's own agency must be the one selected in the portal banner; otherwise the page answers 409 and asks you to pick it.
- Under "New token": enter a name (for example
CAD prod, max 64 characters), choose "Expires after" 7, 30 or 90 days, tick only the scopes needed, press "Create token". - The next page shows the token once (
mcptt_evt_followed by 43 base64url characters), its id (evt_plus 16 hex digits), scopes and expiry. Copy it then; only its SHA-256 is stored. An agency holds at most 100 active tokens per site; revoke old ones to make room. - Repeat at the other site.
Who may create or revoke: an admin with write access to the site's agency. Read-only admins and admins of a suspended agency see the list but not the forms. Every create and revoke is audited (events.token.create, events.token.revoke).
Revoking is the "Revoke" button in the token list. A connection using a revoked token is closed with 4401 at its next heartbeat (within 20 s).
2.4 Scopes and tenant
A token belongs to exactly one tenant (the site's agency) and carries a set of scopes:
| Scope | Lets the connection receive |
|---|---|
events:floor | floor.* |
events:presence | presence.* |
events:network | network.* |
events:location + locations:read | location.* (both are required; either alone gives nothing) |
events:emergency | emergency.* (no person's name: the person fields of events.md §4.2 need emergency:read, which is not offered, and the core does not put them in the events) |
events:monitoring | monitoring.* (remote monitoring sessions and their viewers; never the reason) |
events:identity | No events of its own. Adds the personal fields, today email (the signed-in person's account email), to the events the token's other scopes admit (section 5.1). Without it the same events arrive without email and without any marker that it was removed |
| (none needed) | system.* |
Command scopes (section 6) let the connection send a command; they admit no events:
| Scope | Command |
|---|---|
emergency:clear | emergency.clear |
locations:request | location.request (the fixes arrive as location.position, so the token also needs events:location and locations:read to see them) |
calls:alert | call_alert.send |
monitoring:control | monitoring.start, monitoring.stop |
monitoring:view | monitoring.viewer_token (a token for the viewer WebSocket of the session; each one is audited) |
Lifetimes: 7, 30 or 90 days; at most 30 days if the token has locations:read. Positions are CJI: each connection that can receive them writes an events.stream.locations audit record before the first position leaves.
A token without events:emergency gets emergency topics back in denied.
2.5 Authentication on the wire
Nothing is read from HTTP headers or the query string; do not put the token in the URL. Open the WebSocket, then send hello as the first text frame within 5 seconds:
{"op":"hello","token":"mcptt_evt_<secret>","envelope_version":"1","resume_from_seq":1761300000000123}
| Field | Required | Meaning |
|---|---|---|
token | yes | The API token. |
envelope_version | no | If present must be "1"; anything else closes with 4400. |
resume_from_seq | no | The last seq you processed; see section 4.4. |
tenant_id | no | If present must equal the token's tenant UUID, else 4401. |
Reply:
{"op":"welcome","tenant_id":"0192f3a0-7c1e-7a3b-9f1e-2b3c4d5e6f70","session_id":"ses_3f9c2a1b7d4e8f60","server_seq":1761300000000456,"replay_available_from_seq":1761300000000001,"heartbeat_s":20}
A first frame that is not readable JSON, or a hello without token, closes with 4400. A first frame that is JSON but not hello closes with 4401. No hello within 5 seconds closes with 4401.
Token renewal: 60 s before the token expires the server sends {"op":"reauth_required"}. Send {"op":"reauth","token":"<new token>"} and receive {"op":"reauthed"}. The new token must belong to the same tenant (else 4401). If it is a different token that already holds 8 connections at this site, the connection closes with 1013. Without a reauth the connection closes with 4401 at expiry.
3. The event envelope
Every event is one WebSocket text frame holding one JSON object:
| Field | Type | Meaning |
|---|---|---|
envelope_version | string | "1" |
id | string | UUIDv7, unique per site, stable across replays of the same event |
seq | integer | Per tenant per site, strictly increasing by one per event; the resume cursor. Starts at the clock time, in microseconds since the epoch, when the site first numbers an event for your agency after it starts, so it keeps increasing across restarts. Can exceed 2^53: parse as a 64-bit integer or BigInt, not a JS Number, if you compare exactly |
tenant_id | string | Tenant UUID |
class | string | e.g. floor |
type | string | <class>.<name>, e.g. floor.granted |
occurred_at | string | RFC 3339 UTC, microseconds, when it happened at the producer (for positions: the fix time) |
emitted_at | string | RFC 3339 UTC, microseconds, when the hub numbered it |
source | string | mcptt-edge@site1 or mcptt-edge@site2 |
correlation | object | transmission_id when the event belongs to a burst; otherwise {} |
subject | object | Any of talkgroup_id, alias_id, user_id, device_id (UUID text); absent keys are left out |
payload | object | Per-type body (section 5). Optional fields are left out, never null |
Example (floor.granted, values illustrative):
{
"envelope_version": "1",
"id": "019a1b2c-3d4e-7f01-8a2b-3c4d5e6f7081",
"seq": 1761300000000457,
"tenant_id": "0192f3a0-7c1e-7a3b-9f1e-2b3c4d5e6f70",
"class": "floor",
"type": "floor.granted",
"occurred_at": "2026-10-24T22:15:03.412907Z",
"emitted_at": "2026-10-24T22:15:03.413120Z",
"source": "mcptt-edge@site1",
"correlation": {"transmission_id": "019a1b2c-3d40-7000-9000-00000000a001"},
"subject": {
"talkgroup_id": "00000000-0000-0000-0000-000000000101",
"alias_id": "00000000-0000-0000-0000-00000000c0de",
"user_id": "00000000-0000-0000-0000-00000000c0de",
"device_id": "00000000-0000-0000-0000-0000000d0001"
},
"payload": {
"transmission_id": "019a1b2c-3d40-7000-9000-00000000a001",
"talkgroup_id": "00000000-0000-0000-0000-000000000101",
"alias_id": "00000000-0000-0000-0000-00000000c0de",
"user_id": "00000000-0000-0000-0000-00000000c0de",
"device_id": "00000000-0000-0000-0000-0000000d0001",
"callsign": "Medic 12",
"device_label": "PS35 #4",
"priority": 2,
"emergency": false
}
}
alias_id and user_id are the same value today (the callsign's user id). To show a talkgroup's name, look its UUID up with the management API: GET /api/v1/talkgroups/{id} (scope talkgroups:read, see management-rest.md); a callsign's with GET /api/v1/users/{id} (scope users:read).
Ignore unknown fields and unknown type values; new ones are added without a version change (section 10).
4. Protocol
4.1 Message flow
client server
| -- open wss://.../v1/stream -->|
| -- hello ---------------------->|
| <------------------- welcome ---|
| -- subscribe ------------------>|
| <---------------- subscribed ---|
| <---- replayed events, or system.resync_required (only with resume_from_seq)
| <------------ snapshot (one or more frames, if last_value)
| <---------------- caught_up ----| (only with resume_from_seq)
| <---------- live events ... ----|
| <---------------- heartbeat ----| every 20 s
| -- heartbeat_ack -------------->|
Nothing is delivered before the first subscribe. The connection attaches to the live feed at the first subscribe, not at hello.
4.2 Subscribe and topics
{"op":"subscribe","topics":["floor","presence.battery","location/device_id=00000000-0000-0000-0000-0000000d0001"],"last_value":true}
Reply:
{"op":"subscribed","topics":["floor","presence.battery"],"denied":["location/device_id=00000000-0000-0000-0000-0000000d0001"]}
Topic grammar: <class>[.<name>][/<key>=<uuid>]*, or *.
classis one of the classes in section 5;floor.grantednarrows to one type. An unknown class or type isdenied.- Filters:
talkgroup_id,user_id,alias_id,device_id, value in hyphenated UUID form (8-4-4-4-12 hex digits), up to 4 filters, ANDed. Example:floor/talkgroup_id=<uuid>. *matches every event the token's scopes allow.- A topic the token has no scope for is
denied, not an error. At most 64 topics per connection; extra topics aredenied. Max topic length 256. - Multiple topics are ORed. Each event is delivered once even if several topics match.
last_value(defaulttrue): send a snapshot of current state for the subscribed topics (section 4.3).- A later
subscribeadds topics; withlast_valueit sends a snapshot only for the newly added topics. {"op":"unsubscribe","topics":["presence.battery"]}removes topics matching the exact text; reply{"op":"unsubscribed","topics":[...]}.
4.3 Snapshot (last values)
With last_value: true the server sends the current state as {"op":"snapshot","events":[<envelope>, ...]}, at most 256 envelopes per frame (several frames for a large tenant; there is no end marker other than the next non-snapshot frame or caught_up). The snapshot holds the latest:
floor.grantedorfloor.idleper talkgroup (who holds it now, or that it is free),- the newer of
presence.onlineandpresence.offlineper device: the site's unit list (section 5.1), location.position,presence.battery,presence.channel,network.signalper device,presence.radiosper device,presence.accessoryper device and accessory,- every emergency active at the site: its
emergency.started, and oneemergency.updatedper device that joined it as a trigger (trigger_added). An emergency leaves the snapshot when it is cleared or merged;target_seenupdates are not kept, - per
monitoringsession, as section 5.3a says.
Snapshot envelopes are the original events with their original seq. They are counted against nothing; treat them as state, not as new occurrences. Personal fields follow the token, as for live events: without events:identity the snapshot copies carry no email.
The unit list holds only devices that logged in or went offline at this site since the edge last started. A radio that has been off since before a restart is not in it until it logs in. The two sites keep separate lists (section 5.1, "Online, offline and two sites").
4.4 Resume and replay
Keep the highest seq you processed (from live events and replays; the seq in heartbeat frames is the server's current position). After a disconnect send it as resume_from_seq in hello, then subscribe again with the same topics.
- If the cursor is inside the site's replay buffer: every missed event matching your topics is sent in order, then
{"op":"caught_up","seq":N}. No snapshot follows a successful replay. - If the cursor is older than the buffer, newer than the site's last
seq(for example from the other site), or from before an edge restart: the first frame is asystem.resync_requiredevent, then the snapshot (iflast_value), thencaught_up. Discard your state and rebuild it from the snapshot.
The replay buffer is in memory, per tenant per site: the last 100,000 events or 64 MiB, whichever is smaller. It does not survive an edge restart. A longer, persistent replay window is planned and not available.
Delivery is at-least-once across reconnects: you may see an event you already processed. De-duplicate by id within a site.
4.5 Heartbeats
Every 20 s the server sends {"op":"heartbeat","t":"2026-10-24T22:15:20.000000Z","seq":1761300000000460}. Answer each with {"op":"heartbeat_ack"}. When two heartbeats are outstanding at the next tick, the server closes with 4408. WebSocket ping/pong frames are accepted and ignored; they do not count as an ack. Each heartbeat also re-checks the token, so a revoked token closes with 4401 within 20 s.
4.6 Backpressure
The server never slows the radio system for a client. Each connection may fall at most 5,000 events behind the live feed. Past that, or if one frame cannot be written within 10 s, the server sends (best effort)
{"op":"overflow","dropped":1234,"resume_from_seq":1761300000000460}
and closes with 4429. Reconnect with that resume_from_seq.
Separately, the core hands events to the hub through a bounded queue of 8,192; if the hub falls behind, events are dropped there before numbering. Such drops do not create seq gaps and are not visible to clients; the count is shown on the token page ("dropped at intake"). A client cannot detect them. Rely on the snapshot after any reconnect for state that matters.
4.7 Limits
| Limit | Value | When exceeded |
|---|---|---|
Connections waiting for hello, per site | 32 | HTTP 503 too many connections on the upgrade |
Connections waiting for hello, per source address (IPv6: per /64) | 4 | HTTP 503 too many connections |
| Authenticated connections per site | 256 | close 1013 |
| Authenticated connections per token | 8 | close 1013 |
| Client frame size | 64 KiB | WebSocket protocol error, connection dropped |
| Binary frames from the client | none accepted | close 1003 |
| Topics per connection | 64 | extra topics denied |
| Commands per connection / per tenant at a site | 60 / 600 a minute | rejected /problems/rate-limited |
| Command idempotency keys kept, per site | 20,000, each up to 24 h | the oldest are forgotten first |
| Active tokens per agency per site | 100 | the portal refuses a new token |
4.8 Disconnects you should expect
An edge restart or a reload of the site's TLS proxy closes every connection (1001 or an abrupt TCP close). After an edge restart your cursor is outside the buffer, so you get system.resync_required and a snapshot. Reconnect with backoff (for example 1, 2, 4 ... 30 s with jitter).
5. Event catalog
Field lists below are from the producer code; optional fields (?) are omitted when unknown.
5.1 Emitted today and receivable
| Type | Scope | Fires when | subject | payload |
|---|---|---|---|---|
floor.granted | events:floor | A talker gets the floor (the "who's talking" event). Also on preemption, after the floor.released of the preempted burst | talkgroup_id, alias_id, user_id, device_id | transmission_id, talkgroup_id, talkgroup_name?, talkgroup_number?, alias_id, user_id, device_id, priority (integer), emergency (bool), preempted_transmission_id?, identity |
floor.released | events:floor | A burst ends | talkgroup_id, alias_id, user_id, device_id | transmission_id, talkgroup_name?, talkgroup_number?, duration_ms, end_cause: release|revoke, identity |
floor.idle | events:floor | The talkgroup becomes free | talkgroup_id | talkgroup_id, talkgroup_name?, talkgroup_number?, hang_until (currently the time it went idle) |
presence.online | events:presence | A device's session logs in at this site | device_id, user_id?, alias_id? | device_id, user_id?, alias_id?, device_kind: radio|phone|console, app_version?, site (integer, the site number), identity |
presence.offline | events:presence | That session ends and no other logged-in session of the device remains at this site, or a newer session replaced it | device_id, user_id?, alias_id? | device_id, reason: logout|link_lost|replaced|server_stop|other, identity |
presence.battery | events:presence | A device reports battery; at most one per 5 min per device unless charging changes or pct < 15 | device_id, user_id?, alias_id? | device_id, pct, charging, identity |
presence.accessory | events:presence | An accessory appears, disappears, or its battery changes (at most once a minute per accessory) | device_id, user_id?, alias_id? | device_id, kind: ble_ptt|ble_rsm|wired_rsm|headset|other, name, connected, battery_pct?, at, identity |
presence.channel | events:presence | The device's selected talkgroup or scan set changes | device_id, talkgroup_id? (the selected one), user_id?, alias_id? | device_id, selected_talkgroup_id?, selected_talkgroup_name?, selected_talkgroup_number?, scanning, scan_talkgroup_ids[], scan_talkgroups[]: {talkgroup_id, name?, number?}, identity |
presence.radios | events:presence | The device's Wi-Fi or Bluetooth is switched on or off (change-driven only; the first report after the edge starts is also sent). Needs a radio or phone app 27.0.90 or later and an edge from 11/03/26 | device_id, user_id?, alias_id? | device_id, wifi_enabled?, bluetooth_enabled?, at; a field is absent when the device does not know it (for example Bluetooth on Android 12+ without the Nearby devices permission), identity |
network.signal | events:network | Bearer changes (at once) or signal level changes (at most once a minute per device) | device_id, user_id?, alias_id? | device_id, bearer, mcc_mnc?, carrier?, level?, rsrp_dbm?, rsrq_db?, sinr_db?, rssi_dbm?, band?, identity |
network.tx_report | events:network | After a burst, the quality report for it | talkgroup_id, alias_id, user_id, device_id; correlation.transmission_id | transmission_id, talkgroup_id, grade?: A+|A|B|C|F, expected, union_held, receivers: {"A+":n,"A":n,"B":n,"C":n,"F":n}, not_reported, identity |
location.position | events:location + locations:read | A device sends a location batch; only the newest fix of the batch is published | device_id, user_id?, alias_id?; correlation.transmission_id? | alias_id?, user_id?, device_id, lat, lon, accuracy_m?, altitude_m?, speed_mps?, heading_deg?, fix_at, source: gnss|network|fused, battery_pct?, request_id?, identity |
system.resync_required | none | Your resume cursor cannot be served (section 4.4) | {} | reason: "replay_window_exceeded", from_seq, available_from_seq |
Identity in the table means these fields, each left out when unknown. They describe the subject's device_id, and a CAD can key units on them:
imei(string): the device's IMEI. A phone with no IMEI has a server-made id that starts with00. That id is never sent asimei.callsign(string): the callsign's name.unit_number(string): the callsign's unit number.device_label(string): the device's label.email(string): the account email of the person signed in on the device, or of the person whose callsign it is. It is sent only if your token holdsevents:identity(section 2.4).
Talkgroup names are as your agency sees them in the portal. A talkgroup shared to your agency reads as its alias and guest number. Your own talkgroup reads as its name and number. Another agency's talkgroup reads as <agency>-<n>. talkgroup_number is a string. On a site without the configuration database the name is the channel name and there is no number.
Online, offline and two sites. Each site reports only the sessions connected to it. A dual-homed radio is online at both sites, and each site sends its own presence.online and presence.offline. To show a unit as reachable, take it as online at any site you are connected to. presence.offline is not sent while another logged-in session of the device remains at the site. A replaced offline is followed by the new session's presence.online. device_kind is console for a dispatcher, phone for a phone device or an account sign-in without an IMEI, else radio. The reason values mean:
logout: the client closed the connection cleanly.link_lost: idle timeout, reset or transport error.replaced: a newer session of the device, or the account's new phone, took over.server_stop: the edge closed the session while stopping.other: anything else, for example an admin lock or a suspended phone app.
Exact meaning of network.tx_report counters (expected, union_held, not_reported) follows the radio's TX_REPORT (crates/mcptt-proto/src/tx_report.rs); a precise integrator-facing definition is unverified here.
5.2 Examples
Payloads only; the envelope is as in section 3.
floor.released:
{"type":"floor.released","class":"floor","correlation":{"transmission_id":"019a1b2c-3d40-7000-9000-00000000a001"},
"payload":{"transmission_id":"019a1b2c-3d40-7000-9000-00000000a001","talkgroup_name":"Fire Dispatch","talkgroup_number":"101","duration_ms":4210,"end_cause":"release",
"imei":"490154203237518","callsign":"Medic 12","unit_number":"12","device_label":"PS35 #4"}}
floor.granted, as a token with events:identity receives it:
{"type":"floor.granted","class":"floor","correlation":{"transmission_id":"019a1b2c-3d40-7000-9000-00000000a001"},
"payload":{"transmission_id":"019a1b2c-3d40-7000-9000-00000000a001","talkgroup_id":"00000000-0000-0000-0000-000000000101","talkgroup_name":"Fire Dispatch","talkgroup_number":"101",
"alias_id":"00000000-0000-0000-0000-00000000c0de","user_id":"00000000-0000-0000-0000-00000000c0de","device_id":"00000000-0000-0000-0000-0000000d0001","priority":0,"emergency":false,
"imei":"490154203237518","callsign":"Medic 12","unit_number":"12","device_label":"PS35 #4","email":"j.smith@county.example"}}
floor.idle:
{"type":"floor.idle","class":"floor","subject":{"talkgroup_id":"00000000-0000-0000-0000-000000000101"},
"payload":{"talkgroup_id":"00000000-0000-0000-0000-000000000101","talkgroup_name":"Fire Dispatch","talkgroup_number":"101","hang_until":"2026-10-24T22:15:07.623000Z"}}
presence.online, presence.offline:
{"type":"presence.online","payload":{"device_id":"00000000-0000-0000-0000-0000000d0001","user_id":"00000000-0000-0000-0000-00000000c0de","alias_id":"00000000-0000-0000-0000-00000000c0de",
"device_kind":"radio","app_version":"mcptt-android 27.0.60","site":1,"imei":"490154203237518","callsign":"Medic 12","unit_number":"12","device_label":"PS35 #4"}}
{"type":"presence.offline","payload":{"device_id":"00000000-0000-0000-0000-0000000d0001","reason":"link_lost","imei":"490154203237518","callsign":"Medic 12","unit_number":"12","device_label":"PS35 #4"}}
location.position:
{"type":"location.position","class":"location","occurred_at":"2026-10-24T22:16:00.000000Z",
"subject":{"device_id":"00000000-0000-0000-0000-0000000d0001","user_id":"00000000-0000-0000-0000-00000000c0de","alias_id":"00000000-0000-0000-0000-00000000c0de"},
"payload":{"alias_id":"00000000-0000-0000-0000-00000000c0de","user_id":"00000000-0000-0000-0000-00000000c0de","device_id":"00000000-0000-0000-0000-0000000d0001",
"callsign":"Medic 12","device_label":"PS35 #4","lat":32.9876543,"lon":-86.1234567,"accuracy_m":6,"speed_mps":12.5,"heading_deg":271.5,
"fix_at":"2026-10-24T22:16:00.000000Z","source":"gnss","battery_pct":81}}
presence.battery, presence.channel, presence.accessory:
{"type":"presence.battery","payload":{"device_id":"00000000-0000-0000-0000-0000000d0001","pct":14,"charging":false}}
{"type":"presence.channel","payload":{"device_id":"00000000-0000-0000-0000-0000000d0001","selected_talkgroup_id":"00000000-0000-0000-0000-000000000101","selected_talkgroup_name":"Fire Dispatch","selected_talkgroup_number":"101","scanning":true,"scan_talkgroup_ids":["00000000-0000-0000-0000-000000000102"],"scan_talkgroups":[{"talkgroup_id":"00000000-0000-0000-0000-000000000102","name":"County Mutual Aid","number":"sheriff-4"}],"callsign":"Medic 12","device_label":"PS35 #4"}}
{"type":"presence.accessory","payload":{"device_id":"00000000-0000-0000-0000-0000000d0001","kind":"ble_rsm","name":"RSM-200","connected":true,"battery_pct":64,"at":"2026-10-24T22:17:00.000000Z"}}
presence.radios:
{"type":"presence.radios","class":"presence","payload":{"device_id":"00000000-0000-0000-0000-0000000d0001","wifi_enabled":true,"bluetooth_enabled":false,"at":"2026-11-03T14:40:00.000000Z"}}
network.signal, network.tx_report:
{"type":"network.signal","payload":{"device_id":"00000000-0000-0000-0000-0000000d0001","bearer":"lte","mcc_mnc":"313100","level":3,"rsrp_dbm":-98,"rsrq_db":-11,"sinr_db":8,"band":14}}
{"type":"network.tx_report","payload":{"transmission_id":"019a1b2c-3d40-7000-9000-00000000a001","talkgroup_id":"00000000-0000-0000-0000-000000000101","grade":"A","expected":210,"union_held":209,"receivers":{"A+":5,"A":2,"B":0,"C":0,"F":0},"not_reported":1}}
system.resync_required:
{"type":"system.resync_required","class":"system","subject":{},"correlation":{},
"payload":{"reason":"replay_window_exceeded","from_seq":1761200000000999,"available_from_seq":1761300000000001}}
5.3 Emergencies
Need events:emergency:
| Type | Fires when | payload (from code) |
|---|---|---|
emergency.started | An emergency is declared | emergency_id, alias_id, user_id, device_id, device_label?, callsign?, callsign_unverified, reason: sos|man_down|lone_worker|duress|external, silent, profile_id, auto_call |
emergency.updated | Another trigger joins, or an alert target saw the alert | emergency_id and either trigger_added: {device_id, device_label?, trigger, triggered_at} or target_seen: {device_id, shown_at} |
emergency.cleared | Cleared, or merged into the other site's record of the same emergency | emergency_id, cleared_by: self|dispatcher|admin|... , by_user_id?, at, duration_ms, false_alarm; or on merge emergency_id, merged_into, at |
All three also carry the identity fields of section 5.1 (imei?, callsign?, unit_number?, device_label?, email?) for the envelope's subject.device_id: the triggering device for emergency.started and for an emergency.updated with trigger_added, the target device for one with target_seen, and the emergency's first device for emergency.cleared. A field the event already carries, such as the callsign of emergency.started, is not replaced.
Active emergencies are in the snapshot (section 4.3): after any reconnect the snapshot holds each emergency active at the site as its emergency.started plus one emergency.updated per extra trigger device. A cleared emergency is not in the snapshot; a client that missed the clear and resyncs learns of it by its absence. A clear made through the emergency.clear command reads cleared_by: dispatcher with no by_user_id.
Example, emergency.started (payload only):
{"emergency_id":"019a1b2c-3d40-7000-9000-00000000e001","alias_id":"00000000-0000-0000-0000-00000000c0de","user_id":"00000000-0000-0000-0000-00000000c0de",
"device_id":"00000000-0000-0000-0000-0000000d0001","device_label":"PS35 #4","callsign":"Medic 12","callsign_unverified":false,"reason":"sos","silent":false,
"profile_id":"00000000-0000-0000-0000-000000000000","auto_call":false,"imei":"490154203237518","unit_number":"12","email":"j.smith@county.example"}
5.3a Remote monitoring
Need events:monitoring (ADR 0084, ADR 0085, events.md §4.7.1):
| Type | Fires when |
|---|---|
monitoring.started | A session is authorized and the radio is being asked |
monitoring.publishing | The radio's media is reaching the edge |
monitoring.reconnecting | The radio's connection or media dropped; the session goes on |
monitoring.viewer_joined, monitoring.viewer_left | A viewer socket opened or closed (viewer names who) |
monitoring.recording | The recording state changed (recording_state) |
monitoring.status | The status a viewer sees changed (below) |
monitoring.stopped | A person or the idle rule (60 min with nobody watching) stopped it (stopped_by, stop_cause) |
Payload: session_id, org_id, device_id, callsign_id?, site, trigger, emergency_id?, started_by, audio, camera, covert, state, created_at, publishing_at?, ended_at?, stopped_by?, stop_cause?, recording_state, viewers_now?. The reason is never in an event; viewer events add viewer.
monitoring.status (ADR 0085) has a different payload: session_id, radio, audio, video, detail, viewers, recording, audio_rx_ms, video_rx_ms, the same fields as the status frame of the viewer WebSocket (management-rest.md, Monitoring, "The viewer WebSocket"; remote-monitoring.md §3). audio and video are what the radio reports it sends, except no_data: the radio reports live but nothing arrived for 2 s (5 s for stills). audio_rx_ms and video_rx_ms are Unix milliseconds or null. It is sent on every change only, not on the viewer socket's 5 s repeat.
The snapshot keeps the last of every type but the viewer ones and monitoring.status.
5.4 Planned, not yet available
Defined in docs/specs/events.md §4 but not produced by the core today:
| Class | Types |
|---|---|
floor | floor.requested, floor.denied, floor.queued, floor.taken, floor.hold, floor.revoked |
emergency | emergency.acked, emergency.escalated |
location | location.request_state, location.geofence, location.stale |
presence | presence.status, presence.affiliated, presence.scan, presence.deaffiliated, presence.primary_device, presence.locked |
call / recording | call.started, call.answered, call.participant, call.ended, call.alert, call.radio_check, recording.ready, recording.integrity |
transcript | transcript.partial, transcript.final, transcript.translated, transcript.keyword |
safety / monitoring | safety.trigger, safety.checkin_due, safety.checkin_ok, monitoring.authorized, monitoring.started, monitoring.stopped, monitoring.notified |
network | network.link, network.link_class, network.wan_state, network.outage_cluster, network.migration, network.site, network.gateway |
talkgroup / scan | talkgroup.created, talkgroup.updated, talkgroup.deleted, talkgroup.patch, talkgroup.regroup, scan.list_changed |
system | system.ping |
message / status | message.sent, message.delivered, message.read, message.attachment_ready, message.recalled, status.set |
| others | user_check.result, voice_message.*, broadcast.delivered, broadcast.completed, paging.detected, device.stunned, device.revived, fleet.* |
Their field lists are in events.md §4 and may change before they ship.
6. Commands
Send a command on the same connection, after welcome:
{"op":"command","envelope_version":"1","command_id":"c-0001","idempotency_key":"0192f3a0-7c1e-7a3b-9f1e-2b3c4d5e6f70",
"tenant_id":"<the tenant_id of welcome>","type":"emergency.clear","reason":"checked by phone call",
"payload":{"emergency_id":"0192f3a0-...","reason":"checked by phone call","false_alarm":false}}
The answer is one frame with your command_id:
{"op":"command_result","command_id":"c-0001","outcome":"completed","result":{"emergency_id":"0192f3a0-...","cleared_by":"dispatcher","false_alarm":false}}
outcome is completed (done), accepted (sent on; what follows arrives as events), rejected (nothing was done) or failed (tried, did not work). rejected and failed carry problem: {type, title}. The connection stays open either way.
Rules, checked in this order:
envelope_version, if present, must be"1", else/problems/validation.typemust be one of the six below. A command type that is defined but not offered (for exampleemergency.ack) isrejected/problems/unavailable; any other type is/problems/validation.command_idis required: 1 to 64 letters, digits,-,_or.(a UUID fits), else/problems/validation. Acommand_idthat does not fit is echoed asnull.- The token must hold the command's scope (section 2.4), else
/problems/forbidden. - At most 60 commands a minute per connection and 600 per tenant at the site, else
/problems/rate-limited. tenant_idis required and must be the token's tenant, else/problems/forbidden.idempotency_keyis required, with the same form ascommand_id, else/problems/validation.reason, if present, is at most 256 characters with no control characters;payload, if present, is an object. Else/problems/validation.- A repeated
idempotency_keywith the same type, reason and payload gets the stored result again without running the command. Keys are kept per tenant per site, in memory, for 24 hours or until the site holds 20,000 (the oldest go first), and are lost when the edge restarts. The same key with anything different is/problems/idempotency-mismatch; while the first is still running,/problems/conflict.
Every command is audited: events.command with the token id, command_id, type and key (never the payload or reason), before it runs or with its refusal. The action itself is then audited by the core exactly as when an admin does it in the portal, with token:<id> as the actor.
| Type | Scope | Payload | Does | Result |
|---|---|---|---|---|
emergency.clear | emergency:clear | emergency_id (UUID), reason (at least 10 characters; the envelope reason is used if the payload has none), false_alarm? | The portal's clear (Devices page), cleared as dispatcher. Only an active emergency of the token's tenant | completed, {emergency_id, cleared_by, false_alarm}; emergency.cleared follows. Not active: rejected /problems/not-found |
location.request | locations:request | targets: {alias_ids: [UUID, ...]} (1 to 16 callsigns), mode?: "once"; the envelope reason is required (at most 200 characters) | The portal's "Locate now" for every device of each callsign logged in at this site | accepted, {requests: [{alias_id, device_id?, request_id?, outcome: sent|offline|other_site|not_delivered}]}; the fix arrives as location.position. None sent: failed /problems/offline |
call_alert.send | calls:alert | to_alias_id (UUID), text? (1 to 64 characters; CJI, never logged) | A radio's call alert, sent from a dispatch party named after the token's label | accepted, {alert_id, state: "sent"}. Offline, unknown, in an emergency or not reachable: failed with /problems/offline, not-found, conflict or forbidden |
monitoring.start | monitoring:control | device_id (UUID), reason (at least 10 characters; the envelope reason is used if the payload has none), org_id? (must be the token's tenant), audio? (default true), camera? (none default, rear, front), covert? (default false), emergency_id? (UUID) | Starts remote monitoring of the radio, as POST /api/v1/monitoring/sessions does | completed, the session object of the management API (without reason, live_url or view_url); monitoring.* events follow. Unknown radio or org_id not the token's tenant: rejected /problems/not-found. Already monitored: rejected /problems/conflict. A refused field: rejected /problems/validation. Radio offline or not able: failed /problems/unavailable |
monitoring.stop | monitoring:control | session_id (UUID) | Stops the session; stopping an ended one is harmless | completed, the session object, ended. Unknown session or one of another tenant: rejected /problems/not-found |
monitoring.viewer_token | monitoring:view | session_id (UUID) | A new viewer of the session, audited (monitor.view) | completed, {session_id, url, token, expires_ms}: connect to url?token=<token> before expires_ms (Unix ms, 10 minutes); an open socket is not cut at expiry. url is wss://monitor.locN.server.mcptt.com/monitor/<session id as 32 hex digits>/live at the site holding the session. Protocol: management-rest.md, Monitoring, "The viewer WebSocket". Unknown session or one of another tenant: rejected /problems/not-found |
Not offered: emergency.ack (the core has no acknowledge yet), mode: periodic and targets.talkgroup_ids for location.request, to_role_id for call_alert.send (no dispatch positions yet); each is rejected /problems/unavailable. Terminal results are not repeated as events with correlation.command_id. Delivery and acknowledgement of a call alert by the radio are not reported back. The connection handles one command at a time: until it answers, it reads no other frame and sends no events. At a site where remote monitoring is off, the three monitoring commands are rejected /problems/unavailable. The title of a refused monitoring command ends with the site's reason code in parentheses, for example (device_offline); branch on type, not on title.
7. Webhooks
Not available. No site delivers webhooks and there is no way to register one. Use the WebSocket stream.
8. Errors and close codes
In-band errors (connection stays open):
| Frame | Cause |
|---|---|
{"op":"error","problem":{"type":"/problems/validation","title":"unknown or malformed op"}} | Unparseable JSON or unknown op after hello |
{"op":"error","problem":{"type":"/problems/validation","title":"hello was already accepted"}} | A second hello |
denied list in subscribed | Topic malformed, out of scope, or over 64 |
HTTP responses to the upgrade:
| Status | Cause |
|---|---|
| 404 | Stream not enabled at this site, or wrong path |
| 426 | Not a valid WebSocket upgrade (version 13 required) |
| 503 | Body too many connections: too many connections waiting for hello (site or source address) |
WebSocket close codes:
| Code | Reason text | Cause | Client action |
|---|---|---|---|
| 1000 | closed | Client closed | — |
| 1001 | server stopping | Edge shutting down | Reconnect with backoff and resume |
| 1003 | binary frames are reserved | Client sent a binary frame | Fix client |
| 1013 | too many connections | 256 per site or 8 per token in use, at hello or at a reauth to another token | Retry later; close idle connections |
| 4400 | bad hello | First frame not readable JSON, a hello without token, or envelope_version not "1" | Fix client |
| 4401 | unauthorized | No hello within 5 s; first frame JSON but not hello; token unknown, revoked or expired; tenant_id mismatch; reauth with a bad token | Get a valid token; do not retry in a tight loop |
| 4408 | heartbeat timeout | Two heartbeats unanswered | Answer heartbeats; reconnect and resume |
| 4429 | slow consumer | Over 5,000 events behind, or a send stalled 10 s | Reconnect with the resume_from_seq from overflow |
9. Example clients
Both examples: connect, authenticate, subscribe, answer heartbeats, keep the cursor, reconnect with resume and backoff, rebuild state on system.resync_required. Keep the token outside source code (environment variable). Use one client instance per site.
9.1 JavaScript (Node 22+ global WebSocket, or a browser)
// Usage: MCPTT_TOKEN=... node stream.mjs wss://events.loc1.server.mcptt.com/v1/stream
const url = process.argv[2];
const token = process.env.MCPTT_TOKEN;
const topics = ["floor", "presence", "network"];
let cursor = null; // last seq processed, kept as a string to avoid precision loss
let backoff = 1000;
function onEvent(ev) {
if (ev.type === "system.resync_required") {
console.log("resync: discard state, snapshot follows");
return;
}
console.log(ev.seq_text ?? ev.seq, ev.type, JSON.stringify(ev.payload));
}
// seq can exceed 2^53; pull it out as text before JSON.parse rounds it.
function parse(text) {
const msg = JSON.parse(text);
const m = /"seq":(\d+)/.exec(text);
if (m && msg.envelope_version) msg.seq_text = m[1];
return msg;
}
function connect() {
const ws = new WebSocket(url);
ws.onopen = () => {
const hello = { op: "hello", token, envelope_version: "1" };
let text = JSON.stringify(hello);
if (cursor) text = text.slice(0, -1) + `,"resume_from_seq":${cursor}}`;
ws.send(text);
};
ws.onmessage = (m) => {
const msg = parse(m.data);
switch (msg.op) {
case "welcome":
backoff = 1000;
ws.send(JSON.stringify({ op: "subscribe", topics, last_value: true }));
break;
case "subscribed":
if (msg.denied.length) console.warn("denied:", msg.denied);
break;
case "heartbeat":
ws.send('{"op":"heartbeat_ack"}');
break;
case "snapshot":
for (const raw of msg.events) onEvent(raw); // state, not new occurrences
break;
case "caught_up":
break;
case "overflow":
cursor = /"resume_from_seq":(\d+)/.exec(m.data)[1];
break;
case "reauth_required":
console.warn("token expires in 60 s; send {op:'reauth', token:<new>}");
break;
case "error":
case "command_result":
console.warn(msg);
break;
default:
if (msg.envelope_version) {
onEvent(msg);
cursor = msg.seq_text;
}
}
};
ws.onclose = (e) => {
console.warn("closed", e.code, e.reason);
if ([1003, 4400, 4401].includes(e.code)) return; // fix config before retrying
const wait = backoff + Math.random() * 1000;
backoff = Math.min(backoff * 2, 30000);
setTimeout(connect, wait);
};
}
connect();
Note: snapshot events carry their original seq; the example does not advance the cursor from them, which is safe (a replay may then resend some events; de-duplicate by id).
9.2 Python (asyncio, websockets 12+)
# pip install websockets
# MCPTT_TOKEN=... python stream.py wss://events.loc1.server.mcptt.com/v1/stream
import asyncio, json, os, random, sys
import websockets
URL, TOKEN = sys.argv[1], os.environ["MCPTT_TOKEN"]
TOPICS = ["floor", "presence", "network"]
FATAL = {1003, 4400, 4401}
def on_event(ev):
if ev["type"] == "system.resync_required":
print("resync: discard state, snapshot follows")
return
print(ev["seq"], ev["type"], json.dumps(ev["payload"]))
async def run():
cursor, backoff = None, 1.0
while True:
try:
async with websockets.connect(URL, ping_interval=None) as ws:
hello = {"op": "hello", "token": TOKEN, "envelope_version": "1"}
if cursor is not None:
hello["resume_from_seq"] = cursor
await ws.send(json.dumps(hello))
async for text in ws:
msg = json.loads(text) # Python ints are exact; no seq precision issue
op = msg.get("op")
if op == "welcome":
backoff = 1.0
await ws.send(json.dumps({"op": "subscribe", "topics": TOPICS, "last_value": True}))
elif op == "subscribed" and msg["denied"]:
print("denied:", msg["denied"])
elif op == "heartbeat":
await ws.send('{"op":"heartbeat_ack"}')
elif op == "snapshot":
for ev in msg["events"]:
on_event(ev)
elif op == "overflow":
cursor = msg["resume_from_seq"]
elif op == "reauth_required":
print("token expires in 60 s")
elif op in ("error", "command_result"):
print(msg)
elif "envelope_version" in msg:
on_event(msg)
cursor = msg["seq"]
except websockets.ConnectionClosed as e:
code = e.rcvd.code if e.rcvd else None
print("closed", code, e.rcvd.reason if e.rcvd else "")
if code in FATAL:
return
except OSError as e:
print("connect failed:", e)
await asyncio.sleep(backoff + random.random())
backoff = min(backoff * 2, 30.0)
asyncio.run(run())
10. Versioning and change policy
- The envelope is version
"1"(envelope_version). A client may send"envelope_version":"1"inhello; any other value is refused with 4400. A breaking change to the envelope or to the meaning of an existing field would be a new envelope version; the server supports only"1"today, and how long an old version would run beside a new one is not yet set (unverified). - Additive changes are made without a version change: new event types, new classes, new scopes, new optional payload fields, new
opreplies. Clients must ignore unknowntype,opand fields. - Changes are recorded in this guide.
- When replay moves to persistent storage, the envelope, ops and topic grammar are meant to stay the same; what changes is the replay window (longer, surviving restarts) and possibly the relationship of
seqbetween sites. Stream tokens may later be replaced by management API client credentials; thehelloshape is meant to stay.
