Web API
The web API lives at https://<your-community>.radio.meridiancomms.net/api/v1. An admin creates API keys and webhooks on the
Integrations page of the web app. The page tells you if your plan doesn’t include the web API.
API keys
Section titled “API keys”Each request sends a key in the Authorization header:
curl -H "Authorization: Bearer mrk_..." https://your-community.radio.meridiancomms.net/api/v1/unitsA key has one or both scopes: read (everything under GET, including the event stream) and dispatch (the
actions). The full key is shown once, when it’s created; the server keeps only a hash of it, so a lost key can’t be
recovered. Revoke it and create another. Revoking a key takes effect immediately, including for open event
streams.
Keep keys on your server. Never put one in a web page, a client script or a public repository: anyone with the key
can do everything its scopes allow. Each key may make 300 requests a minute; beyond that the API answers 429.
Reading the system
Section titled “Reading the system”| Request | Returns |
|---|---|
GET /api/v1/system |
{ talkgroups, units, calls, emergencies } |
GET /api/v1/talkgroups |
{ talkgroups: [{ id, name, alias }] } |
GET /api/v1/units |
{ units: [...] }, every connected radio |
GET /api/v1/units/:unitId |
{ unit }, or 404 if it isn’t connected |
GET /api/v1/calls |
{ calls: [{ callId, talkgroupId, unitId, alias, emergency, startedAt, fromConsole }] } |
GET /api/v1/emergencies |
{ emergencies: [{ unitId, alias, talkgroupId, acknowledgedBy, since }] } |
A unit is { unitId, alias, agencies, talkgroupId, siteId, bars, inhibited }. These are the server-side API’s
shapes without player: the web API doesn’t know about game server players. Times are milliseconds since the epoch.
Dispatch actions
Section titled “Dispatch actions”These need the dispatch scope. Bodies are JSON with Content-Type: application/json; an action with nothing
to say can send no body at all. by is optional everywhere: it’s who took the action (for example the CAD user),
shown on radios for acknowledgements and recorded in the audit log. Without it, the key’s name is used.
| Request | Body | Effect |
|---|---|---|
POST /api/v1/emergencies/:unitId/acknowledge |
{ by? } |
Acknowledge a radio’s emergency |
POST /api/v1/emergencies/:unitId/clear |
{ by? } |
Clear a radio’s emergency |
POST /api/v1/units/:unitId/emergency |
{ talkgroupId?, by? } |
Raise an emergency for a radio, on talkgroupId or its selected talkgroup |
POST /api/v1/units/:unitId/inhibit |
{ inhibited, by? } |
Disable (true) or re-enable (false) a radio |
POST /api/v1/units/:unitId/talkgroup |
{ talkgroupId, by? } |
Move a radio to a talkgroup in its codeplug |
POST /api/v1/alerts |
{ tone, talkgroupId?, unitIds?, by? } |
Play an alert tone on every radio selected to talkgroupId, or on the radios in unitIds (give one or the other) |
Tones are "alert", "priority" and "page", as in the server-side API.
A successful action answers { "ok": true }. A refused one answers with a status and { error, code }:
| Status | code |
|---|---|
400 |
bad_request (also for invalid bodies, with no code) |
404 |
unit_offline, unknown_talkgroup |
409 |
no_emergency, no_talkgroup, busy, inhibited, not_affiliated, out_of_range |
422 |
no_permission (for example a talkgroup that isn’t in the radio’s codeplug) |
Other statuses: 401 for a missing or wrong key, 403 for a missing scope or a plan without the web API, 429
for too many requests.
Event stream
Section titled “Event stream”GET /api/v1/events is a server-sent events
stream of the same events as the server-side API. Use a client library that can send the Authorization header
(the browser’s EventSource can’t, and keys don’t belong in browsers anyway).
The stream starts with a snapshot event holding the whole system (GET /api/v1/system’s answer), then sends
each event as it happens:
id: m1abc2-42event: emergencyRaiseddata: {"id":"m1abc2-42","event":"emergencyRaised","at":1727541234567,"data":{"emergency":{"unitId":1001,"alias":"1-ADAM-12","talkgroupId":100,"acknowledgedBy":null,"since":1727541234560}}}| Event | data |
|---|---|
unitConnected, unitDisconnected |
{ unit } |
unitTalkgroupChanged |
{ unit, previousTalkgroupId } |
unitInhibited |
{ unit, inhibited } |
callStarted, callEnded |
{ call } |
emergencyRaised, emergencyAcknowledged, emergencyCleared |
{ emergency } |
?events=emergencyRaised,emergencyClearedlimits the stream to those events.- When reconnecting, send the last
idyou received as theLast-Event-IDheader (SSE libraries do this for you) and the stream resends what you missed instead of a snapshot. If too much has happened, or the radio server has restarted, you get a fresh snapshot instead. - A comment line is sent every 25 seconds to keep the connection open.
- Each key can have 5 streams open at once.
Webhooks
Section titled “Webhooks”A webhook POSTs each event to your URL as it happens, with the same JSON body as the event stream’s data: line.
Choose which events each webhook receives (none ticked means every event). Every webhook has a signing secret,
shown once when it’s created; New secret replaces it.
Each request carries these headers:
| Header | Meaning |
|---|---|
Meridian-Event |
The event name (ping for the Integrations page’s Test button) |
Meridian-Delivery |
A unique ID for this delivery, the same on retries. Use it to ignore duplicates |
Meridian-Signature |
t=<unix seconds>,v1=<signature> |
Check the signature before trusting a request. v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with the
webhook’s secret. Compare it in constant time, and reject requests whose t is more than five minutes old:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody) { const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("="))); if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); return v1?.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));}Answer with any 2xx status within 5 seconds. Anything else counts as a failure, and the delivery is retried after
10 seconds and again after a minute. Events reach each webhook in order, so a slow or failing endpoint delays the
events behind it. After 20 events in a row fail, the webhook is switched off; the Integrations page shows the last
error, and Resume turns it back on.
Webhook URLs must use https:// and resolve to a public internet address.
Webhook deliveries waiting to be sent or retried are lost if the radio server restarts (after an update, for
example). After any gap, re-read the state with GET /api/v1/system rather than relying on every event arriving.
