Skip to content

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.

Each request sends a key in the Authorization header:

Terminal window
curl -H "Authorization: Bearer mrk_..." https://your-community.radio.meridiancomms.net/api/v1/units

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

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.

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.

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-42
event: emergencyRaised
data: {"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,emergencyCleared limits the stream to those events.
  • When reconnecting, send the last id you received as the Last-Event-ID header (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.

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.