62 KiB
Beacon: API Contract
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.
Auth
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.
Rate limiting
On by default for /api/v1/* (not /ws or /swagger). Each client gets ratelimit.requests_per_minute (default 600) 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.
Cost guards
Every public /api/v1 request also passes through cost guards (not /ws, not /api/v1/admin/*). By default they only count what they would refuse, and no response changes. A guard refuses only for a family the operator has listed in guards.enforce (see Configuration). Clients should still handle the three refusals:
| Status | code |
Retry-After |
When |
|---|---|---|---|
429 |
rate_limited |
1 |
More than 4 database-backed requests at once from one client, and no slot freed within 5 s. Cache hits don't count. |
503 |
service_unavailable |
2 |
No database connection within 5 s. |
503 |
deadline_exceeded |
5 |
The request's database time passed its class limit: entity 5 s, list 10 s, aggregation 30 s, counted from its first statement. |
HTTP/1.1 503 Service Unavailable
Retry-After: 5
{"error":{"code":"deadline_exceeded","message":"The request ran past its database time limit. Retry later."}}
The body is the usual error envelope. Wait at least Retry-After seconds before retrying; a deadline_exceeded request will likely run as long again, so narrow it (a shorter window, a region) rather than repeat it. Clients are keyed by IP after the trusted-proxy step above.
Separately from the guards, and on every server, a page whose database statement runs past its pool's statement limit (35 s; 120 s for the slow region and message statements) answers 503 with code deadline_exceeded and Retry-After: 10. This covers route evidence (/routes/{iata}/{pathKey}/observations) and the background-data pages (region packets, region and scope messages, observer adverts). Other endpoints still answer 500 in that case.
Each request falls in one family and one class:
| Path | Condition | Family | Class |
|---|---|---|---|
/packets, /packets/backfill |
since or until set (/packets only) |
packets_window |
aggregation |
/packets, /packets/backfill |
region set | packets_region |
list |
/packets, /packets/backfill |
otherwise (including hashPrefix) |
packets_global |
list |
/packets/{hash} |
packet_detail |
entity | |
/routes |
routes |
list | |
/routes/cross, /routes/search |
routes |
aggregation | |
/routes/{iata}/{pathKey}/observations |
routes |
entity | |
/channels/{id}/messages, /messages, /messages/backfill |
channel_messages |
list | |
/stats/* |
stats |
aggregation | |
/channels, /nodes, /observers, /traces, /nodes/{id}/observations, /observers/{id}/adverts |
other |
list | |
/observers/directory |
region set | other |
list |
/observers/directory |
otherwise | other |
aggregation |
/observers/{id}/activity |
other |
aggregation | |
/observers/{id}, /observers/{id}/telemetry, /nodes/{id}, /nodes/{id}/neighbors, /channels/{id}, /traces/{tag} |
other |
entity | |
everything else (/iatas*, /regions*, /scopes*, /info, /brokers, unknown paths) |
other |
entity |
"Region set" means a non-empty iata, iatas, region or regionId.
Pages that wait for server data
Some pages are served only from data the server prepares in the background. While it is not ready they answer 503 with code service_unavailable and Retry-After: 30, whatever guards.enforce says:
| Pages | Ready when |
|---|---|
/packets with iata, iatas, region or regionId, without hashPrefix (region pages) |
the server trusts its packet_sites read model |
/channels/{id}/messages with iatas, or with scope and no since; /messages with iatas and neither since nor scope, or with scope, no since and no iatas |
the server trusts its channel_message_iatas read model |
/observers/{observerId}/adverts |
every observation carries its payload type |
That happens during the first fill after an upgrade, during a repair, after a failed verification, until the observation payload-type backfill is done, and for as long as the operator turns a read model off (see Operations). /messages with iatas and since or scope reads no read model and is always served, on a small pool of its own. The same pool answers trusted region /packets pages whose filter is too sparse for the read model's bounded reads. When that pool is busy, those requests answer 503 service_unavailable with Retry-After: 5.
HTTP/1.1 503 Service Unavailable
Retry-After: 30
{"error":{"code":"service_unavailable","message":"This page is not available yet. Retry later."}}
Retry after Retry-After. A first fill can take minutes, so a client that gives up after a few retries should let the user retry later.
Versioning
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.
Errors
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, and deadline_exceeded (a 503 from the cost guards). A 503 service_unavailable with Retry-After can also mean a page is waiting for server data. 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 [].
Pagination
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.
ID cursors are 64-bit integers: the observation ID cursor of /observers/{id}/adverts and /nodes/{id}/observations, telemetry afterId, the message ID cursor of /messages, and the backfill afterObservationId and afterId (Backpressure and reconnection). The same goes for the IDs they come from. Store and send them as full integers; never narrow them to 32 bits, since a busy server passes 2^31. As JSON numbers they stay exact up to 2^53, so a JavaScript number or a Dart int holds them.
A few lists return a plain array instead: the backfill endpoints, /routes*, /traces and the stats endpoints.
Exact page cursors
A numeric cursor holds a millisecond, so rows that share the last row's millisecond can fall
between two pages. /packets, /nodes, /observers and /channels also return
nextPageCursor on a page with hasMore: an opaque string of at most 160 characters that marks
the exact last row. Send it back as pageCursor. A walk by pageCursor returns every row once,
including those ties. Don't parse or build it; today it reads v1:<epoch µs>:<packet hash hex>
on /packets and v1:<epoch µs>:<id> on /nodes and /observers.
| Endpoint | pageCursor |
nextPageCursor |
|---|---|---|
/packets without iata, iatas, region, regionId or hashPrefix |
Wins over cursor when both are sent. |
When hasMore |
/packets with iata, iatas, region or regionId (a region page), without hashPrefix |
Wins over cursor when both are sent. |
When hasMore, except on a page requested by a millisecond cursor alone that a sparse filter sent to the server's fallback statement |
/packets with hashPrefix |
Checked, then ignored; cursor pages. |
Not returned |
/nodes, /observers |
Wins over cursor when both are sent. |
When hasMore |
/channels |
Can't be combined with a positive cursor (400). |
When hasMore |
nextCursorstays on every page withhasMoreand keeps its meaning: rows strictly older than that millisecond. Paging bycursoralone gives the same pages as before and still skips rows tied on the boundary millisecond.- On
/packets,/nodesand/observers, send both:cursorset tonextCursorandpageCursorset tonextPageCursorwhen the page has both,cursoralone otherwise. A server older than 2.1.0 ignorespageCursorand still moves forward bycursor. TreatnextPageCursoras optional on every list./channelstakes only one of the two. - Region pages of
/packetsare served only while the server trusts itspacket_sitesread model, which it checks again after every start and drops on any doubt; otherwise they answer503(see Pages that wait for server data). Keep sending both cursors whenever a page has both. - A malformed
pageCursoris a400with messageinvalid pageCursoron those three endpoints, on every path, including the/packetspaths that ignore it. Start again from the first page without it. An emptypageCursorcounts as absent.
Common filters
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) |
Comma-separated list params (iatas, scopes, payloadTypes, routeTypes) take at most 1000 values; more is a 400.
REST endpoints
Packets
| 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, hashPrefix (4 to 16 hex characters of the packet hash), cursor, pageCursor, 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. With hashPrefix the order and cursor use global lastHeardAt even when an IATA filter is set.
With an IATA or region filter and no hashPrefix (a region page), the list is ordered by the time the packet was last heard at the requested sites, newest first. While the server trusts its site read model (see Operations), pageCursor applies and those pages are the same as before with three differences:
- Packets heard at the same site time are ordered by
packetHash, descending. - A
pageCursorpage returns the rows in the cursor's millisecond that a millisecondcursorskips, as on/packetswithout a region (see Exact page cursors). - Where many observers heard a site's packets, the previous statement could end a page short, end a walk early or move
nextCursorpast rows it never returned; the new page returns those rows.
While it does not, region pages answer 503 with Retry-After: 30; see Pages that wait for server data.
Without an IATA or region filter or hashPrefix, the list is ordered by lastHeardAt, newest first, and packets heard in the same instant by packetHash, descending. pageCursor applies there (see Exact page cursors). Repeated values in the type and scope lists count once, and type values outside the header's range (payload type 0 to 15, route type 0 to 3) are dropped; a type list left with no valid value returns an empty page, not a 400. When several observations of a packet share the newest time, latestObserver comes from the one with the highest observation ID, so the choice is stable.
/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 summaries
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.
Packet detail
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.
parsedPayload by type
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.
Nodes
| Endpoint | Notes |
|---|---|
GET /nodes |
Page of node summaries, newest lastSeen first, ties by id descending. Cursor is lastSeen epoch ms; pageCursor is exact. Default limit 50. |
GET /nodes/{nodeId} |
Node detail: capability flags, minFirmwareVersion, iatas ([{iata, lastHeard}]), neighbors, clock drift for repeaters and room servers, and meshmapper when imported (see MeshMapper repeater details). |
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, pageCursor, limit. Summaries include stale (not seen within nodes.stale_threshold) and, when nodes.mark_foreign is on, possiblyForeign.
MeshMapper repeater details
With meshmapper.repeaters.enabled, node detail carries meshmapper when MeshMapper's repeater
list has the node's full public key. It is omitted otherwise. Times are epoch ms; fetchedAt is
when Beacon last fetched the list. Text is admin-entered on MeshMapper, so escape it.
"meshmapper": {
"iata": "YOW",
"fetchedAt": 1791234600000,
"site": {
"power": "1W", "hardware": "RAK4631", "antenna": "5.8 dBi fibreglass",
"heightM": 12, "heightAslM": 341.6, "powerSource": "solar",
"notes": "On the fire tower, east face"
},
"carpeater": false,
"admins": ["MrAlders0n"],
"scopes": [
{ "scope": "yow", "observedAt": 1791200000000, "observedPackets": 37, "proof": "multibyte", "reportedAt": 1791100000000 }
],
"scopesCheckedAt": 1791100000000,
"defaultScope": { "name": "yow", "state": "named", "updatedAt": 1791230000000, "source": "advert" }
}
siteis always an object; each field is omitted when unset.powerSourceismains,solar,batteryorpoe.heightAslMcan lag a newheightMby a few minutes.carpeatermarks a repeater riding in a car: its position moves.scopeslists scopes MeshMapper confirmed it carrying.proofismultibyteorfloor;observedAt/observedPackets(seen forwarding),reportedAt(an observer asked it) anduploadedAt(a wardriver's app asked it) are omitted for sources that never confirmed it.scopesCheckedAtpresent with emptyscopesmeans it answered with none.defaultScope.stateisnamed,unknown(it sets one MeshMapper can't name yet) ornone;nameis only sent whennamed;sourceisadvertorreported. This is MeshMapper's view; the top-leveldefaultScopeis what Beacon matched from adverts.
Foreign repeater indication
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.
Observers
| Endpoint | Notes |
|---|---|
GET /observers |
Page of observers, most recently seen first, ties by id descending. Params: location filters, type (e.g. meshcoretomqtt), broker, status (online/offline), name, scope, cursor (last-seen epoch ms), pageCursor, 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. |
/observers pages by each observer's last-seen time, which the summary does not include. Since
2.1.0 nextCursor is that time for the last row and is present whenever hasMore is true.
Before, it came from the last status message and was missing when the last row had none, so a
walk by cursor skipped most observers. Paging by cursor alone can still skip observers that
share a millisecond (presence updates often stamp many at once); pageCursor reaches all of them.
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
}
]
}
Channels and messages
| 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.
IATAs, regions and scopes
| 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.
Routes
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?from=&to=[&iatas=] |
Routes within one IATA between two node hash prefixes. from, to required (hex, 1–4 bytes; widths may differ). iatas optional: comma list, at most 100, omitted = every IATA; the single iata is still accepted. At most 500 results, shortest first. hops is trimmed to source…destination and hopCount = hops.length. |
GET /routes/cross?fromHash=&toHash=[&iatas=] |
Routes that cross from one IATA into another. fromHash, toHash required. iatas optional: 2–100 codes, omitted = every IATA. The legacy fromIata+toIata pair is still accepted, but not together with iatas. At most 500 results, sorted by totalHops, then crossHop.lastSeen newest first. |
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. A lookup past the statement limit answers 503 + Retry-After (Cost guards). |
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.
An iata that ends in a blank (YYZ followed by a space) matches no region and returns 200 [].
/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 }. sourceSegment runs from the source node to the boundary node and targetSegment from its neighbor across the boundary to the destination node; fromNode/toNode are that boundary pair (the last and first hops of the segments). totalHops is the two segment lengths combined.
Both searches return 400 for bad input, and also hash matches too many areas; use a longer hash or fewer iatas when a short hash resolves in too many IATAs; show that message to the user. A search that hits the 15s timeout returns 503.
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.
Stats
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.
GET /stats/series
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.
Response notes
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.
Traces
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.
Server info
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", "minWebVersion": "2.0.3", "serverVersion": "2.0.4" }
minAppVersion and minWebVersion are the oldest BEACON Mobile and Beacon Web releases allowed
to use this server, as strict X.Y.Z strings, or null when there is no requirement. Each is the
higher of the server's built-in floor and the operator's mobile.min_app_version /
web.min_web_version. 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.
Beacon Web calls /info on load and again when the tab regains focus (at most once a minute). When
its own version is lower than minWebVersion it shows a full-screen "reload" screen instead of the
app. A 404, a null or malformed value, and network errors never block.
Admin
All under /api/v1/admin/, bearer key required (see Auth).
| Endpoint | Notes |
|---|---|
GET /admin/config |
Running CORS settings with defaults applied, auth.configured, ingest.broker_count (configured broker workers, not connection status), mobile.min_app_version and web.min_web_version (read-only effective floors; "" when there is none), and guards (below). No credentials, broker addresses, channel material or database connection settings. |
PUT /admin/config |
Replaces cors.allowed_origins, guards.enforce or both until the next restart. JSON body up to 16 KiB. |
GET /admin/db/statements |
pg_stat_statements for this database, summed by statement name. 503 when the extension is missing or not preloaded. |
GET /admin/db/planguard |
The last report of the daily plan check. ?fresh=true runs it now, at most one run per 5 minutes. 404 while planguard.daily_check is off or before the first run; 500 when no run has succeeded. Details below. |
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 {"cors":{"allowed_origins":[...]}}, {"guards":{"enforce":[...]}}
or both, and applies them immediately. Both are validated before either is applied, so a rejected
update changes nothing. 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 settings.
guards in the config reports the cost guards:
{ "enforce": ["other", "routes"], "observe_only": false, "budget": "shadow", "api_plan_layout": "custom_all" }
enforce lists the families that refuse; every other family only counts. observe_only is true
under database.api_plan_layout: single, where every family observes whatever enforce says.
budget is always shadow. In a PUT, guards.enforce is a list of family names (routes,
channel_messages, packets_global, packets_region, packets_window, packet_detail,
stats, other), deduplicated and returned in that order. [] makes every family observe; an
unknown name is a 400. The change applies to requests that start afterwards.
GET /admin/db/statements returns {"items":[...]}, highest total execution time first. Each
item has name (the statement name; statements without one are summed as (unnamed)), calls,
exec_ms, plan_ms, shared_hit and shared_read (buffer blocks), rows and wal_bytes.
Times are milliseconds rounded to microseconds. plan_ms stays 0 unless pg_stat_statements.track_planning is on.
Counters run from the last pg_stat_statements reset. Setup is in
Operations.
GET /admin/db/planguard reports the daily plan check (see
Configuration). It is admin-only and changes nothing in the
public API. Like the other /admin/db/ reports, its fields are snake_case, and started_at is
an RFC 3339 timestamp rather than epoch milliseconds:
{
"tier": "prod",
"started_at": "2026-10-19T14:02:11.482Z",
"duration_ms": 41873.2,
"clean": true,
"counts": { "ok": 120, "RETIRED_INDEX": 2, "SKIP": 640 },
"failures": [],
"runs": [
{
"entry": "routes.byiata.R01_SEA_first_l50",
"stmt": "ListKnownRoutesByIATA",
"mode": "custom",
"verdict": "ok",
"drivers": ["known_routes:Index Scan:idx_known_routes_iata_keyset"]
}
]
}
cleanis true when no run isFALSE_POSITIVE,MISSEDorERROR.countsmaps each verdict to its number of runs.runshas one item per corpus entry and plan mode (customorgeneric):entry,stmt,mode,verdict, and when they applyfailures(the plan rules the run broke),drivers(relation:node:indexof each driving scan) andskip(why the run was not made).failuresholds the failing runs in the same shape. It is[], never null, when there are none.verdictis one ofok,DETECTED,MISSED,FALSE_POSITIVE,RETIRED_INDEX,KNOWN_ISSUE,SKIPorERROR. What each means is in Operations.
Without fresh, it returns the last successful report, or 404 with
planguard has not run yet (the first run is about an hour after startup) or
planguard daily check is disabled. A read-only server cannot turn the check on, so it always
answers the latter.
?fresh=true runs the check now and waits for it, which can take minutes. A request during a run
joins that run. Within 5 minutes of the end of the last run it starts nothing and returns the last
successful report (started_at gives its age), or 500 when no run has succeeded yet. A run
that fails is a 500 too. A run started this way keeps going if the client gives up, for at most
15 minutes. A fresh value that is not a boolean is a 400.
The check also feeds two metrics on the metrics listener:
beacon_planguard_failures{entry,mode} (1 for each failing run of the last check) and
beacon_planguard_last_run_timestamp_seconds (when the last successful check finished; 0 until
the first one, and absent while the check is off).
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.
WebSocket
Single endpoint at /ws. Bidirectional JSON messages. Subscription-based: the client tells the server what it wants, the server pushes matching events.
Connection
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" }
Client → Server messages
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.
Server → Client events
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.
Backpressure and reconnection
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.
Mobile-specific concerns
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.
Open questions
- 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.)