MCPTT Management REST API (v1)
For developers who integrate a CAD, an HR or roster system, or a provisioning script with
MCPTT. This document describes what the management API serves today, and only that. The
machine-readable contract is openapi/management-v1.yaml.
Anything not described here or there is not served.
Status: live at both sites
GET /api/v1/health answers {"status":"ok"} at https://loc1.server.mcptt.com and
https://loc2.server.mcptt.com. Anything older than TLS 1.3 gets 426 on the API paths.
What it is
One HTTP JSON service that reads and writes the system's configuration: orgs (agencies), callsigns and persons, devices (radios and phones), talkgroups and their members, and remote monitoring sessions. Writes go through the same code and transaction path as the admin portal, and every write is audited. The sites pick changes up within seconds, so a radio sees a new talkgroup or membership without a restart.
Base URLs
| Site | API base | Token endpoint |
|---|---|---|
| Site 1 | https://loc1.server.mcptt.com/api/v1 | https://loc1.server.mcptt.com/oauth/token |
| Site 2 | https://loc2.server.mcptt.com/api/v1 | https://loc2.server.mcptt.com/oauth/token |
Both sites share one configuration database and one signing key, so a token from either site
works at both. While that database is unavailable, both sites answer 503.
Authentication
OAuth 2.0 client credentials (the normal way)
-
Get a client. An agency administrator or the operator creates an API client and gives you its client id and secret. The secret (it starts
mcptt_cs_) is shown once; keep it in a secret store.A client belongs to one org and acts in that org and every org below it. It is granted a fixed set of scopes. A client that adds devices or decides pending IMEIs must also be created to act as a user; say so when you ask for it. Only a hash of the secret is stored. Creation and revocation are audited.
-
Trade it for an access token at
POST /oauth/token(RFC 6749 §4.4). Send the client credentials either as HTTP Basic or in the form body, not both. The body must beapplication/x-www-form-urlencoded.curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=client_credentials \ -d scope="talkgroups:read users:read" \ https://loc1.server.mcptt.com/oauth/token{"access_token":"eyJ...","token_type":"Bearer","expires_in":900,"scope":"talkgroups:read users:read"}scopeis optional. Without it, the token carries every scope the client was granted. Asking for a scope the client lacks isinvalid_scope. -
Call the API with
Authorization: Bearer <access_token>.
Access tokens are HS256 JWTs (typ at+jwt) and last 900 s by default (the operator can set
60 to 3600 s; read expires_in). Treat them as opaque. Get a new one when it expires; there are
no refresh tokens. Revoking a client stops new tokens at once. A token already issued works
until it expires. Token answers carry Cache-Control: no-store and Pragma: no-cache.
Token endpoint errors use the OAuth JSON shape, not problem details:
| Status | error | When |
|---|---|---|
| 400 | invalid_request | Wrong content type, missing grant_type, credentials in both places, or half of them in the body |
| 400 | unsupported_grant_type | Any grant but client_credentials |
| 400 | invalid_scope | A scope the client was not granted, a scope this API does not know, or no scope left |
| 401 | invalid_client | No credentials, a malformed Basic header, unknown or revoked client, wrong secret, or the client's org is deleted (with WWW-Authenticate: Basic) |
| 503 | temporarily_unavailable | Database or audit log unavailable (with Retry-After: 1) |
Static tokens (migration only)
The operator can issue a long-lived static bearer token to move an older integration over. They work only where the operator has turned them on, which is off by default. Do not build a new integration on one.
Human sign-in
Human sign-in with OIDC and MFA is not offered on this API. It takes only the two token kinds above.
Scopes
| Scope | Grants |
|---|---|
orgs:read | Read orgs in reach |
orgs:write | Create and rename orgs |
users:read | Read callsigns, persons and other users |
users:write | Create, change and delete callsigns and persons |
users:lock | Lock and unlock users |
devices:read | Read devices; list pending IMEIs (platform token only) |
devices:write | Add, label, assign and remove devices; decide pending IMEIs (platform token only) |
talkgroups:read | Read talkgroups and members |
talkgroups:write | Create, change and delete talkgroups |
talkgroups:members | Add, change and remove talkgroup members |
monitoring:read | Read monitoring sessions, list and fetch their recorded segments |
monitoring:control | Start and stop monitoring sessions |
monitoring:view | Get a viewer token to watch and listen live |
A missing scope is 403 forbidden-scope with a scope member naming it.
Reach and org rules
- A token acts in its org and the orgs below it. A row outside that reach answers
404, never403, so its existence is not revealed. - A platform token is one whose org is the platform org (MCPTT Main). Only a platform token
creates a wholesale org, picks an agency number, or touches pending devices. Other tokens get
403tenant-mismatch. - While the token's org is suspended, its reads work and its writes are
403tenant-mismatchcodeorg_suspended. Once the org is deleted, reads are refused too (codeorg_deleted). A read may see the old state for up to 30 s. - Adding a device, and the three pending-device writes, record who added the device. The token
must carry a user (the API client was created to act as a user, or the static token names
one); otherwise the answer is
403tenant-mismatch.
Conventions
IDs and timestamps
- IDs are UUIDs in lowercase hyphenated form. New ones are UUIDv7. Talkgroup IDs are allocated below 2^63 (the upper 64 bits are zero) but look the same. A device ID is derived from its org and IMEI, so an IMEI removed and added again in the same org gets the same ID back.
- A path ID that is not a UUID answers
404. - Timestamps are RFC 3339 UTC with milliseconds, e.g.
2026-10-03T09:55:37.123Z. - IMEIs travel only in request and response bodies, never in paths, because proxies log paths.
Request bodies
- Writes take
Content-Type: application/json; anything else is415. Bodies are capped at 1 MiB. - Unknown fields are refused with
400typeunknown-field. - Text fields are trimmed and refuse control and invisible formatting characters.
PATCHbodies list only the fields to change. A body that changes nothing is400.
Pagination
Lists return:
{ "items": [ ... ], "next_cursor": "AZLzoAAAcACAAAAAAAAAqg" }
Pass next_cursor back as ?cursor= for the next page; it is null on the last page.
limit is 1 to 500, default 50. Order is fixed per list (by ID; pending devices by IMEI).
Cursors are opaque and do not expire.
Unknown query parameters, and malformed values such as an org_id that is not a UUID, are
refused with a plain-text 400 from the web framework, not a problem document. The monitoring
lists (sessions and segments) are the exception: they answer these with a problem document.
A method a path does not serve (for example PUT /api/v1/orgs) is 405 with an Allow header
and an empty body.
Optimistic concurrency
GET of one resource and every write that returns it send ETag: "42" (the row version). Send
it back as If-Match: "42" on PATCH, DELETE, lock and assignment. A mismatch is 412
precondition-failed. Only one strong tag is accepted; *, weak tags and lists are 400.
If-Match is optional; without it the write applies to the current version.
Each committed write also returns X-Config-Version, the configuration version it committed
as.
Idempotency
Every write accepts Idempotency-Key (1 to 255 visible ASCII characters; otherwise 400 code
idempotency_key). For 24 hours, the same key with the same method, path and query,
If-Match and body replays the first response. The same key with a different request is 422
idempotency-mismatch. Keys are per API client (all access tokens of one client share them),
stored in the database, and checked inside the
write's transaction, so two concurrent retries run the write once and keys hold across
restarts and both sites. Only a committed write is kept; a refused write is not, and retrying
it runs it again.
Correlation
Send X-Correlation-Id: <uuid> to tie a request to your own logs; otherwise the server makes
one. It is echoed on every /api/v1 response (except /health) and on /oauth/token, appears
in every error body as correlation_id, and is written to the audit line.
Errors
Errors are RFC 9457 application/problem+json:
{
"type": "https://mcptt.example/problems/conflict",
"title": "Conflict",
"status": 409,
"detail": "the callsign still has devices; move or remove them first",
"instance": "/api/v1/users/0192f3a0-0000-7000-8000-0000000000b1",
"correlation_id": "0192f3a0-1111-7000-8000-000000000001",
"code": "callsign_has_devices",
"device_ids": ["2f402ba7-25e4-ae16-3199-df5c5269d3bf"]
}
Validation errors add errors:
{
"type": "https://mcptt.example/problems/validation",
"title": "Request failed validation",
"status": 400,
"detail": "imei is 15 digits",
"instance": "/api/v1/devices",
"correlation_id": "0192f3a0-1111-7000-8000-000000000002",
"code": "imei",
"errors": [{ "pointer": "/imei", "code": "imei", "message": "imei is 15 digits" }]
}
detail never contains names, tokens or database messages. Branch on status, the last
segment of type, and code; do not parse detail.
| Status | type | Meaning |
|---|---|---|
| 400 | validation, unknown-field | Bad body, field or header |
| 401 | unauthenticated | No or invalid bearer token (WWW-Authenticate: Bearer) |
| 403 | forbidden-scope | Token lacks the scope |
| 403 | tenant-mismatch | Org rule (platform only, suspended, deleted, no user on the token) |
| 404 | not-found | No such route or row, or outside reach |
| 405 | (no body) | Method not served on that path; see Allow |
| 409 | conflict | Store refusal; see code |
| 412 | precondition-failed | If-Match does not match |
| 415 | validation | Body is not JSON |
| 422 | validation | Well-formed but wrong kind (wrong_kind, wrong_role, wrong_org, parent_kind) |
| 422 | idempotency-mismatch | Key reused with another request |
| 424 | rolled-back | Item of a failed atomic batch |
| 503 | unavailable | Database or audit log unavailable; nothing changed; retry (Retry-After: 1) |
Common 409 codes: name_taken, username_taken, badge_taken, imei_taken,
number_taken, number_held, no_free_number, agency_number_taken, person_already_set,
person_has_callsign, callsign_has_devices, emergency_active, max_devices,
too_many_channels, no_share, no_callsign, rejected_needs_removal, last_system_admin.
The full list is in the OpenAPI Problem.code description.
Emergencies and force
Deleting or locking a user, and moving a device, are refused with 409 emergency_active
(listing emergency_ids) while an affected callsign is in an active emergency. To go ahead,
send {"force": true, "reason": "<10 to 512 characters>"}. These operations answer 200 with
{"emergency_ids": [...], "pending_devices": [...]}, or 202 when some device's part waits
for the emergency to clear.
Rate limits
None are implemented, on the API or on /oauth/token. No RateLimit-* headers are sent and
there is no 429. Limits of 600 requests per minute per token and 3,000 per agency are
planned. Do not rely on their absence; stay under those numbers.
Endpoints (implemented)
43 operations: 42 under /api/v1 (including /health) and POST /oauth/token. Paths below are relative to /api/v1 unless they start with /oauth.
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | /health | none | Liveness: {"status":"ok"} |
| POST | /oauth/token | none (client credentials) | Access token |
| GET | /orgs | orgs:read | List live orgs in reach. Filter parent_id |
| POST | /orgs | orgs:write | Create a wholesale (platform token) or customer org |
| GET | /orgs/{id} | orgs:read | One org |
| PATCH | /orgs/{id} | orgs:write | Rename |
| GET | /users | users:read | List users. Filters org_id, kind, include_deleted |
| POST | /users/callsigns | users:write | Create a callsign |
| POST | /users/persons | users:write | Create a person (no password or PIN is set) |
| GET | /users/invites | users:read | List invites. Filters org_id, include_closed |
| POST | /users/invites | users:write | Invite a person by email to a customer org (ADR 0090) |
| DELETE | /users/invites/{id} | users:write | Revoke an open invite |
| GET | /users/{id} | users:read | One user, deleted ones included |
| PATCH | /users/{id} | users:write | Rename a callsign, change a person's display name, renumber, link a callsign's person |
| DELETE | /users/{id} | users:write | Soft delete (optional force body) |
| PUT | /users/{id}/lock | users:lock | Lock (optional force body) |
| DELETE | /users/{id}/lock | users:lock | Unlock |
| GET | /devices | devices:read | List devices. Filters org_id, callsign_id, include_removed |
| POST | /devices | devices:write | Add a device by IMEI, to a callsign or shared |
| GET | /devices/pending | devices:read | IMEIs that tried to log in unknown (platform token) |
| POST | /devices/pending/approve | devices:write | Add a pending IMEI to an org (platform token) |
| POST | /devices/pending/reject | devices:write | Store a pending IMEI as rejected in an org (platform token) |
| POST | /devices/pending/assign | devices:write | Give a pending IMEI to an agency or a user (platform token) |
| GET | /devices/{id} | devices:read | One device, removed ones included |
| PATCH | /devices/{id} | devices:write | Change the label |
| DELETE | /devices/{id} | devices:write | Remove; its key and enrollment codes go with it |
| PUT | /devices/{id}/assignment | devices:write | Move to a callsign, make shared, or unassign |
| GET | /talkgroups | talkgroups:read | List live talkgroups. Filter owner_org_id |
| POST | /talkgroups | talkgroups:write | Create a normal talkgroup with default settings |
| GET | /talkgroups/{id} | talkgroups:read | One live talkgroup |
| PATCH | /talkgroups/{id} | talkgroups:write | Rename and renumber |
| DELETE | /talkgroups/{id} | talkgroups:write | Soft delete; its number is held, memberships end |
| GET | /talkgroups/{id}/members | talkgroups:read | List members |
| PUT | /talkgroups/{id}/members/{user_id} | talkgroups:members | Add a member or change its role |
| DELETE | /talkgroups/{id}/members/{user_id} | talkgroups:members | Remove a member |
| POST | /batch | each item's own | Up to 500 writes, optionally atomic |
| POST | /monitoring/sessions | monitoring:control | Start monitoring a radio |
| GET | /monitoring/sessions | monitoring:read | List sessions. Filters org_id, state |
| GET | /monitoring/sessions/{id} | monitoring:read | One session |
| POST | /monitoring/sessions/{id}/stop | monitoring:control | Stop a session |
| POST | /monitoring/sessions/{id}/viewer-token | monitoring:view | Viewer WebSocket URL and token to watch and listen live |
| GET | /monitoring/sessions/{id}/segments | monitoring:read | Recorded segments of one track (?track=audio|video) |
| GET | /monitoring/sessions/{id}/segments/{track}/{seq} | monitoring:read | One segment's bytes |
Every write accepts Idempotency-Key, except monitoring stop and viewer-token, which ignore
it (stop is safe to repeat; a viewer token is a credential and is never stored). PATCH, DELETE, lock, unlock and assignment accept
If-Match.
Examples
The examples use these shell variables. IDs in responses are illustrative.
API=https://loc1.server.mcptt.com/api/v1
TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -d grant_type=client_credentials \
https://loc1.server.mcptt.com/oauth/token | jq -r .access_token)
AUTH="Authorization: Bearer $TOKEN"
J="Content-Type: application/json"
Health
curl -s "$API/health"
{"status":"ok"}
Orgs
curl -s -H "$AUTH" "$API/orgs?limit=2"
{
"items": [
{
"org_id": "0192f3a0-0000-7000-8000-0000000000aa",
"parent_id": "0192f3a0-0000-7000-8000-000000000001",
"kind": "customer",
"customer_type": "government",
"name": "Coosada 911",
"agency_number": 1001,
"state": "active",
"created_at": "2026-10-15T19:08:00.000Z",
"updated_at": "2026-10-15T19:08:00.000Z",
"row_version": 3
}
],
"next_cursor": null
}
Create a customer under the token's org (or under a wholesale in reach). kind is wholesale
or customer. A customer needs customer_type (government, business, individual); a
wholesale must not have one. agency_number (2 to 999999) may be asked for by a platform token
only; otherwise the lowest free number from 1001 is taken.
curl -s -H "$AUTH" -H "$J" -H "Idempotency-Key: org-elmore-ems" \
-d '{"parent_id":"'$PARENT'","kind":"customer","customer_type":"government","name":"Elmore EMS"}' \
"$API/orgs"
201, with Location: /api/v1/orgs/{id}, ETag and the org as above. Rename:
curl -s -X PATCH -H "$AUTH" -H "$J" -H 'If-Match: "3"' -d '{"name":"Elmore County EMS"}' "$API/orgs/$ORG"
Users: callsigns and persons
A callsign is the talking identity (what radios log in as and what is heard on air). A
person is a human with a login or badge who talks through their callsign. Other kinds
(dispatcher, integration, gateway) appear in lists but are not created here.
Create a person (all fields but org_id optional; username is 1 to 32 of a-z 0-9 . _ -):
curl -s -H "$AUTH" -H "$J" \
-d '{"org_id":"'$ORG'","display_name":"Jo Smith","badge":"1001","username":"jsmith"}' \
"$API/users/persons"
Create a callsign, optionally linked to that person (name 1 to 32 characters, the radio alias, which may
repeat within the org (the response then has name_in_use: true); unit_number 1 to 8999999, omitted takes the lowest free;
priority_level -10 to 10, default 0):
curl -s -H "$AUTH" -H "$J" \
-d '{"org_id":"'$ORG'","name":"Engine 5","person_id":"'$PERSON'"}' \
"$API/users/callsigns"
201:
{
"user_id": "0192f3a0-0000-7000-8000-0000000000b1",
"org_id": "0192f3a0-0000-7000-8000-0000000000aa",
"kind": "callsign",
"name": "Engine 5",
"username": null,
"badge": null,
"priority_level": 0,
"state": "active",
"person_id": "0192f3a0-0000-7000-8000-0000000000b0",
"unit_number": 17,
"created_at": "2026-10-24T22:10:00.000Z",
"updated_at": "2026-10-24T22:10:00.000Z",
"deleted_at": null,
"row_version": 1
}
name is a callsign's name or a person's display name. state is active, locked or
deleted.
Change a user. name and person_id apply to a callsign; display_name to a person (the other
kind is 422 wrong_kind). A callsign's person is set once and never replaced (409
person_already_set).
curl -s -X PATCH -H "$AUTH" -H "$J" -H 'If-Match: "1"' -d '{"name":"Engine 5A","unit_number":18}' "$API/users/$CALLSIGN"
Lock, unlock, delete:
curl -s -X PUT -H "$AUTH" "$API/users/$CALLSIGN/lock"
curl -s -X DELETE -H "$AUTH" "$API/users/$CALLSIGN/lock"
curl -s -X DELETE -H "$AUTH" -H "$J" -d '{"force":true,"reason":"unit destroyed in fire"}' "$API/users/$CALLSIGN"
Lock and delete answer 200 (or 202) with {"emergency_ids":[],"pending_devices":[]};
unlock answers 204. Locking a callsign drops its devices; locking a person also locks their
callsign and resets their phones. A callsign that still has devices cannot be deleted (409
callsign_has_devices).
Users: invites by email
Invite a person to a customer org in reach (ADR 0090 §5). role is one of supervisor,
dispatcher, radio.user or auditor; an agency admin (tenant.admin) is invited only from the
portal's Users page, by a signed-in admin (400 code role here). Listed invites can still show
tenant.admin for invites made in the portal. display_name (up to 64) and callsign
(the radio alias, up to 32) are optional. The org must be an active customer (422
not_a_customer). An org may write 50 invites in any 24 hours, portal and API together; an org
that signed itself up (/signup) and that MCPTT has not verified (cji_verified_at unset) may
write 5. Past that the answer is 409 with code invite_cap and detail "the org has written the
most invites allowed in a day"; try again once the oldest of the day is 24 hours old.
curl -s -H "$AUTH" -H "$J" -H "Idempotency-Key: invite-jsmith" \
-d '{"org_id":"'$ORG'","email":"jo.smith@example.org","role":"dispatcher","callsign":"Disp 2"}' \
"$API/users/invites"
201, with Location: /api/v1/users/invites/{id}:
{
"invite_id": "0192f3a0-0000-7000-8000-0000000000c1",
"org_id": "0192f3a0-0000-7000-8000-0000000000aa",
"email": "jo.smith@example.org",
"role": "dispatcher",
"display_name": null,
"callsign": "Disp 2",
"state": "queued",
"created_at": "2026-11-03T15:00:00.000Z",
"expires_at": "2026-11-10T15:00:00.000Z",
"sent_at": null,
"accepted_at": null,
"revoked_at": null
}
This service sends no mail. The invite is queued until an edge with an SMTP relay
(docs/runbooks/smtp.md) claims it, within about 30 seconds: the edge gives it the token the
mail carries, so the link exists only in that mail, and the invite becomes sent (with
sent_at) once the relay took it. An invite whose mail did not go out is claimed again after
10 minutes with a new token. The person
opens the link on the portal and chooses a password, or, when the address already has a login,
enters that password to add the org to it; the invite is then accepted. Links work for 7 days
from creation and once. Without an edge with mail an invite stays queued and expires.
List open invites (add include_closed=true for accepted, revoked and expired ones too), and
revoke one (204; an accepted or revoked invite is 409 invite_closed):
curl -s -H "$AUTH" "$API/users/invites?org_id=$ORG"
curl -s -X DELETE -H "$AUTH" "$API/users/invites/$INVITE"
Invites are not configuration: they take no X-Config-Version. Before configuration database
migration 35 these endpoints answer 503.
Devices
Add a radio by IMEI. Give exactly one of callsign_id or "shared": true. kind is
terminal (default) or phone; label is up to 64 characters.
curl -s -H "$AUTH" -H "$J" \
-d '{"org_id":"'$ORG'","imei":"356938035643809","callsign_id":"'$CALLSIGN'","label":"Truck 5"}' \
"$API/devices"
201:
{
"device_id": "2f402ba7-25e4-ae16-3199-df5c5269d3bf",
"org_id": "0192f3a0-0000-7000-8000-0000000000aa",
"imei": "356938035643809",
"kind": "terminal",
"callsign_id": "0192f3a0-0000-7000-8000-0000000000b1",
"owner_user_id": null,
"shared": false,
"label": "Truck 5",
"key_state": "awaiting_key",
"added_by_user_id": "0192f3a0-0000-7000-8000-0000000000c9",
"enrolled_at": null,
"last_seen_at": null,
"created_at": "2026-10-24T22:12:00.000Z",
"updated_at": "2026-10-24T22:12:00.000Z",
"removed_at": null,
"row_version": 1
}
key_state is awaiting_key, active or rejected. Adding an IMEI that was removed from the
same org restores the old device.
Label, move and remove:
curl -s -X PATCH -H "$AUTH" -H "$J" -d '{"label":"Truck 5 spare"}' "$API/devices/$DEVICE"
curl -s -X PUT -H "$AUTH" -H "$J" -d '{"callsign_id":"'$OTHER'"}' "$API/devices/$DEVICE/assignment"
curl -s -X PUT -H "$AUTH" -H "$J" -d '{"shared":true}' "$API/devices/$DEVICE/assignment"
curl -s -X PUT -H "$AUTH" -H "$J" -d '{"none":true}' "$API/devices/$DEVICE/assignment"
curl -s -X DELETE -H "$AUTH" "$API/devices/$DEVICE"
The assignment body takes exactly one of callsign_id, "shared": true or "none": true,
plus optional force and reason. Only a terminal can be shared or unassigned; an unassigned
terminal is refused at login until it is assigned again. The answer is 200 or 202 with the
emergency_ids / pending_devices body. Remove answers 204.
Pending devices (platform token only)
A radio whose IMEI is not provisioned and tries to log in becomes pending. Only a platform token sees and decides them.
curl -s -H "$AUTH" "$API/devices/pending"
{
"items": [
{
"imei": "490154203237518",
"attestation_level": 0,
"attempts": 4,
"first_seen_at": "2026-10-24T20:01:12.345Z",
"last_seen_at": "2026-10-24T21:40:03.002Z"
}
],
"next_cursor": null
}
Approve into an org (same body as POST /devices):
curl -s -H "$AUTH" -H "$J" -d '{"org_id":"'$ORG'","imei":"490154203237518","shared":true}' "$API/devices/pending/approve"
Reject into an org, so the edge refuses it by name instead of listing it again:
curl -s -H "$AUTH" -H "$J" -d '{"org_id":"'$ORG'","imei":"490154203237518"}' "$API/devices/pending/reject"
Assign, to finish provisioning in one step:
# to a user: a callsign, or a person's callsign (409 no_callsign if the person has none)
curl -s -H "$AUTH" -H "$J" -d '{"user_id":"'$PERSON'","imei":"990000862471854"}' "$API/devices/pending/assign"
# to an agency as a shared device
curl -s -H "$AUTH" -H "$J" -d '{"org_id":"'$ORG'","imei":"353918057125474","shared":true}' "$API/devices/pending/assign"
# to an agency under a new callsign named from the label ("Radio 5474" without one)
curl -s -H "$AUTH" -H "$J" -d '{"org_id":"'$ORG'","imei":"353918057125474","label":"Engine 7"}' "$API/devices/pending/assign"
All three answer 201 with the device, or 404 when the IMEI is not pending. With both
org_id and user_id, the user must be in that org (422 wrong_org).
Talkgroups and members
Create a normal talkgroup with default settings. name is 1 to 64 characters, unique in the
owner org. tg_number (1 to 64999) is optional; omitted takes the owner's lowest free number.
curl -s -H "$AUTH" -H "$J" -H "Idempotency-Key: tg-fire-1" \
-d '{"owner_org_id":"'$ORG'","name":"Fire Dispatch"}' "$API/talkgroups"
201:
{
"talkgroup_id": "00000000-0000-0000-0000-00000000012c",
"owner_org_id": "0192f3a0-0000-7000-8000-0000000000aa",
"name": "Fire Dispatch",
"kind": "normal",
"tg_number": 3,
"priority": 0,
"all_call": false,
"broadcast": false,
"recording": "always",
"transcription": false,
"hang_time_ms": 3000,
"transmit_limit_ms": 60000,
"created_at": "2026-10-24T22:15:00.000Z",
"updated_at": "2026-10-24T22:15:00.000Z",
"deleted_at": null,
"row_version": 1
}
The other values shown are the store's defaults for a new talkgroup. Only name and
tg_number can be changed through the API.
curl -s -X PATCH -H "$AUTH" -H "$J" -H 'If-Match: "1"' -d '{"name":"Fire Main","tg_number":4}' "$API/talkgroups/$TG"
curl -s -X DELETE -H "$AUTH" "$API/talkgroups/$TG"
Members. A callsign is member (default) or rx_only; a dispatcher holds dispatcher. A user
of another org joins only through that org's active share of the talkgroup (409 no_share).
A callsign holds at most 100 talkgroups (409 too_many_channels).
curl -s -X PUT -H "$AUTH" -H "$J" -d '{"role":"member"}' "$API/talkgroups/$TG/members/$CALLSIGN"
curl -s -X DELETE -H "$AUTH" "$API/talkgroups/$TG/members/$CALLSIGN"
curl -s -H "$AUTH" "$API/talkgroups/$TG/members"
Both writes answer 204. The list:
{
"items": [
{
"user_id": "0192f3a0-0000-7000-8000-0000000000b1",
"org_id": "0192f3a0-0000-7000-8000-0000000000aa",
"kind": "callsign",
"name": "Engine 5",
"role": "member",
"share_id": null,
"position": 0
}
],
"next_cursor": null
}
name is null for a member outside the token's reach (a guest org's names are its own).
Batch
Up to 500 writes in one request. Each item names a write of this API by method and path (with
or without /api/v1), its body, and optionally if_match (the ETag, quotes included). Each
item needs its own scope.
curl -s -H "$AUTH" -H "$J" -H "Idempotency-Key: roster-2026-10-24" -d '{
"atomic": true,
"items": [
{"method":"POST","path":"/talkgroups","body":{"owner_org_id":"'$ORG'","name":"Fire 2"}},
{"method":"PUT","path":"/talkgroups/'$TG'/members/'$CALLSIGN'","body":{"role":"member"}},
{"method":"PATCH","path":"/talkgroups/'$TG'","body":{"name":"Fire Main"},"if_match":"\"43\""}
]}' "$API/batch"
The answer is 200 with one result per item:
{
"atomic": true,
"committed": true,
"results": [
{ "index": 0, "status": 201, "body": { "talkgroup_id": "...", "name": "Fire 2" }, "etag": "\"1\"", "location": "/api/v1/talkgroups/..." },
{ "index": 1, "status": 204 },
{ "index": 2, "status": 200, "body": { "talkgroup_id": "...", "name": "Fire Main" }, "etag": "\"44\"" }
]
}
atomic: trueruns every item in one transaction. If one fails, nothing is committed:committedisfalse, the failing item carries its problem, and every other item is424rolled-back.X-Config-Versionis sent only when an atomic batch commits.- Without
atomic, each item commits on its own and a failure does not stop the rest;committedis absent. Retried with the sameIdempotency-Key, the batch replays the items that committed and runs the rest. - The batch itself is refused (a problem, not
200) for a bad body, 0 or more than 500 items, or a reused key with another body.
Monitoring
Remote monitoring streams a radio's microphone and, if asked, a camera to any number of viewers over its own connection to the site, and records everything. The site's edge runs the session; the API starts, lists, stops and watches it. A session runs until someone stops it (portal, console, API, CAD command) or until 60 minutes pass with no viewer connected. An emergency clear or a radio going offline does not stop it; the radio reconnects into the same session.
Session states: starting, publishing, reconnecting, ended. recording_state is
recording, failed or complete. Instants are RFC 3339 UTC. Every start, stop and viewer
token is audited; monitoring is CJI.
# start (one open session per device; reason is 10 to 512 characters)
curl -s -X POST "$API/monitoring/sessions" -H "$AUTH" -H "$J" \
-H "Idempotency-Key: cad-incident-2291-mon" \
-d '{"org_id":"0192f3a0-...-0aa","device_id":"5f1c...","reason":"Officer down, no answer on TAC 2",
"audio":true,"camera":"rear","covert":false}'
# 201, Location: /api/v1/monitoring/sessions/0192f3b4-...
# {"session_id":"0192f3b4-...","org_id":"0192f3a0-...","device_id":"5f1c...","callsign_id":"...",
# "site":"loc1","trigger":"api","emergency_id":null,"started_by":"api:client-...",
# "reason":"Officer down, no answer on TAC 2","audio":true,"camera":"rear","covert":false,
# "state":"starting","created_at":"2026-10-25T09:40:12.031Z","publishing_at":null,"ended_at":null,
# "stopped_by":null,"stop_cause":null,"recording_state":"recording","viewers_now":0,
# "live_url":"wss://monitor.loc1.server.mcptt.com/monitor/0192f3b4a1c27e3d8b5f60a9c4d2e118/live",
# "view_url":"https://monitor.loc1.server.mcptt.com/monitor/0192f3b4a1c27e3d8b5f60a9c4d2e118/view?token=..."}
# list open sessions of an org in reach (default: the token's org)
curl -s "$API/monitoring/sessions?org_id=0192f3a0-...&state=open&limit=50" -H "$AUTH"
# {"items":[{...session...}],"next_cursor":null}
# one session
curl -s "$API/monitoring/sessions/$SID" -H "$AUTH"
# stop; stopping an ended session answers it as it is
curl -s -X POST "$API/monitoring/sessions/$SID/stop" -H "$AUTH"
audio defaults to true, camera to none (none, rear, front), covert to false.
A covert session shows nothing on the radio while it runs; when it stops the radio shows when it
was monitored. Asking for neither audio nor a camera is 400 code nothing_to_monitor.
Every session answer (start, list, one session, stop) carries live_url, the viewer WebSocket of
the site holding the session, when that site can serve live viewing. It holds no secret; open it
with a viewer token (below).
The start answer alone also carries view_url, a web page that plays the session's audio and video
in a browser with no sign-in. Anyone with the link can watch, so treat it as a secret and share it
only with the people who should watch; many can open it at once. It works until the session ends
(at most 7 days), and the page then shows "Monitoring ended". The page may be embedded in an
iframe in your own console. Because of the link, the start answer is sent with
Cache-Control: no-store. Retrying a start with the same Idempotency-Key returns the same
answer, the same view_url included. view_url is not shown again by any other call.
Watch and listen live
POST /monitoring/sessions/{id}/viewer-token returns, for one viewer, the WebSocket URL of the
site holding the session and a token. Each call makes a new viewer and is audited. The
response carries Cache-Control: no-store; treat the token as a secret and hand it only to the
person watching.
curl -s -X POST "$API/monitoring/sessions/$SID/viewer-token" -H "$AUTH"
# 201
# {"url":"wss://monitor.loc1.server.mcptt.com/monitor/0192f3b4a1c27e3d8b5f60a9c4d2e118/live",
# "token":"v1....","expires_ms":1792922412031}
urliswss://monitor.locN.server.mcptt.com/monitor/<session id as 32 hex digits>/live, wherelocNis the site holding the session (the radio's site), which need not be the site you called. Use it as given.- Connect to
url?token=<token>beforeexpires_ms(Unix time in milliseconds, 10 minutes after issue). A socket that is already open is not cut when the token expires. A token works at either site but only for its own session. - A session that has ended, or is not there, is
404not-foundwith nocode.
The viewer WebSocket
The token is the only credential: the socket takes no cookie and no Authorization header. A
browser's Origin must be one of the system's own names (https://cp.mcptt.com,
https://locN.server.mcptt.com, https://monitor.locN.server.mcptt.com, and the portal names
of agencies that have their own); a client that sends no Origin (a server-side integration) is
accepted. So a web page of your own cannot open the socket directly: relay it through your
backend, or embed the view_url page instead.
The handshake is refused, with a short plain-text body, as follows:
| Status | Body | When |
|---|---|---|
401 | viewer token required | No token in the query |
401 | bad_token, token_expired | The token is not valid, or expires_ms has passed |
403 | origin not allowed | Origin is present and is not one of the system's names |
403 | wrong_session | The token is for another session |
404 | not found, session_not_found | Bad session id in the path, or the session is not running at this site |
426 | websocket upgrade required | Not a WebSocket upgrade |
Server to viewer, text frames (JSON):
-
First,
hello:{"type":"hello","session":"<32 hex digits>","audio":{"codec":"opus","sample_rate":48000,"channels":1},"video":{"codec":"avc1"},"started_ms":<Unix ms>} -
Then
status, on every change and at least every 5 s:{"type":"status","radio":"connected","audio":"live","video":"qvga","detail":null,"viewers":2,"recording":"recording","audio_rx_ms":<Unix ms or null>,"video_rx_ms":<Unix ms or null>}Field Values radioconnected,reconnecting,offline(gone 60 s or more)audiolive,off,mic_unavailable,muted_ptt,no_datavideovga,qvga,stills,paused,camera_error,starting,off,no_datadetailText the radio gave with its status, or nullviewersViewers connected now recordingrecording,failed,offaudio_rx_ms,video_rx_msWhen the edge last received audio, or a video frame; nullif neveraudioandvideoare what the radio reports it sends, exceptno_data: the edge's own finding that the radio reports live but nothing arrived for 2 s (5 s forstills). It is the signal that something is broken between the radio and the edge. -
Last,
ended:{"type":"ended","by":"<who stopped it, or idle>","ended_ms":<Unix ms>}, then the server closes with code 1000.
Server to viewer, binary frames, integers big-endian:
| Kind | Layout |
|---|---|
| Audio | byte 0x01; ts_us u64 (radio capture time, microseconds); seq u32; one Opus packet (48 kHz, mono, 20 ms) |
| Video | byte 0x02; ts_us u64; flags u8 (bit 0: key frame); width u16; height u16; one H.264 Annex B access unit |
The first video frame a viewer gets is always a key frame (the edge sends its last key frame and the frames since, then live frames). Each key frame carries SPS and PPS in front.
Viewer to server: the text frame {"type":"keyframe"} asks for a key frame; the edge passes it to
the radio at most once a second per session. Anything else is ignored. Frames from the viewer are
limited to 4 KiB.
Each viewer has its own bounded queue. When a viewer falls behind, video is dropped up to the next key frame; audio is never dropped to make room for video. A viewer that cannot take even audio for 10 s is dropped and may reconnect with a new token; the edge closes it with code 1013 when the socket can still carry a close frame, and otherwise just drops the connection, so treat any close or a silent drop as the signal to reconnect.
A browser sketch (behind your backend, which calls viewer-token with its API credentials and,
because the browser sends Origin, relays the socket to it):
const ws = new WebSocket(relayUrl); // your backend's relay of url?token=...
ws.binaryType = 'arraybuffer';
ws.onmessage = (m) => {
if (typeof m.data === 'string') {
const msg = JSON.parse(m.data); // hello, status, ended
return;
}
const v = new DataView(m.data);
const tsUs = v.getBigUint64(1);
if (v.getUint8(0) === 0x01) {
const opus = new Uint8Array(m.data, 13); // feed an AudioDecoder ({codec:'opus'})
} else if (v.getUint8(0) === 0x02) {
const key = (v.getUint8(9) & 1) === 1;
const annexB = new Uint8Array(m.data, 14); // feed a VideoDecoder ({codec:'avc1...'})
}
};
// Ask for a picture now rather than at the next key frame.
ws.send(JSON.stringify({ type: 'keyframe' }));
The session's 60-minute idle rule counts connected viewers, so a viewer that stays connected keeps the session open; close the socket when done. The live stream is what the radio sent, byte for byte; the recording below is the system of record.
Recorded segments
The recording is the system of record: 6-second segments per track, audio as Ogg/Opus, video as fragmented MP4 (H.264), stored as received. Segments appear while the session runs, so a player can scrub back during the session as well as after it.
curl -s "$API/monitoring/sessions/$SID/segments?track=audio" -H "$AUTH"
# {"items":[{"seq":0,"started_at":"2026-10-25T09:40:14.200Z","duration_ms":6000,"codec":"opus"}, ...]}
curl -s "$API/monitoring/sessions/$SID/segments/video/12" -H "$AUTH" -o seg12.m4s
The segment bytes are streamed with Cache-Control: no-store. The first segment fetched per
caller and session is audited at the edge (monitor.playback).
Monitoring errors
| Status | Type, code | When |
|---|---|---|
400 | validation, codes reason, camera, nothing_to_monitor, track, state, cursor | Bad body or query |
403 | forbidden-scope | Missing monitoring:* scope |
403 | tenant-mismatch, org_suspended | Start or stop while the token's org is suspended |
404 | not-found (no code) | Session, device or org outside the token's reach, or not there |
409 | conflict, already_open | The device already has an open session |
422 | validation, with the edge's code when this API knows it (for example reason_too_short, camera_unsupported), else code validation and edge_code (for example no_media) | The edge refused the request |
503 | unavailable, codes offline, not_supported (as the edge gives them) | The radio is offline or cannot be monitored. Other edge codes, such as monitoring_off (monitoring not set up at this site) or other_site (the session is held at a site this site cannot reach for it), come as edge_code |
503 | unavailable, monitor_unreachable, monitor_unconfigured, monitor_failed | This site's monitoring service did not answer, is not set up for the API, or failed |
An edge code this API does not know is passed as edge_code. Edge error text is never passed on.
Audit
Every write, refused write and token request is written to the API's audit log before the
response is sent, and copied into the audit view of the admin portal. The actor is
api:client-<client_id> for an access token and oauth:client-<client_id> for a token request.
Reads of these resources are not audited (none of them is CJI), except monitoring: the edge
records every viewer token issued (monitor.view) and the first segment fetch per caller and
session (monitor.playback) in its access audit, with the API client as the actor.
Planned, not yet available
Everything below is planned and is not served. A request to any of these paths answers
404; a method that a served path does not offer answers 405.
| Area | Planned paths | Notes |
|---|---|---|
| Human auth | OIDC bearer tokens, MFA (mfa-required), X-Tenant-Id | Only client credentials and static tokens work |
| Rate limits | RateLimit-* headers, 429 rate-limited | No limits at all today |
| Cursor expiry | 15 min, tenant-bound cursors | Cursors never expire |
| Deprecation headers | Deprecation, Sunset | Not sent |
| Tenants and policy | /tenants, /tenants/{id}, /tenants/{id}/policy, /tenants/{id}/contacts/pinned | Orgs exist as /orgs (read, create, rename only); no delete, suspend or settings |
| Sites | /sites, /sites/{id} | |
| Callsign and people views | /callsigns, /callsigns/{id}, /callsigns/{id}/devices, /people, /people/{id}, ?unit= lookups | Use /users with kind |
| User channels, roles | /users/{id}/channels, /users/{id}/channels/order, /users/{id}/roles | |
| Talkgroup settings | priority, all_call, broadcast, recording, transcription, timers as writable fields; 202 deferral for emergency-frozen talkgroups | Readable only; PATCH takes name and tg_number |
| Talkgroup number lookup | lookup by n or a-n; number fields on shares | |
| Memberships | affiliation_policy, position writes, If-Match on member PUT | |
| Dynamic regroup | /talkgroups/dynamic, /talkgroups/{id}/forced-selection | |
| Shares, patches | share acceptance, /patches | |
| Device enrollment and certificates | /devices/enrollments, /devices/{id}/certificates | |
| Device inventory fields | manufacturer, model, firmware, battery, accessories, online state | Not in the device body |
| Profiles, statuses | /profiles, /statuses | |
| Emergency | /emergency-profiles, /users/{id}/emergency-profile, /emergency-recipients, /emergencies and clear and link revoke | |
| Self-service | everything under /me | |
| Scan lists | /scan-lists, /users/{id}/scan-lists, /users/{id}/scan-state | |
| Recordings and transcripts | /recordings, /recordings/{id}/playback-url, legal holds, /transcripts | |
| Calls and messages | /private-calls, /voice-messages, /call-alerts, /incidents, /messages | |
| Locations and geofences | /locations/current, /locations/history, /locations/requests, /geofences | |
| Webhooks | /webhooks and test, rotate-secret, deliveries, replay | The event stream is a separate service (events-websocket.md) |
| OTAP | /otap/releases, /otap/cohorts, /otap/rollouts, approvals, device health | |
| Audit and approvals | /audit, /approvals | Audit is visible in the portal only |
| Client management | Managing API clients through this API; immediate access-token revocation | An agency administrator or the operator creates an API client and gives you its client id and secret |
