The Go server exposes one REST API namespace under /api/v1/ and one WebSocket endpoint at /ws. React (web) and Flutter (mobile) consume identical endpoints. All JSON is camelCase. Times are Unix epoch milliseconds as integers (e.g. 1747668456000), bytes are hex strings, UUIDs are stringified.
Why epoch ms: smaller on the wire, no timezone or parser ambiguity, no string parsing in hot paths (chart axes, sorts, comparisons), and matches the units MeshCore uses internally (uint32 Unix seconds in adverts and traces). JS new Date(ms) and Dart DateTime.fromMillisecondsSinceEpoch(ms) consume it natively.
Query params follow the same convention: since and until are epoch ms integers. range is a Go duration string (24h, 168h, 720h) on the endpoints that take one.
This document describes the contract and the parts that need explaining. Every request parameter and response schema is in the Swagger UI the server serves at /swagger/index.html.
Everything under /api/v1/ is public and read-only, except the /api/v1/admin/ subtree.
Admin routes need the operator key, set as auth.api_key in config.yaml or BEACON_API_KEY in the environment (the variable wins when set, even if empty). A key shorter than 16 characters or with whitespace inside it stops startup.
Authorization: Bearer <key>
- No key configured: every admin route returns
503with codeservice_unavailable. - Missing or wrong key:
401with codeunauthorizedandWWW-Authenticate: Bearer. - Admin responses carry
Cache-Control: no-store.
Send the key only over HTTPS.
On by default for /api/v1/* (not /ws or /swagger). Each client gets ratelimit.requests_per_minute (default 300) per minute, plus a one-second window capped at ratelimit.burst (defaults to the per-minute value). Clients are keyed by IP after the trusted-proxy step below; IPv6 clients share a budget per /64. Over the limit:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
{"error":{"code":"rate_limited","message":"Request rate limit exceeded. Retry later."}}
Retry-After is 1 or 60 seconds depending on which window tripped. ratelimit.enabled: false turns both windows off. Counters live in the server process, so each Beacon instance counts separately.
Client IP. The server only accepts X-Real-IP, and only from a direct peer listed in server.trusted_proxies. X-Forwarded-For and True-Client-IP are always ignored and stripped. Behind a reverse proxy that isn't listed, every client shares the proxy's budget.
Path is the contract: /api/v1/. Breaking changes get /api/v2/. Backward-compatible additions just expand the existing payloads. WebSocket messages include a type discriminator and a top-level v: 1 field so we can evolve in place.
All errors use one shape:
{ "error": { "code": "not_found", "message": "packet not found" } }code is the HTTP status text in snake_case, so match on it or on the status. Codes in use: bad_request, unauthorized, not_found, conflict, request_entity_too_large, unsupported_media_type, rate_limited, internal_server_error, service_unavailable, gateway_timeout, insufficient_storage. The heavy stats endpoints (series, signal, paths, observer-comparison) return 503 when the query times out; try a shorter window.
A 404 means the requested thing doesn't exist. A database or other server-side failure is always a 500 (or 503 above), never a 404, so clients can retry 5xx and treat 4xx as final. Empty lists are 200 with [].
Most lists return a page:
{ "items": [ ... ], "nextCursor": 1747665456000, "hasMore": true }Pass nextCursor back as cursor for the next page; it is omitted on the last page. What the cursor holds depends on the list (a timestamp in epoch ms, or an ID), as noted per endpoint. limit has a per-endpoint default, must be positive, and is clamped to 200.
A few lists return a plain array instead: the backfill endpoints, /routes*, /traces and the stats endpoints.
Most list and stats endpoints take the same location filters:
| Param | Meaning |
|---|---|
iatas |
Comma-separated IATA codes, case-insensitive (YOW,YYZ) |
iata |
Single IATA, used only when iatas is absent |
region / regionId |
Region slug or ID; expands to its member IATAs and adds them to any iatas. An unknown region or malformed ID is a 400; a failed lookup is a 500. |
scope |
Transport scope name, URL-encoded (%23bc for #bc) |
| Endpoint | Notes |
|---|---|
GET /packets |
Page of packet summaries, newest first. Default limit 50. |
GET /packets/{packetHash} |
Full packet with every observation. 404 if unknown. |
GET /packets/backfill?afterObservationId=<id> |
Packets with observations after that ID, oldest first, plain array. Default limit 100. For reconnect backfill. |
/packets params: payloadType or payloadTypes (comma-separated integers), payloadTypeName (advert, group_text, trace, ...; used when no number is given), routeType or routeTypes, the location filters, scope or scopes (comma-separated), since, until, cursor, limit. Filters OR within themselves and AND across each other. The cursor is epoch ms: global lastHeardAt order without an IATA filter, and the time heard at the requested sites when one is set, so a filtered list stays bounded to those IATAs.
/packets/backfill takes single payloadType/payloadTypeName, routeType, the location filters, scope and limit.
A packet summary:
{
"packetHash": "9e9b7d6a91cab445",
"payloadType": 4,
"payloadTypeName": "advert",
"routeType": 1,
"routeTypeName": "FLOOD",
"scope": "#bc",
"firstHeardAt": 1747665456000,
"lastHeardAt": 1747665462000,
"observationCount": 15,
"latestObserver": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"displayName": "FlightlessDt",
"iata": "KEH",
"pathLength": { "raw": "41", "hashSize": 2, "hopCount": 1 },
"pathBytes": "ae9b"
},
"summary": "WW7STR/PugetMesh Cougar"
}summary is the advert name, ACK <checksum>, TRACE <tag> or PING <tag>, and is omitted for other types. scope is the matched transport scope, omitted when none. latestObserver can also carry resolvedSource / resolvedDestination (see below).
Packet lists, regional lists, reconnect backfill and live packetObservation events carry the
optional summary string. It comes from the packet's own stored metadata, never from a node's
current name or a fresh decode.
| Payload | Summary | Source |
|---|---|---|
| ADVERT | Advertised name | Saved appData.name / verified decoded advert |
| ACK | ACK 01020304 |
Four-byte acknowledgement checksum |
| TRACE | TRACE efbeadde |
Trace tag |
| TRACE classified as PING | PING efbeadde |
Trace tag |
- References are eight lowercase hex characters in the same byte order as packet detail's
checksum/traceTag. Zero is valid. Extended ACK retry bytes do not change the checksum. - They are short references, not unique packet IDs or proof of delivery. No message body, trace auth code or inferred sender or recipient is included.
- The text is projected from saved JSON in the existing list queries: no extra column, backfill or per-packet lookup. A stored value of the wrong type or not exactly eight hex characters omits the field.
- Other payload types have no summary yet. A missing summary does not mean the packet is invalid.
GET /api/v1/packets/{packetHash}
| Field | Type | Description |
|---|---|---|
packetHash |
string | Content hash (hex) |
header |
object | raw (hex byte), routeType, routeTypeName, payloadType, payloadTypeName, payloadVersion |
transportCodes |
object | regionCode, subRegionCode; only on TRANSPORT_FLOOD / TRANSPORT_DIRECT |
originPubkey |
string | Sender public key (hex), when the payload carries one (ADVERT, ANON_REQ) |
parsedPayload |
object | Decoded payload; shape depends on type, see below |
rawPayload |
string | Payload hex, without header and path |
decrypted |
boolean | True when a group text was decrypted |
channelHash |
string | Channel byte (hex), group packets only |
scope |
string | Matched transport scope |
firstHeardAt, lastHeardAt |
number | Epoch ms |
firstToLastMs |
number | Spread between first and last observation |
observationCount |
number | Observations across all observers |
resolvedRoute |
array | TRACE only: the probed route, resolved hop by hop |
observations |
array | One entry per observer hearing |
Each observation:
| Field | Type | Description |
|---|---|---|
id |
number | Observation ID (use with /packets/backfill) |
observerId, observerName |
string | Observer UUID and display name |
iata |
string | Where it was heard |
heardAt |
number | Epoch ms |
pathLength |
object | raw (hex byte), hashSize (1–3), hopCount |
pathBytes |
string | Accumulated path hashes (hex). For TRACE these are per-hop SNR bytes. |
rssi, snr |
number | dBm / dB, omitted when missing |
propagationTimeMs |
number | Milliseconds after the packet's first observation (0 for the first) |
radio |
object | freqMhz, spreadFactor, bandwidthKhz, codingRate, copied from the observer |
sourceBroker |
string | Name of the MQTT broker it arrived through |
resolvedPath |
array | Per-hop resolution |
resolvedSource, resolvedDestination |
object | The packet's endpoints, for types that carry them (ADVERT by full key; REQUEST, RESPONSE, TEXT_MESSAGE, PATH and ANON_REQ by 1-byte hash) |
A resolved hop is { "confidence": "high" | "ambiguous" | "none", "snr": 7.25, "nodes": [ ... ] }, with one node for high, several for ambiguous and none for none. Each node is { id, name, publicKey, latitude, longitude } (publicKey is the prefix that matched). Resolution is empty on a fresh database until adverts have taught the server which nodes exist.
Every shape has type and raw (payload hex). type uses the upper-case names below, which differ from the lower-case payloadTypeName in the header.
type |
Fields |
|---|---|
ADVERT |
publicKey, timestamp (uint32 seconds), signature, appData |
REQUEST, RESPONSE, TEXT_MESSAGE, PATH |
destinationHash, sourceHash (1 byte hex), cipherMac, ciphertext, ciphertextLength, decrypted |
GROUP_TEXT, GROUP_DATA |
channelHash, cipherMac, ciphertext, ciphertextLength, decrypted |
ANON_REQUEST |
destination (byte as a number), ephemeralPubKey (sender key, hex) |
TRACE / PING |
traceTag (hex), authCode, flags, pathHashes (hex per hop), snrValues (dB per hop). PING is a trace with one hop. |
ACK |
checksum (4 bytes hex) |
MULTIPART |
remaining, wrappedType, wrappedPayload |
DISCOVER_REQ |
prefixOnly, typeFilter (bitmask), tag (hex), since (seconds, omitted when unset) |
DISCOVER_RESP |
nodeType, nodeTypeName, requestSnr (the responder's reading of the request), tag, pubKey, pubKeyPrefixOnly |
CONTROL |
flags, data; other CONTROL sub-types |
RAW |
Anything without a decoder (e.g. RAW_CUSTOM) |
decrypted in parsedPayload is always null today. Decrypted channel text is served by the messages endpoints; direct messages are not decrypted.
An ADVERT payload:
{
"type": "ADVERT",
"raw": "7e7662676f7f...",
"publicKey": "7e7662676f7f08501a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f",
"timestamp": 1747665450,
"signature": "a1b2c3d4...",
"appData": {
"raw": "92...",
"flags": {
"raw": "92",
"deviceRole": 2,
"deviceRoleName": "REPEATER",
"hasLocation": true,
"hasName": true,
"hasFeature1": false,
"hasFeature2": false
},
"latitude": 48.4284,
"longitude": -123.3656,
"feature1": null,
"feature2": null,
"name": "WW7STR/PugetMesh Cougar"
}
}Adverts with a bad signature are not decoded and have no parsedPayload.
| Endpoint | Notes |
|---|---|
GET /nodes |
Page of node summaries. Cursor is lastSeen epoch ms. Default limit 50. |
GET /nodes/{nodeId} |
Node detail: capability flags, minFirmwareVersion, iatas ([{iata, lastHeard}]), neighbors, clock drift for repeaters and room servers. |
GET /nodes/{nodeId}/observations |
Page of observations of packets the node originated. Cursor is the observation ID. |
GET /nodes/{nodeId}/neighbors |
Plain array of neighbours: { id, name, publicKey, nodeType, nodeTypeName, lat, lng, iata, observationCount, firstSeen, lastSeen, snr }. |
/nodes params: type (1 companion, 2 repeater, 3 room server, 4 sensor) or typeName, the location filters, name (partial, case-insensitive), scope, pubkey (exact hex), pubkeyPrefix, supportsMultibytePaths, supportsMultibyteTraces, neighbors (adds neighborIds), cursor, limit. Summaries include stale (not seen within nodes.stale_threshold) and, when nodes.mark_foreign is on, possiblyForeign.
With nodes.mark_foreign: true, repeater nodes carry possiblyForeign. It is true when the
repeater's reported position is outside the union of all configured iatas.<code>.borderFile
polygons, with meshmapper.zones borders replacing them where MeshMapper has one. IATAs without
a border add no area, and airport coordinates are not used. Enabling it with no border source, a
missing file or invalid geometry stops startup. Border changes need a restart.
- Points on an edge (including hole edges) are inside; hole interiors are outside.
- Other roles and missing or invalid positions get no value. A 0/0 advert clears the stored position.
- It is a hint from the reported position, not proof of origin. Ingest, heard-in IATAs and route matching are unaffected.
- Computed at read time, so existing nodes need no backfill. The field is omitted when the feature is off (the default).
- In
nodeUpdate, it is a boolean when the advert carries a position,nullwhen the position is 0/0 or invalid or the node stops being a repeater, and omitted to keep the previous value.lat/lngfollow the same rule.
GeoJSON uses longitude, latitude order. Split borders that cross the antimeridian into MultiPolygons per RFC 7946 section 3.1.9; edges spanning more than 180 degrees are rejected.
| Endpoint | Notes |
|---|---|
GET /observers |
Page of observers. Params: location filters, type (e.g. meshcoretomqtt), broker, status (online/offline), name, scope, cursor (lastSeen epoch ms), limit. |
GET /observers/directory |
Traffic/name pagination, row counts and type facets. See observer directory contract for rollout status and exact semantics. |
GET /observers/{observerId} |
Observer detail. |
GET /observers/{observerId}/telemetry?range=24h&interval=1h |
Telemetry history. interval is 1h (default), 6h or 24h. afterId returns only newer points. |
GET /observers/{observerId}/activity?range=24h&interval=15m |
What the observer heard, bucketed. |
GET /observers/{observerId}/adverts |
Page of adverts the observer heard. Cursor is the observation ID. |
Telemetry response is a time-bucketed array suitable for direct chart consumption. airtimeTxSecs and
airtimeRxSecs are seconds of radio time as reported by the observer: cumulative since boot on
interval=1h points, and the per-bucket delta on 6h/24h. Divide by the bucket length for a percentage.
Fields the observer did not report are omitted.
{
"range": "24h",
"interval": "1h",
"points": [
{
"t": 1747612800000,
"batteryMv": 4180,
"airtimeTxSecs": 13.4,
"airtimeRxSecs": 37.8,
"noiseFloorDb": -103.2,
"uptimeSeconds": 86400,
"queueLength": 0,
"receiveErrors": 3
}
]
}Activity reports what an observer actually heard, bucketed over a trailing window. range is a Go
duration up to 720h (default 24h); interval is one of 5m, 15m, 1h, 6h, 24h
(default 15m). range / interval may not exceed 1000 buckets, sub-hour intervals (5m, 15m)
additionally cap range at 48h, and an unknown observer is a 404. An optional until (epoch ms,
at most 30 days old) ends the window earlier.
Buckets are aligned to the clock in UTC (a 15m bucket always starts at :00, :15, :30 or :45) and the
window start is rounded up to the next bucket boundary, so bucket starts are stable across requests.
Only non-empty buckets are returned. Gaps in points mean the observer heard nothing in that bucket,
and the client is expected to render them as zero, except for uncovered hours (below).
Hourly intervals (1h, 6h, 24h) read the analytics rollups up to the newest complete rollup hour,
and raw observations after it, at most 24 hours back. Those responses add rolledUntil (end of the
newest complete rollup hour, epoch ms; omitted before the first rollup) and rawFrom (start of the raw
tail, epoch ms). Rollup buckets cover times before rolledUntil and raw buckets from rawFrom on.
When rawFrom is later than rolledUntil (or rolledUntil is missing), the rollup is more than 24
hours behind and [rolledUntil, rawFrom) is unknown, not quiet; show it as a gap.
radio echoes the observer's current radio parameters plus the preamble length the airtime maths
assumes; it is null when those parameters are unknown, in which case airtimeMs is null on every
bucket too (airtime cannot be costed without them). snrAvg, snrMin and rssiAvg are null for
buckets whose observations carried no usable signal readings. The payloadTypes counts cover the same
window and their total matches the sum of observations across points. On sub-hour intervals only,
observations with an undetermined payload type are counted in observations but omitted from the
breakdown; none exist within the retention window in practice.
airtimeMs sums only the observations that could be costed. The migration that introduced airtime
backfills the trailing 7 days, so for the first 30 days after deploy buckets older than 7 days may be
only partially costed, and observations recorded without radio parameters are never costed.
Sub-hour intervals read live observation rows and are always current. The response also reports
windowStart, windowEnd, generatedAt, source (raw or hourly) and a summary of recorded
packets and the last complete hour:
summary.recordedPacketscounts stored observations in the window; repeated broker deliveries of the same packet count once. Unknown payload types show as-1.- Freshness fields (
lastCompleteHourwith its start and end,latestRecordedAt) are measured atgeneratedAt, even for historicaluntilrequests. untillines up two observers' charts.- Missing records do not prove downtime. The observer's
observationCountis a legacy cumulative presence counter (including status and neighbour events), not a packet total.
{
"range": "24h",
"interval": "15m",
"radio": {
"freqMhz": 869.525,
"sf": 11,
"bwKhz": 250,
"cr": 5,
"preambleSymbols": 16
},
"payloadTypes": [
{ "payloadType": 4, "payloadTypeName": "advert", "count": 128 },
{ "payloadType": 5, "payloadTypeName": "group_text", "count": 41 }
],
"points": [
{
"t": 1747612800000,
"observations": 12,
"airtimeMs": 3120.5,
"snrAvg": 6.2,
"snrMin": -4.5,
"rssiAvg": -92.3
}
]
}| Endpoint | Notes |
|---|---|
GET /channels |
Channels, most recently seen first. |
GET /channels/{channelID} |
Channel detail by integer ID. |
GET /channels/{channelID}/messages |
Page of that channel's messages, newest first. |
GET /messages |
Page of messages across channels, newest first. |
GET /messages/backfill?afterId=<id> |
Messages after that ID, oldest first, plain array. Default limit 100. |
/channels params: hash (channel byte hex), iata/iatas, keyKnown (true for channels Beacon can decrypt, false for hash-only), cursor, pageCursor, limit (default 50). An IATA filter lists channels MeshMapper lists there, configured channels scoped to a region containing it, and Beacon-wide configured channels. The page adds nextPageCursor, an opaque string that keeps timestamp ties and precision; prefer it over the numeric cursor (the two can't be combined). A channel's messageCount is a lifetime total; packet retention does not reduce it.
Message lists take since, the location filters, scope, cursor (message ID) and limit (default 50). /messages also takes channelID or channelHash. A message:
{
"id": 88213,
"packetHash": "3c4f8a12b7e60d91",
"channelHash": "f3",
"senderName": "Chris",
"content": "anyone hear that trace?",
"sentAt": 1747665450000,
"observationCount": 8,
"scope": null,
"scopeStatus": "unscoped"
}sentAt comes from the sender's clock. scopeStatus is matched, unscoped, unknown or unavailable.
Channel keys come from the server config file and, optionally, MeshMapper.
| Endpoint | Notes |
|---|---|
GET /iatas |
All IATAs, including ones created from traffic. [] when there are none. |
GET /iatas/{iata} |
One IATA. 404 if unknown or not three letters. |
GET /iatas/{iata}/border |
GeoJSON Feature (Polygon or MultiPolygon, with bbox); 204 when the IATA has no border, 404 if the IATA is unknown. |
GET /regions |
Regions in display order. [] when none are configured. |
GET /regions/{regionId} |
One region with its IATAs. 400 for a non-numeric ID, 404 if unknown. |
GET /scopes |
Array of scope names. Location filters narrow it to manual scopes configured for a matching region and imported scopes whose MeshMapper catalogue includes a matching IATA. |
GET /scopes/{name} |
Scope detail (%23bc for #bc). |
GET /brokers |
[{ "name", "connected" }] per MQTT broker. |
Regions, IATA names and scopes are managed in the server config file (or imported from MeshMapper), not through the API.
Known routes are fully resolved multi-hop paths distilled from packet history.
| Endpoint | Notes |
|---|---|
GET /routes |
Plain array, newest lastSeen first. Params: iata, hopCount, cursor (last item's lastSeen, epoch ms), cursorId (last item's id), limit (default 50). |
GET /routes/search?iata=&from=&to= |
Routes in one IATA between two node hash prefixes (hex). All required. |
GET /routes/cross?fromIata=&fromHash=&toIata=&toHash= |
Routes that cross IATA boundaries. All required. |
GET /routes/{iata}/{pathKey}/observations |
Retained reports that match a saved route exactly. pathKey comes from a route response. Window: range (default 24h, max 720h) or since+until (max 30 days); pageCursor for the next page; limit default 50. |
To page /routes, send the last item's lastSeen as cursor and its id as cursorId. Routes share a millisecond often (one batch of upserts), and cursorId returns the ones that didn't fit on the previous page. cursor alone still works but can skip those ties. cursorId must be a positive integer and requires cursor; otherwise 400.
/routes and /routes/search return a plain array of routes:
{
"id": 412,
"pathKey": "5d1c0e7a9b2f4c8d6e3a1b0f9c8d7e6a",
"iata": "YOW",
"hopCount": 3,
"hops": [ { "nodeId": "uuid", "hashBytes": "ae", "node": { "id": "uuid", "name": "YOW_Kanata", "...": "" } } ],
"firstSeen": 1747526400000,
"lastSeen": 1747665456000,
"observationCount": 57
}/routes/cross returns [{ sourceSegment, crossHop, targetSegment, totalHops }], where the segments are hop arrays as above and crossHop is { fromNode, toNode, fromIata, toIata, lastSeen }.
The observations endpoint returns { items, hasMore, nextPageCursor, route, windowStart, windowEnd, generatedAt, matchType, matchAvailable, hashSize, pathBytes }. Each item is { id, packetHash, observerId, observerName, heardAt, payloadType, payloadTypeName, rssi, snr }. It matches on the route's full path bytes, hash size and hop count within its IATA; limit is capped at 200. Evidence expires with raw packets, so a route can outlive every observation that produced it.
GET /api/v1/stats/overview?iatas=YOW
GET /api/v1/stats/series?iatas=YOW&since=<ms>&until=<ms>
GET /api/v1/stats/observations?iatas=YOW&since=<ms>
GET /api/v1/stats/payload-breakdown?iatas=YOW&since=<ms>
GET /api/v1/stats/top-nodes?iatas=YOW&since=<ms>&limit=10
GET /api/v1/stats/top-observers?iatas=YOW&since=<ms>&limit=10
GET /api/v1/stats/top-advertisers?iatas=YOW&since=<ms>&limit=10
GET /api/v1/stats/top-talkers?iatas=YOW&since=<ms>&limit=10
GET /api/v1/stats/scopes?iatas=YOW&since=<ms>
GET /api/v1/stats/signal?iatas=YOW&since=<ms>&until=<ms>
GET /api/v1/stats/paths?iatas=YOW&since=<ms>&until=<ms>
GET /api/v1/stats/observer-comparison?observerA=<uuid>&observerB=<uuid>&since=<ms>&until=<ms>
GET /api/v1/stats/clock-drift?iatas=YOW&limit=10
GET /api/v1/stats/radio-presets?iatas=YOW&preset=910.525,62.5,7
GET /api/v1/stats/node-types?iatas=YOW
All stats endpoints accept iatas (comma-separated), region (slug) or regionId; a region expands to
its member IATAs. since and until are epoch milliseconds. limit defaults to 10 and is capped at 200.
Historical stats are read from hourly rollup tables, not raw observations. One background task rolls
each UTC hour about 95 minutes after it closes, so the newest hour or two are never included. Windows
snap down to UTC hours, and responses report the effective window. Any window can reach back
analytics.rollup_retention (90 days by default), independently of packet retention; /stats/signal
and /stats/paths keep their 30-day cap. Server-side details are in beacon-server's
docs/historical-stats.md.
Packets, adverts and messages count once per hour they were heard, however many of the requested IATAs heard them, so a packet heard across an hour boundary counts twice (about 1%). Multi-IATA and region filters give exact distinct counts.
Default windows: overview covers the 24 most recent rolled hours; observations and scopes/top-nodes
default to the last 7 days; payload-breakdown, top-observers, top-advertisers and top-talkers
default to the last 24 hours.
Hourly network activity for sparklines and KPI cards. since (inclusive) and until (exclusive) are
required, rounded down to UTC hours; the window may be at most the rollup retention, and since is
clamped to the oldest hour the rollups still hold. An empty region returns zeros, not all IATAs.
{
"since": 1747526400000,
"until": 1747612800000,
"revision": 4182,
"earliestComplete": 1739750400000,
"completeHours": 22,
"hours": [
{
"hour": 1747526400000,
"status": "complete",
"values": {
"observations": 1840, "uniquePackets": 412, "activeObservers": 23, "activeIatas": 4,
"scopedPackets": 37, "activeScopes": 3, "maxPathEntries": 9,
"snrSum": 1520.5, "snrSamples": 1802, "rssiSum": -168211, "rssiSamples": 1802
}
},
{ "hour": 1747609200000, "status": "missing", "values": null }
],
"summary": { "observations": 40211, "uniquePackets": 9120, "...": "same fields as values" }
}statusiscomplete,missing(not rolled yet) orpartial(raw rows were deleted before the hour could be rolled; it never fills).valuesisnullunless the hour is complete. Draw gaps, not zeros.summarycovers complete hours only. Counts sum across hours;activeObservers,activeIatasandactiveScopesare distinct across the whole window, so the hours do not sum to them. Averages aresnrSum / snrSamplesandrssiSum / rssiSamples(guard against 0 samples).revisionchanges whenever any rolled hour changes.earliestCompleteis the first complete hour held, ornull.
overviewreturnssince/untilfor the 24 rolled hours it summarises.observationsitems are{ "hour", "iata", "observationCount" }. Distinct packet and observer counts do not sum across IATAs; read them from/stats/series.top-nodesranks nodes by advert hearings.TopNodeandTopAdvertisercarrypublicKey(hex, always set) and a nullablenodeId(null once the node row has been deleted); key rows bypublicKey.scopescounts packets heard sincesince. Observer and node counts are current memberships: observers filter by the IATA they last reported from.hourlyis[{ hour, packets, observers, nodes }], oldest first with empty hours omitted: the scope's packets that hour, plus the distinct observers that heard them and nodes whose adverts were heard in the scope.signalgives SNR/RSSI histograms and averages;pathsgives hash-width and path-length distributions. Both have anhourlyarray, requiresinceanduntil(at most 30 days) and read the hourly rollups, so empty or not-yet-rolled hours are missing. Eachpathshour carriesmaxEntries, the longest path heard that hour (0 if only empty paths were heard).observer-comparisoncounts distinct flood packets heard by A only, B only and both, over a requiredsince/until. The observers must be different; an unknown one is a404. Response:{ observerA, observerB, since, until, totalPackets, onlyA, onlyB, both }.clock-driftlists repeaters and room servers whose last advert clock is off by more thannodes.clock_drift_threshold, worst first. It is current state, not windowed.radio-presetsis[{ preset, iata, sourceType, count }], wherepresetisfreqMhz,bwKhz,sfandsourceTypeisobserverornode; it is rebuilt everybackground.view_refresh(default1h).node-typesis[{ nodeType, nodeTypeName, count }]over all known nodes.
GET /api/v1/traces?type=TRACE&scope=<name>&since=<ms>&until=<ms>
GET /api/v1/traces/{tag}
/traces reads a per-tag summary kept at ingest and also accepts iatas, region and regionId.
Filters apply to whole tags: type is TRACE if any packet in the tag is multi-hop and PING
otherwise, scope matches the first scope seen, and since/until match the tag's first hearing.
Counts and times always describe the whole tag.
It returns a plain array, most recently heard first, default limit 50. To page, pass the last item's
lastHeardAt as cursor and its trace tag (hex) as cursorTag; the tag breaks ties between traces
last heard in the same millisecond. /traces/{tag} is a 404 for an unknown tag.
A list item:
{
"traceTag": "a3f1b2c4",
"firstHeardAt": 1747665450000,
"lastHeardAt": 1747665462000,
"packetCount": 3,
"iataCount": 2,
"traceType": "TRACE",
"pathHashes": ["ae", "9b", "f3"],
"snrValues": [7.25, 4.5, -1.0]
}pathHashes and snrValues come from the most complete observation. The detail is
{ traceTag, packets }, one entry per packet: { packetHash, routeType, routeTypeName, scope, firstHeardAt, lastHeardAt, rawPath, resolvedRoute }. rawPath is [{ hash, snr }] as received; resolvedRoute uses the
resolved hop shape from packet detail.
GET /api/v1/info
Public, no key. Counts against the normal rate limit and is sent with Cache-Control: no-cache.
{ "minAppVersion": "0.1.1", "serverVersion": "2.0.3" }minAppVersion is the server's mobile.min_app_version: the oldest BEACON Mobile release
allowed to use this server, as a strict X.Y.Z string, or null when the operator has not set
one. serverVersion is the server's API version (X.Y.Z, no v), the same value Swagger shows. Servers
older than this endpoint return 404. See
Mobile-specific concerns for how the app uses it.
All under /api/v1/admin/, bearer key required (see Auth).
| Endpoint | Notes |
|---|---|
GET /admin/config |
Running CORS settings with defaults applied, auth.configured, and ingest.broker_count (configured broker workers, not connection status), and mobile.min_app_version (read-only; "" when unset). No credentials, broker addresses, channel material or database settings. |
PUT /admin/config |
Replaces cors.allowed_origins until the next restart. JSON body up to 16 KiB. |
GET /admin/accounts, POST /admin/accounts |
List or create operator account records (name only; not logins). |
GET /admin/accounts/{id}, DELETE /admin/accounts/{id} |
Fetch or deactivate one. |
GET /admin/backup |
Streams a private .tar.gz of the database and saved config. Off unless backup.enabled: true; needs pg_dump in the server's runtime. One export at a time (409), 504 on timeout, 507 over the size limit. Contains secrets. |
PUT /admin/config accepts only {"cors":{"allowed_origins":[...]}} and applies the new list
immediately. It is runtime-only: nothing is written to disk, and a restart reloads the file. The
response carries config, persisted: false and requires_restart: false. Send 1 to 32 ASCII
http(s) origins of at most 512 bytes each, with an optional single hostname wildcard; a lone *
allows all. Empty or null lists, paths, queries, credentials, control characters and unknown
fields are rejected. Concurrent updates apply one at a time; requests already in flight may see
the old list.
POST /admin/accounts takes {"name": "..."} in a body of at most 4 KiB. The name is trimmed,
case-sensitive, 1 to 128 characters with no control characters, and unique among active
accounts. DELETE deactivates: 204, or 404 if missing, 409 if already inactive. The name
can then be reused. The list returns active and inactive accounts, newest first, unpaginated.
Browser admin clients need the matching methods in cors.allowed_methods. The default
GET, HEAD, OPTIONS is read-only. For an admin UI, use
[GET, HEAD, OPTIONS, POST, PUT, DELETE], restrict cors.allowed_origins to that UI and allow
the Authorization and Content-Type headers, or preflight blocks requests that work from
curl. CORS controls browser access, not authentication.
Backup details are in Backup and export.
Single endpoint at /ws. Bidirectional JSON messages. Subscription-based: the client tells the server what it wants, the server pushes matching events.
GET /ws
The upgrade is checked before anything else:
- Connect rate. Each client IP gets
websocket.max_connects_per_minuteattempts per minute (default 10, IPv6 per /64), failed handshakes included. Over that, the server answers429withRetry-After: 60and no upgrade. - Origin. A browser
Originmust match the request's host, or one ofwebsocket.allowed_origins. Anything else gets403. Setallowed_originswhen the web app is served from a different host than the API. - Concurrent connections. At most
websocket.max_connections_per_ip(default 5) open sockets per IP. Past that, the socket is accepted and immediately closed with code1013(try again later), reasonconnection limit reached, so the browser can back off.
On connect the server sends a hello:
{ "v": 1, "type": "hello", "serverTime": 1747665456000, "connectionId": "uuid" }All client messages have a type and an optional id, which replies echo.
subscribe: add a filter to this connection. Subscriptions are unioned: an event is sent if it matches any of them. A connection with no subscriptions gets no events.
{
"v": 1,
"type": "subscribe",
"id": "sub-1",
"scope": {
"iatas": ["YOW"],
"regionIds": ["3"],
"regionSlugs": ["eastern-canada"],
"payloadTypes": [4, 5],
"channelHashes": ["f3"],
"events": ["packetObservation", "observerStatus"]
}
}iatas, plus the member IATAs of anyregionIds(numeric IDs as strings) andregionSlugs. Unknown regions are skipped.payloadTypes: numeric payload types.channelHashes: only filterschannelMessageevents.events: any ofpacketObservation,observerStatus,nodeUpdate,channelMessage.
Every field is optional, and an omitted field or an empty array means no filter on that dimension. routeTypes filters packetObservation events; observerIds filters packetObservation and observerStatus; channelHashes only filters channelMessage. The server replies with subscribed:
{ "v": 1, "type": "subscribed", "id": "sub-1", "subscriptionId": "uuid" }unsubscribe, replied to with unsubscribed (same fields):
{ "v": 1, "type": "unsubscribe", "id": "unsub-1", "subscriptionId": "uuid" }ping:
{ "v": 1, "type": "ping", "id": "p-1" }Server replies with { "v": 1, "type": "pong", "id": "p-1" }. The server closes a connection that sends nothing for 90s, so ping every 30s.
configure: connection-wide options, separate from subscriptions. Every configure sets all flags to exactly the values sent, so an omitted flag turns off. All default to false.
{ "v": 1, "type": "configure", "id": "cfg-1", "resolvePath": true, "includeObserverKey": false, "includeRepeats": false }resolvePath:packetObservationevents carry per-hopobservation.resolvedPath.includeObserverKey:packetObservationevents carryobservation.observerPublicKey.includeRepeats: also stream later hearings of a packet by an observer that already reported it, when they arrive over a new path. See below.
Server replies with configured, echoing all three flags:
{ "v": 1, "type": "configured", "id": "cfg-1", "resolvePath": true, "includeObserverKey": false, "includeRepeats": false }Malformed or unknown messages are logged and ignored; there is no error reply.
Events share one envelope and carry no id:
{ "v": 1, "type": "event", "event": "<name>", "data": { ... } }packetObservation: a new observation was stored. This is the primary live event.
{
"v": 1,
"type": "event",
"event": "packetObservation",
"data": {
"packetHash": "9e9b7d6a91cab445",
"packet": {
"payloadType": 4,
"payloadTypeName": "ADVERT",
"routeType": 1,
"routeTypeName": "FLOOD",
"isFirstObservation": false,
"observationCount": 16,
"scope": "#bc",
"summary": "WW7STR/PugetMesh Cougar"
},
"observation": {
"observerId": "uuid",
"observerName": "NodeRunner",
"iata": "SEA",
"heardAt": 1747665462000,
"rssi": -105,
"snr": 7.2,
"sourceBroker": "mqtt2",
"pathBytes": "ae9bbf3c",
"pathLength": { "raw": "42", "hashSize": 2, "hopCount": 2 },
"propagationTimeMs": 0,
"resolvedPath": null,
"resolvedSource": { "confidence": "high", "nodes": [ { "id": "uuid", "name": "WW7STR/PugetMesh Cougar", "publicKey": "7e76..." } ] }
}
}
}isFirstObservation: truemeans the packet is new, so add a row.falsemeans update the existing row's count and time, and append the observation if the packet is expanded.payloadTypeNamehere is the protocol name (ADVERT,GRP_TXT,TXT_MSG, ...), not the lower-case REST name.scopeandsummaryare omitted when there is none.resolvedSource/resolvedDestinationare omitted for types without endpoints.resolvedPathisnullunless the connection setresolvePath.observerPublicKeyappears only withincludeObserverKey.propagationTimeMsis always 0 on live events. The event has no observation ID.
Connections with includeRepeats also get repeats, which have packet.isRepeat: true. A repeat is a later hearing of the packet by an observer that already reported it, arriving over a different path, for example after a repeater further out rebroadcast it. Repeats are never stored, so they don't appear in REST reads. They carry observationCount: 0 and isFirstObservation: false, so don't treat them as count updates. Exact copies (the same path again, or the same hearing via another broker) are not sent. When the server is busy, repeats are dropped before any other event. Without includeRepeats the key is absent.
observerStatus: an observer's /status message was processed.
{
"v": 1,
"type": "event",
"event": "observerStatus",
"data": {
"observerId": "uuid",
"displayName": "Hull_Hospital",
"observerType": "meshcoretomqtt",
"iata": "YOW",
"online": true,
"radio": "910.525,62.5,7",
"scopes": ["#bc"],
"batteryMv": 4180,
"uptimeSeconds": 86400,
"lastStatusAt": 1747665462000
}
}online is always true (the event means a status just arrived). radio is freqMhz,bwKhz,sf. observerType, iata, radio and batteryMv are omitted when unknown. The event is a full snapshot, not a diff.
nodeUpdate: a new advert from a node was stored.
{
"v": 1,
"type": "event",
"event": "nodeUpdate",
"data": {
"nodeId": "uuid",
"publicKey": "7e7662676f7f...",
"name": "YOW_Kanata",
"nodeType": 2,
"nodeTypeName": "repeater",
"iata": "YOW",
"lat": 45.3,
"lng": -75.9,
"isObserver": false,
"iatas": [{ "iata": "YOW", "lastHeard": 1747665462000 }],
"defaultScope": "#bc",
"radio": "910.525,62.5,7",
"possiblyForeign": false
}
}lat/lng omitted means the advert had no position (keep the one you have); null means the advert cleared it. iatas holds only the IATA that heard this advert, so merge it into what you have. possiblyForeign is only sent when nodes.mark_foreign is on. defaultScope and radio are omitted when unknown.
channelMessage: a group text was decrypted into a new message. The data is the same shape as a REST message (see Channels and messages) plus channelId, without observationCount.
Each connection has a 256-event send buffer. When it is full, the server evicts the oldest queued event to make room and then queues the new one; if that still fails, the new event is dropped too. A lagged notice follows with the count:
{ "v": 1, "type": "lagged", "droppedCount": 1, "since": 1747665440000 }droppedCount is 1 or 2 per notice; since is when the notice was sent. Several can arrive in a burst. Clients should treat a lagged notice as a gap and refresh the affected view over REST.
Reconnection is the client's responsibility. On any disconnect, the client should:
- Reconnect with backoff (1s, 2s, 5s, 10s, 30s cap)
- Re-issue all subscriptions and
configure - Backfill over REST
- Resume streaming
There's no replay buffer for missed events; REST is the source of truth for history. For backfill by ID:
GET /api/v1/packets/backfill?afterObservationId=12345&iatas=YOW&limit=100
GET /api/v1/messages/backfill?afterId=88213&limit=100
channelMessage events carry the message id, so afterId is the last one seen. packetObservation events carry no observation ID; packet summaries (including /packets/backfill results) don't either. Observation IDs come from packet detail, /nodes/{id}/observations and /observers/{id}/adverts, so in practice the simplest recovery is to re-fetch the first page of /packets.
Flutter on iOS background suspension and Android battery saver will kill the WebSocket. Pattern for the mobile app:
- On foreground: open WS, subscribe.
- On background: close WS gracefully (don't fight the OS).
- On return to foreground: reopen, re-subscribe, and fire a REST refresh on whatever screen is active to backfill anything missed.
The protocol doesn't need to know about backgrounding; the client just treats reconnection as the recovery mechanism.
Minimum app version. The app calls GET /info on launch, on return to foreground and when
the user switches servers. When the app's own version is lower than minAppVersion (compared
numerically, part by part), it blocks that server behind an "update required" screen until the
app is updated. Other servers stay usable. minAppVersion: null means no requirement. A 404
comes from a server that predates the endpoint and also means no requirement. Network errors,
timeouts and other failures do not block: the app keeps using the server and checks again next
time.
- packetObservation payload size. With
resolvePathon, events carry the full resolved path with node coordinates, which can be 1-2 KB each in heavy traffic. Clients that only need counts should leave it off and fetch detail over REST.
(See Questions and Answers in the high level design for resolved items.)