MCPTT Mission Critical Push-to-Talk Service sealMCPTT.comMission Critical Push-to-Talk ServiceDeveloper documentation

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

SiteAPI baseToken endpoint
Site 1https://loc1.server.mcptt.com/api/v1https://loc1.server.mcptt.com/oauth/token
Site 2https://loc2.server.mcptt.com/api/v1https://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)

  1. 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.

  2. 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 be application/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"}
    

    scope is optional. Without it, the token carries every scope the client was granted. Asking for a scope the client lacks is invalid_scope.

  3. 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:

StatuserrorWhen
400invalid_requestWrong content type, missing grant_type, credentials in both places, or half of them in the body
400unsupported_grant_typeAny grant but client_credentials
400invalid_scopeA scope the client was not granted, a scope this API does not know, or no scope left
401invalid_clientNo credentials, a malformed Basic header, unknown or revoked client, wrong secret, or the client's org is deleted (with WWW-Authenticate: Basic)
503temporarily_unavailableDatabase 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

ScopeGrants
orgs:readRead orgs in reach
orgs:writeCreate and rename orgs
users:readRead callsigns, persons and other users
users:writeCreate, change and delete callsigns and persons
users:lockLock and unlock users
devices:readRead devices; list pending IMEIs (platform token only)
devices:writeAdd, label, assign and remove devices; decide pending IMEIs (platform token only)
talkgroups:readRead talkgroups and members
talkgroups:writeCreate, change and delete talkgroups
talkgroups:membersAdd, change and remove talkgroup members
monitoring:readRead monitoring sessions, list and fetch their recorded segments
monitoring:controlStart and stop monitoring sessions
monitoring:viewGet 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, never 403, 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 403 tenant-mismatch.
  • While the token's org is suspended, its reads work and its writes are 403 tenant-mismatch code org_suspended. Once the org is deleted, reads are refused too (code org_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 403 tenant-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 is 415. Bodies are capped at 1 MiB.
  • Unknown fields are refused with 400 type unknown-field.
  • Text fields are trimmed and refuse control and invisible formatting characters.
  • PATCH bodies list only the fields to change. A body that changes nothing is 400.

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.

StatustypeMeaning
400validation, unknown-fieldBad body, field or header
401unauthenticatedNo or invalid bearer token (WWW-Authenticate: Bearer)
403forbidden-scopeToken lacks the scope
403tenant-mismatchOrg rule (platform only, suspended, deleted, no user on the token)
404not-foundNo such route or row, or outside reach
405(no body)Method not served on that path; see Allow
409conflictStore refusal; see code
412precondition-failedIf-Match does not match
415validationBody is not JSON
422validationWell-formed but wrong kind (wrong_kind, wrong_role, wrong_org, parent_kind)
422idempotency-mismatchKey reused with another request
424rolled-backItem of a failed atomic batch
503unavailableDatabase 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.

MethodPathScopePurpose
GET/healthnoneLiveness: {"status":"ok"}
POST/oauth/tokennone (client credentials)Access token
GET/orgsorgs:readList live orgs in reach. Filter parent_id
POST/orgsorgs:writeCreate a wholesale (platform token) or customer org
GET/orgs/{id}orgs:readOne org
PATCH/orgs/{id}orgs:writeRename
GET/usersusers:readList users. Filters org_id, kind, include_deleted
POST/users/callsignsusers:writeCreate a callsign
POST/users/personsusers:writeCreate a person (no password or PIN is set)
GET/users/invitesusers:readList invites. Filters org_id, include_closed
POST/users/invitesusers:writeInvite a person by email to a customer org (ADR 0090)
DELETE/users/invites/{id}users:writeRevoke an open invite
GET/users/{id}users:readOne user, deleted ones included
PATCH/users/{id}users:writeRename a callsign, change a person's display name, renumber, link a callsign's person
DELETE/users/{id}users:writeSoft delete (optional force body)
PUT/users/{id}/lockusers:lockLock (optional force body)
DELETE/users/{id}/lockusers:lockUnlock
GET/devicesdevices:readList devices. Filters org_id, callsign_id, include_removed
POST/devicesdevices:writeAdd a device by IMEI, to a callsign or shared
GET/devices/pendingdevices:readIMEIs that tried to log in unknown (platform token)
POST/devices/pending/approvedevices:writeAdd a pending IMEI to an org (platform token)
POST/devices/pending/rejectdevices:writeStore a pending IMEI as rejected in an org (platform token)
POST/devices/pending/assigndevices:writeGive a pending IMEI to an agency or a user (platform token)
GET/devices/{id}devices:readOne device, removed ones included
PATCH/devices/{id}devices:writeChange the label
DELETE/devices/{id}devices:writeRemove; its key and enrollment codes go with it
PUT/devices/{id}/assignmentdevices:writeMove to a callsign, make shared, or unassign
GET/talkgroupstalkgroups:readList live talkgroups. Filter owner_org_id
POST/talkgroupstalkgroups:writeCreate a normal talkgroup with default settings
GET/talkgroups/{id}talkgroups:readOne live talkgroup
PATCH/talkgroups/{id}talkgroups:writeRename and renumber
DELETE/talkgroups/{id}talkgroups:writeSoft delete; its number is held, memberships end
GET/talkgroups/{id}/memberstalkgroups:readList members
PUT/talkgroups/{id}/members/{user_id}talkgroups:membersAdd a member or change its role
DELETE/talkgroups/{id}/members/{user_id}talkgroups:membersRemove a member
POST/batcheach item's ownUp to 500 writes, optionally atomic
POST/monitoring/sessionsmonitoring:controlStart monitoring a radio
GET/monitoring/sessionsmonitoring:readList sessions. Filters org_id, state
GET/monitoring/sessions/{id}monitoring:readOne session
POST/monitoring/sessions/{id}/stopmonitoring:controlStop a session
POST/monitoring/sessions/{id}/viewer-tokenmonitoring:viewViewer WebSocket URL and token to watch and listen live
GET/monitoring/sessions/{id}/segmentsmonitoring:readRecorded segments of one track (?track=audio|video)
GET/monitoring/sessions/{id}/segments/{track}/{seq}monitoring:readOne 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: true runs every item in one transaction. If one fails, nothing is committed: committed is false, the failing item carries its problem, and every other item is 424 rolled-back. X-Config-Version is sent only when an atomic batch commits.
  • Without atomic, each item commits on its own and a failure does not stop the rest; committed is absent. Retried with the same Idempotency-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}
  • url is wss://monitor.locN.server.mcptt.com/monitor/<session id as 32 hex digits>/live, where locN is 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> before expires_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 404 not-found with no code.

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:

StatusBodyWhen
401viewer token requiredNo token in the query
401bad_token, token_expiredThe token is not valid, or expires_ms has passed
403origin not allowedOrigin is present and is not one of the system's names
403wrong_sessionThe token is for another session
404not found, session_not_foundBad session id in the path, or the session is not running at this site
426websocket upgrade requiredNot 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>}

    FieldValues
    radioconnected, reconnecting, offline (gone 60 s or more)
    audiolive, off, mic_unavailable, muted_ptt, no_data
    videovga, qvga, stills, paused, camera_error, starting, off, no_data
    detailText the radio gave with its status, or null
    viewersViewers connected now
    recordingrecording, failed, off
    audio_rx_ms, video_rx_msWhen the edge last received audio, or a video frame; null if never

    audio and video are what the radio reports it sends, except no_data: the edge's own finding that the radio reports live but nothing arrived for 2 s (5 s for stills). 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:

KindLayout
Audiobyte 0x01; ts_us u64 (radio capture time, microseconds); seq u32; one Opus packet (48 kHz, mono, 20 ms)
Videobyte 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

StatusType, codeWhen
400validation, codes reason, camera, nothing_to_monitor, track, state, cursorBad body or query
403forbidden-scopeMissing monitoring:* scope
403tenant-mismatch, org_suspendedStart or stop while the token's org is suspended
404not-found (no code)Session, device or org outside the token's reach, or not there
409conflict, already_openThe device already has an open session
422validation, 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
503unavailable, 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
503unavailable, monitor_unreachable, monitor_unconfigured, monitor_failedThis 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.

AreaPlanned pathsNotes
Human authOIDC bearer tokens, MFA (mfa-required), X-Tenant-IdOnly client credentials and static tokens work
Rate limitsRateLimit-* headers, 429 rate-limitedNo limits at all today
Cursor expiry15 min, tenant-bound cursorsCursors never expire
Deprecation headersDeprecation, SunsetNot sent
Tenants and policy/tenants, /tenants/{id}, /tenants/{id}/policy, /tenants/{id}/contacts/pinnedOrgs 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= lookupsUse /users with kind
User channels, roles/users/{id}/channels, /users/{id}/channels/order, /users/{id}/roles
Talkgroup settingspriority, all_call, broadcast, recording, transcription, timers as writable fields; 202 deferral for emergency-frozen talkgroupsReadable only; PATCH takes name and tg_number
Talkgroup number lookuplookup by n or a-n; number fields on shares
Membershipsaffiliation_policy, position writes, If-Match on member PUT
Dynamic regroup/talkgroups/dynamic, /talkgroups/{id}/forced-selection
Shares, patchesshare acceptance, /patches
Device enrollment and certificates/devices/enrollments, /devices/{id}/certificates
Device inventory fieldsmanufacturer, model, firmware, battery, accessories, online stateNot in the device body
Profiles, statuses/profiles, /statuses
Emergency/emergency-profiles, /users/{id}/emergency-profile, /emergency-recipients, /emergencies and clear and link revoke
Self-serviceeverything 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, replayThe event stream is a separate service (events-websocket.md)
OTAP/otap/releases, /otap/cohorts, /otap/rollouts, approvals, device health
Audit and approvals/audit, /approvalsAudit is visible in the portal only
Client managementManaging API clients through this API; immediate access-token revocationAn agency administrator or the operator creates an API client and gives you its client id and secret