HTTP API¶
The gateway exposes one HTTP surface across all runtimes. Every response is CORS-wrapped; OPTIONS on any
path returns 204. The stable, versioned, rate-limited read surface is /api/v1 (see
the read API); the other routes power the web app and federation.
A request body stops at 20 MB on both the Node and the Bun server (413 past it), and a route with a smaller
cap of its own reads no further than that cap. A failure inside the gateway answers 500 with the JSON body
{"error":"internal error"} and nothing else; the error itself goes to the server's log.
Authentication¶
| Gate | Meaning |
|---|---|
| public | No authentication. |
| rate-limited | Public, throttled per IP (and higher with a free API key). |
| session | A passkey, email-link or operator-link cookie session, signed with SESSION_SECRET. It names the account and its session generation, and resolves only while that account exists at that generation and holds the session's call. |
| actor | A session or x-ingest-secret — the "web write behind sign-in / RF write over APRS" dual path. |
| x-ingest-secret | Matches INGEST_SECRET — the ingest box. Ingest-plane only: never operator configuration, device keys or sessions. |
| x-operator-secret | Matches OPERATOR_SECRET — the operator's scripts. Closed while OPERATOR_SECRET is unset. |
| sysop | A signed-in operator whose callsign is in ADMIN_CALLSIGNS, held by their account and control-verified, with a session issued on an https address (any address when the instance has none), or x-operator-secret where marked. Locked if ADMIN_CALLSIGNS is unset. |
| signed-body | An Ed25519 assertion (key, sig, at) registered to the callsign, or a session of the account that holds the call. |
| x-relay-secret / x-fed-secret / wx-key | Federation relay / federation submit-corroborate / weather-station keys. |
| spoke-signed | x-relay-instance, x-relay-at and x-relay-sig: the request signed with the spoke instance's federation key, which the hub already holds; the signing time must sit within 120 s of the hub's clock. |
Cross-origin requests carry credentials only from APP_URL, EXTRA_ORIGINS and CORS_ORIGINS, and of those only
from an https or loopback origin: a plain-http origin is answered without credentials. With none set the gateway
answers Access-Control-Allow-Origin: * without credentials.
An instance with several addresses (EXTRA_ORIGINS) answers each request for the address it came on, when that
address is APP_URL or listed: the session cookie (host-only, Secure only on https), sign-in links and links
into the app name it. The scheme is the listener's, raised to https by x-forwarded-proto only behind a declared
proxy (TRUST_PROXY=1). A request on any other host is answered as if it came to APP_URL. Passkeys keep one
relying party, RP_ID; GET /.well-known/webauthn lists every https address as a related origin, so a browser
on one of them accepts that relying party (One instance, several addresses).
An enrolled ingest box sends no x-ingest-secret: it signs each request with its own Ed25519 key in the
headers x-box-id, x-box-at (unix seconds), x-box-nonce and x-box-sig. The signature covers the
method, the path with its query, the time, the nonce and the body's SHA-256, with the domain prefix
acs-box/1 (boxRequestMessage in packages/shared). It is fresh for five minutes and accepted once. A box's
key is narrower than x-ingest-secret, since the box may be a receiver a ham lends to an instance they do not
run (Enrolling ingest boxes):
- box-signed: any enrolled box. It reaches
/ingest,/ingest/check,/ingest/txgateand its own/api/box/:idendpoints. - service box: an enrolled box the sysop marks Runs this instance's services, off by default. It also reaches the outbox, the packet BBS mailbox, FBB forwarding, the partner list, the NET/ROM node mirror, White Pages and federation pages, and may queue transmit commands for itself. Trust this station's hearings grants none of these.
A box's frames attest only the receiving sites trusted under that box; FIRST_PARTY_SITES and the stations
added by call count only for frames sent with x-ingest-secret.
No box key logs a find, acts as a cache owner, creates a cache or runs an import: those take
x-ingest-secret or a session.
Public read API¶
curl -s https://aprs.example.net/api/v1 # index: routes, limits, how to get a key
curl -s "https://aprs.example.net/api/v1/caches?bbox=15.3,47.0,15.5,47.1" # bbox = minLon,minLat,maxLon,maxLat
KEY=acg_… # from Settings → Developer
curl -s -H "Authorization: Bearer $KEY" "https://aprs.example.net/api/v1/stations?bbox=15.3,47.0,15.5,47.1"
curl -s "https://aprs.example.net/api/v1/activity?key=$KEY" # or pass the key as ?key=
A key travels as Authorization: Bearer <key>, as an x-api-key header, or as ?key=.
Limits per 60-second window: 60 requests per IP without a key, 600 with one (API_RATE_*); a box may span at
most 20° a side.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1 |
Self-describing index of the read API. |
| GET | /api/v1/caches · /caches/:code · /caches.gpx · /caches.kml · /caches/:code.gpx |
Caches in a box (bbox), one cache, and GPX/KML exports. Exports hold native caches only; imported places stay on the instance. |
| GET | /api/v1/stations · /station/:call · /station/:call/track · /station/:call.kml |
Live stations, one station, its track (JSON/KML). |
| GET | /api/v1/profile/:call · /profile/:call.adif |
A callsign's public profile · the person's verified finds, any SSID, as ADIF 3.1 (SIG=APRSCACHING); unlisted caches stay out. |
| GET | /api/v1/activity · /leaderboard · /corroborators · /spots |
Activity feed (unlisted caches stay out), rankings, top corroborating IGates, live spots. |
| GET | /api/v1/licence/:call |
Callsign validity from public licence registers (same as /api/licence/:call). |
| GET | /api/v1/stats |
The instance's counts for its landing page: active caches, finds heard on the air (Tier A) in the last 7 days, stations heard in the last hour. Cached for 5 minutes. |
| GET | /api/v1/key |
The key the request presents: its name, prefix, creation and last use (401 for none or an unknown one). |
Every /api/v1 route is rate-limited per IP; a free key raises the limit. Keys never gate a feature.
A key belongs to a signed-in account. Create one under Settings → Developer, or with the account's session:
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET · POST | /api/keys |
The account's keys (name, prefix, createdAt, lastUsedAt) and its cap · create one { name } → the full key, shown this once (409 at the cap) |
session |
| DELETE | /api/keys/:id |
Revoke one of the account's keys | session |
| GET | /api/admin/api-keys |
Every key on the instance, with its owner call |
sysop or x-operator-secret |
| DELETE | /api/admin/api-keys/:id |
Revoke any key | sysop or x-operator-secret |
An account holds at most API_KEYS_PER_ACCOUNT keys (default 5) and creates at most 10 an hour. The instance
stores only each key's SHA-256: a lost key cannot be shown again, so revoke it and create another.
POST /api/v1/keys answers 410: there are no anonymous keys.
Caching & finds¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/caches?bbox= |
Caches in a box (native + trust-filtered federated mirrors) | public |
| POST | /api/caches |
Hide a cache. A signed-in member needs a control-verified call (403 otherwise) and hides at most HIDE_DAILY_LIMIT new caches in 24 hours (429 past it; the sysop is exempt). Only the sysop or the ingest may give an imported cache its own code; AC- codes are always minted. A living cache's stationCall must be one of the caller's own stations (/api/my/stations), on hiding and on an edit (403 otherwise) |
actor |
| GET · PATCH (PUT) | /api/caches/:id |
Detail (the caller's own favourite and rating; an imported place also carries its source's sourceOwner and sourceAttribution to show with it) · update (owner). country is a DXCC prefix (OE, DL…), checked against the AD1C country list; on PATCH "" clears it. Once the cache has a find, a PATCH moves its coordinates at most CACHE_MOVE_LIMIT_M from where it was found (409 beyond; a living cache is exempt), and the owner's detail carries own.move { limitM, pinned, stagePins } |
public · actor |
| GET | /api/offline/pack?grid= (&types=) |
An offline pack: the caches of a Maidenhead locator square — a field (JN), square (JN77), subsquare (JN77sb) or extended square (JN77sb42) — each with its page details, its latest 5 logs, its stages' number and unlock kind (never coordinates, clues or codes) and its images' list with their thumbnails; federated caches the map shows come as mirrored. 413 past 5000 caches; ETag/If-None-Match answers 304 when nothing changed; 30 full builds per address an hour |
public |
| GET | /api/offline/tiles |
Where the instance's offline map comes from: { url, attribution, maxZoom }, url being OFFLINE_TILES_URL, /tiles/offline.pmtiles when the instance holds an archive, or null when there is none |
public |
| GET · HEAD | /tiles/offline.pmtiles |
The instance's regional PMTiles archive, one byte range per request (at most 16 MiB; 416 without a Range); 404 when the instance has none |
public |
| GET · POST | /api/caches/:id/logs |
Logbook · log a find/DNF/note. A log signed with the logger's device key is timed by its signature (author.signedAt) when that is at most 60 s ahead, at most 7 days back, and after both the cache and the key existed; otherwise it is timed by its arrival and the answer's fieldTimeRejected says why (future, too_old, before_cache, before_key, or unsigned for an unsigned log sent with offline: true). The answer's foundAt is the time used |
public · actor |
| GET | /api/radio/commands |
Your radio commands (FOUND / DNF / NOTE / MAIL / NEAR / HELP messages) and the service call to send them to | session |
| POST | /api/radio/commands/:id/confirm · /discard |
Log or drop a command that arrived only over the internet | session |
| POST | /api/caches/:id/favorite · /watch · /rate |
Favorite · watch · rate 1–5 (finders), always as the caller | actor |
| GET | /api/search?q= · /api/leaderboard · /api/activity |
Search · rankings · activity feed | public |
| GET | /api/corroborators · /api/profile/:call |
Top corroborating IGates · public profile | public |
| GET · POST | /api/caches/:id/media, /stages, /stages/:n/unlock |
Media gallery (each image with its thumbnail, thumbUrl, when one is stored); stages (owner sets them with POST /stages, whose answer's offline says per stage whether it unlocks offline and why not: an NFC stage with a code of 40 bits or more is sealed under it for offline packs) & staged unlock, as the caller; the stage list shows the caller's own unlocks. An NFC code allows 10 tries an hour per finder and 60 per stage. Once the cache has a find, each stage it had then stays in the list (by its number, or prevStageNo when renumbered) within CACHE_MOVE_LIMIT_M of where it was found (409 otherwise) |
public/actor |
| POST | /api/caches/:id/media (?title=) |
Upload one photo (JPEG, PNG, WebP, GIF, AVIF; at most 2 MB) or sound (MP3, Ogg, WAV, M4A; at most 3 MB) as the raw body. A cache holds at most 6 items and 10 MB of media (audio clues included), one account's caches 50 MB together, the instance MEDIA_QUOTA_MB in all: 413 past a size, 409 past 6 items, 415 for another type |
actor (owner) |
| DELETE · PUT | /api/caches/:id/media/:mid · /api/caches/:id/media/:mid/thumb · /api/caches/:id/stages/:n/media |
Remove a photo (and its thumbnail) · store a photo's thumbnail (JPEG or WebP, at most 150 kB, made by the uploader's browser) · set a stage's audio clue (MP3, Ogg, WAV, M4A; at most 3 MB, counted toward the same limits) | actor (owner) |
| GET | /api/media/* |
Serve an uploaded photo or audio file, by the exact key the server stored it under (404 for any other shape) | public |
| GET | /api/adoptions |
Caches up for adoption, with the sysop's public note and when the owner's notice ends | public |
| GET · POST · DELETE | /api/caches/:id/adoption |
The offer on a cache and your own request · request adoption ({ inPlace, note? }, needs a control-verified call) · the owner keeps the cache |
public · session |
| DELETE | /api/caches/:id/adoption/request |
Withdraw your pending adoption request | session |
| POST | /api/reports |
Report an item to the sysop: { kind, id, category, text? }. kind is cache, log, media, message, bbs, mailbox, meshcom or profile (id is then the callsign); category is spam, offensive, unsafe, copyright or other (other needs text, at most 1000 characters). 201 { ok, id }; a second open report from the same account on the same item answers { ok, id, duplicate: true }. A signed-in reporter files 10 an hour (20 per address); a signed-out visitor 3 an hour per address, then 429 |
public · session |
A cache the sysop removed answers 410 on /api/caches/:id, /logs, /media and /stages to everyone but its owner and the sysop; to them the detail carries removed: { at, reason }. Its owner's edits answer 403 until the sysop restores it.
Stations & the Shack¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/stations, /api/stations/:call, /:call/series, /:call/packets |
Live registry, detail, telemetry series, raw packets | public |
| GET | /api/meshcom/nodes?bbox=&call=&maxAge=&limit= |
MeshCom nodes with a known position as this instance's node(s) heard them: how (direct, relayed, server, node), device, firmware, a signal-quality and battery bucket; signed-in members also get the exact battery, RSSI and SNR (exact: true). sentVia lists the relays the node's latest unrelayed message allowed (its via list), absent when it named none. bbox is minLon,minLat,maxLon,maxLat; call narrows to one callsign; maxAge ≤ 7 d (default 1 d); limit ≤ 1000 |
public |
| GET | /api/meshcom/links?bbox=&maxAge=&limit= |
Links between MeshCom nodes whose both ends have a known position (direct, or a relay leg), with a quality bucket; exact averages for signed-in members. maxAge ≤ 48 h (default 24 h) |
public |
| GET | /api/meshcom/groups |
The MeshCom groups (a group number, or * for all) this instance's node(s) heard within the message retention, most recently active first (the 200 most recent), each with its message count and lastHeard |
public |
| GET | /api/meshcom/groups/:group/messages?cursor=&limit= |
One group's messages (:group 1–99999, or * sent as %2A), newest first, keyset-paged (limit default 50, ≤ 200) → { group, messages, nextCursor, hasMore }: sender, text, the node that heard it (receiver) and how (heard: direct, relayed, server, node). Read only: nothing sends to a group |
public |
| GET | /api/ports · /api/messages |
Transport status · APRS message log, newest first, each with the transport that carried it (as a position records it; null on a row stored without one); ?call=<CALL> narrows it to messages from or to any SSID of that base call, ?to=<CALL> to one recipient, ?bulletins=1 to bulletins. A sent message (direction: "tx") also carries its msgNo and its delivery: queued in the APRS-IS outbox, sent, acked once the instance heard the station acknowledge it, or failed when the outbox did not send it within its hour |
public |
| POST | /api/messages/sent |
Record a message your browser radio sent ({ from, to, text, msgNo? }), from a verified call on your account |
session |
| GET · POST | /api/mailbox |
The Mailbox: the messages you left (sent) and those waiting for your calls (received) · leave one ({ from, to, text }) under a verified call of yours |
session |
| DELETE | /api/mailbox/:id |
Withdraw a message you left that is still held; one already sent on the air answers 409 | session |
| POST | /api/tx/aprs |
Gated user APRS TX through the instance's APRS-IS outbox, under your call: { kind: "message", addressee, text, msgNo? } (text ≤ 67 characters; the message joins /api/messages as sent, with its delivery state) or { kind: "beacon", lat, lon, symbol?, comment? }; 30 an hour per account |
session (verified callsign) |
| GET | /api/my/profile |
Your own profile for editing: every field, also while it is hidden, and profilePublic. POST /auth/profile saves it |
session |
| GET | /api/my/hides |
Your new caches in the last 24 hours against the daily hide limit: { limit, used, remaining }; limit and remaining are null when none applies |
session |
| GET | /api/my/logged |
The caches you found, logged as a did-not-find, or hid, under any of your calls and SSIDs: { found, dnf, owned } (cache ids) |
session |
| GET · POST · PATCH · DELETE | /api/my/stations · /:sid · /:sid/wx-key · /:sid/cache |
Manage your own stations; issue a weather station's push key (one per station; a new weather station replies with it as wx); make one a cache. A new station's base call must be held and verified by the caller's account (403 otherwise); a weather-only station needs it held, and one without coordinates sits at the home locator. The list carries livingCaches on a station that living caches follow |
session |
| POST | /api/me/cache |
Become a living cache yourself (your beacon is the cache position) | session |
| GET/POST | /api/wx/submit, /api/wx/updateweatherstation |
PWS push (Ecowitt / WU-Rapidfire) | wx-key |
| POST | /api/wx/tx |
Toggle a weather station's APRS-IS beacon / CWOP relay: { stationId, txIs, txCwop }; enabling needs the station's callsign verified (403) |
session |
| GET | /api/tools/registries |
The tool registries the Tools app lists for this visitor: the instance's switched-on ones, and the signed-in player's own while players may add them → { registries: [{ id, scope, spec, url, authority, label, enabled, builtin?, proxied }], players: { allowed, signedIn, stored }, proxy } (How registries work) |
none; session for your own |
| GET | /api/tools/registries/preview?spec= |
A registry file before it is added, fetched once and kept nowhere, to compare its key's fingerprint. 400 for an address that is not https or github:; counts against the 60 fetches an hour. The gateway fetches over https on every hop (at most 3 redirects) and never from a loopback, private or LAN address, whatever the federation settings allow; a refused, unresolvable or silent host all answer 502 "its host is unreachable from this instance" |
sysop, or session while players may add registries |
| GET | /api/tools/registries/:id/file?url= |
One file of a registry this instance carries, by its upstream address: the registry file, a manifest it lists or a script one of those names. Served from a copy kept an hour, the last good copy while the host fails (x-tool-registry-copy: stale); 404 for any other file or while TOOL_REGISTRIES_PROXY is off, 502 when no copy can be had. A player's own registry answers only that player, 429 past 60 fetches an hour, and every client address 429 past 600 requests a minute |
none; session for your own |
| GET · POST · PATCH · DELETE | /api/my/tool-registries · /:id · /:id/confirm |
Your own registries: list { allowed, registries, max }; add { spec, authority, label? } (https or github:owner/repo[/path][@ref], no credentials, at most 500 characters and 10 registries); switch on or off or relabel { enabled?, label? }; pin a new key { authority }; remove. 403 while players may not add registries, except to remove |
session |
Live¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /ws?region= |
Upgrade to the live room (positions/finds); region is global or absent, anything else is 400 |
public |
| GET | /api/spots |
Live activity spots (POTA/SOTA/DX…), off unless SPOTS_ENABLED |
public |
| GET | /api/cot |
Cursor-on-Target snapshot for TAK (bbox) |
public |
| GET | /api/cot/stream |
Cursor-on-Target push feed (Server-Sent Events): snapshot, then live updates | public |
Federation¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /.well-known/aprscaching |
Instance discovery document | public |
| GET | /federation/caches, /finds, /bulletins, /keys, /tombstones, /account-moves, /registry |
Signed, cursor-paged feeds | public |
| GET | /federation/peers |
Peer list + health, each peer's key fingerprint, and this instance's own (self) |
sysop or x-operator-secret |
| POST | /federation/peers |
Add a peer by address. {url} looks it up and answers {preview: {url, instance, fingerprint, operator}}, storing nothing; {url, fingerprint} adds it unvetted with that key pinned (409 when the key is no longer the one compared) |
sysop or x-operator-secret |
| DELETE | /federation/peers?url= |
Remove a peer and its pinned key; what it published stays, as from an unknown origin (409 for a peer FED_PEERS lists) |
sysop or x-operator-secret |
| POST | /federation/peers/trust |
Set trust {url, trust, fingerprint?, reason?}; trusted needs a pinned key and its compared fingerprint; blocked covers the instance. A change lands in the audit log with the optional reason |
sysop or x-operator-secret |
| POST | /federation/peers/sync |
Sync now for one peer {url}: pull at once; the records pulled or the error, and its last pull times. 3 a minute per peer |
sysop or x-operator-secret |
| POST | /federation/peers/44net |
Add a peer by its ARDC-verified <call>.ampr.org binding, reached over 44Net, https (web=) or both (DNSSEC auto-admits, else confirm) |
sysop or x-operator-secret |
| POST | /federation/peers/follow |
Follow a discovered instance {url, trust?, fingerprint?}: fetches its key at the learned addresses, refuses one other than its listed fingerprint or a key already pinned, and enables it unvetted; trust: true with the compared fingerprint trusts it at once |
sysop or x-operator-secret |
| GET | /federation/exchange |
The instances this one trusts: {instance, peers: [{instance, fingerprint, addresses}]} (peer exchange; 404 while FED_PEER_EXCHANGE is off) |
public |
| GET | /federation/sync/:type |
CBOR sync page of signed fedwire frames (the canonical wire; see the federation wire format) | public |
| GET | /federation/sync/summary?for=&bbox=&after= |
Every origin this instance serves, itself and those FED_RESERVE passes on (not the asker, for): per kind how far it holds that origin whole and the highest record it can pass on, and each other origin's key; next continues the list. 404 while the instance is unsigned, 429 past 120 a minute |
public |
| GET | /federation/sync/origin?origin=&kind=&since=&limit=&for=&bbox= |
One page of an origin's records of one kind after the sequence since (limit 1–1000, default 200), as the origin signed them, with their hop counts; nextCursor and held. 404 for an origin this instance does not pass on or while it is unsigned, 429 past 1200 a minute |
public |
| POST | /federation/gaps/seen |
The sysop has seen the records the instance gave up waiting for: they leave the list → {seen} |
sysop or x-operator-secret |
| POST | /federation/sync |
Pull from all peers; an optional JSON body {types?, maxPages?} narrows it to some feeds (deletes always come too) and a page cap. Answers the counts and the bytes read |
sysop or x-operator-secret |
| POST | /federation/corroborate |
Cross-instance corroboration query. A question addressed to one of this hub's push spokes is queued for it in the relay and answered 202 { relayed, id, ticket }; only an asker the hub knows, signing the question, is queued |
public (rate-limited; x-fed-secret if configured) |
| POST | /federation/notify |
Gossip "come pull" ping | public |
| POST | /federation/submit |
Hub accepts a spoke's signed records; the answer's mark ({type, cursor, id?}) is where that feed of the spoke now stands here |
x-fed-secret (FED_SUBMIT_SECRET) |
| GET | /federation/submit/marks |
Where each of the calling spoke's feeds stands here, {marks: {type: {cursor, id?}}}: the spoke resumes from it after a restart or an outage |
x-fed-secret and spoke-signed |
| POST | /federation/frames |
Connected-mode delivery of a CBOR sync page (AX.25/NET-ROM binding); frames are signature-verified; 413 past 4 MB |
x-ingest-secret, a service box, sysop or x-operator-secret |
| GET | /federation/packet/peers |
The peers the ingest box pulls over packet circuits (an ax25 or netrom endpoint), longest-waiting first, with their packet cursors; total, failing, never counts |
x-ingest-secret, a service box, sysop or x-operator-secret |
| POST | /federation/packet/status |
The ingest box reports one packet session: endpoint, outcome, counts and the feeds' new cursors (404 for an instance that is not an unblocked peer) |
x-ingest-secret, a service box, sysop or x-operator-secret |
| GET · POST | /federation/beacon |
Beacon-tier presence: serve our signed single-frame record · apply a heard one (trust-gated; 413 past 4 KB) |
public · x-ingest-secret, a service box, sysop or x-operator-secret |
| POST | /federation/bbs/enqueue |
Queue federation records for the FBB store-and-forward carrier (experimental; 409 unless FED_BBS is on) |
sysop or x-operator-secret |
| POST | /federation/relay/:instance/query |
Queue a query for a spoke behind NAT; answers 201 { id, ticket } |
x-relay-secret |
| GET | /federation/relay/lease |
The spoke leases its next queued query | spoke-signed |
| POST | /federation/relay/answer |
The spoke answers a leased query | spoke-signed |
| GET | /federation/relay/result/:id |
The requester reads the answer, presenting its ticket in x-relay-ticket; an instance that queued a corroboration question signs the read like a spoke |
x-relay-secret + ticket, or instance-signed + ticket |
| POST | /federation/relay/:instance/dispatch |
Pack a packet-only spoke's queued queries into an FBB batch (experimental; 409 unless FED_BBS is on) |
sysop or x-operator-secret |
Remote box¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/box/:id/pair |
The box obtains a one-time pairing code ({ code, expiresAt }, 15 min) to print for its operator |
x-ingest-secret, or box-signed for its own id |
| POST | /api/box/:id/claim |
Link the box to the signed-in account with that code ({ code }); single-use |
session |
| POST | /api/box/:id/command |
Enqueue a command (TX kinds need a verified callsign); 403 { pair: true } until the box is paired to the session's account; a beacon names no lat/lon (400): the box beacons its own configured position |
session (paired owner), x-ingest-secret, or box-signed for its own id (TX kinds: a service box) |
| GET · POST | /api/box/:id/commands · /commands/ack |
Box leases · acks commands ({ id, status, result, position? }; a beacon's ack names the position it was sent from). A command still queued after an hour is marked expired, never handed out |
x-ingest-secret, or box-signed for its own id |
| GET | /api/box/:id/log |
Operator view of command activity | session (paired owner), x-ingest-secret, or box-signed for its own id |
Admin / sysop¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/admin/whoami |
Whether the caller is an operator; pending: "verify" for the unconfirmed holder of an ADMIN_CALLSIGNS call |
public (answers for the session) |
| POST | /api/admin/stations |
List a station for a member: { owner, callsign, lat?, lon?, roles? }, owner a callsign the member's account holds |
sysop |
| GET · POST | /api/admin/verifications |
List · add manual callsign verifications ({ callsign, note, holder }). holder is the account id the sysop looked up (null when nobody holds the call); without it, or when another account holds the call now, 409 { reason: "confirm_holder", holder, … } and nothing is verified |
sysop |
| DELETE | /api/admin/verifications/:call |
Revoke a manual verification | sysop |
| GET · POST | /api/admin/callsigns/:call |
The sysop's view of a call: { callsign, adminCall, holder: { accountId, activeCallsign, passkeys, email, createdAt, held }, verification, openClaims, events } (holder null when nobody holds the call). POST { action: "release", reason, holder } detaches the call from its holder (holder must be the account id the lookup showed, else 409 { reason: "confirm_holder" }); the holder's content stays on their account, they are told in the app and by email, and the release is recorded in the holder-change trail |
sysop |
| GET · POST | /api/admin/adoptions |
Withdrawn-owner caches, offers with their pending requests and the recent trail · offer a cache ({ cacheId \| code, note }) |
sysop |
| DELETE | /api/admin/adoptions/:cacheId |
Withdraw an offer (cancels its pending requests) | sysop |
| POST | /api/admin/adoptions/:cacheId/assign |
Hand a cache to a control-verified call ({ callsign, note, activate? }) |
sysop |
| POST | /api/admin/adoptions/requests/:id/approve · /decline |
Decide an adoption request ({ note? }) |
sysop |
| POST | /api/admin/caches/:id/place |
Correct a cache's coordinates, or one stage's, beyond the owner's move limit ({ lat, lon, stageNo? }); the corrected place becomes where the limit counts from, and finders' unlocks from a moved stage on are dropped |
sysop or x-operator-secret |
| GET | /api/admin/moderation/reports?status= |
The report queue, open (default) or resolved, newest first, at most 100: { reports: [{ id, kind, targetId, label, preview, ownerCall, cacheId, code, gone, category, text, reporter, status, resolution, resolvedBy, resolvedAt, createdAt }], counts: { open, resolved } } |
sysop or x-operator-secret |
| POST | /api/admin/moderation/reports/:id |
Resolve or reopen a report: { status: "resolved" \| "open", note? } |
sysop or x-operator-secret |
| POST | /api/admin/moderation/remove |
Take one item down: { kind, id, reason, reportId? }, kind as in /api/reports, reason 3–500 characters. A cache is archived and hidden, a profile loses its self-written fields, everything else is deleted; open reports on the item are resolved. → { ok, kind, id, tombstones }; 404 for an item already gone, 409 for a cache already removed |
sysop or x-operator-secret |
| POST | /api/admin/moderation/restore |
Bring a removed cache back, disabled: { kind: "cache", id, reason } |
sysop or x-operator-secret |
| GET | /api/admin/moderation/accounts?q= · ?suspended=1 |
Find accounts by callsign prefix or email (at least two characters), or list the suspended ones: { accounts: [{ callsign, calls, email, createdAt, suspended, operator }] }; the suspended list adds erasedCalls: [{ callsign, category, until, at }], the calls of suspended accounts that were erased |
sysop or x-operator-secret |
| GET | /api/admin/moderation/accounts/:call |
One account: { account, openReports, content: [{ kind, id, label, preview, at, … }], actions }, its latest 50 items of each kind and the moderation actions about it |
sysop or x-operator-secret |
| POST | /api/admin/moderation/accounts/:call/suspend · /unsuspend |
Suspend { reason, category, until? } (category one of the report categories; until a future unix time, empty holds until lifted) · lift { reason }, which also frees the call of an erased account. An operator's account answers 409 |
sysop or x-operator-secret |
| GET | /api/admin/moderation/log?before=&limit= |
The audit log, newest first: { entries: [{ id, at, actor, action, kind, targetId, label, reason }], nextBefore } |
sysop or x-operator-secret |
| POST | /api/admin/boxes/codes |
A one-time box enrollment code {label?, callsign?, ttlMin?} → {code, expiresAt}, shown once |
sysop or x-operator-secret |
| GET | /api/admin/boxes |
Enrolled boxes (who, when, last seen, revoked, trust) and the codes still open | sysop or x-operator-secret |
| GET | /api/admin/federation/sync |
Pushing to the hub (last push, records waiting per feed, offline since) and, on a hub, each spoke's last submission with a stale flag | sysop or x-operator-secret |
| POST | /api/admin/federation/sync |
Sync now: pull from the peers and push to the hub in the background (202) |
sysop or x-operator-secret |
| POST | /api/admin/boxes/:id/revoke |
Revoke a box's key at once, and with it the box's trust, its owner and its queued commands | sysop or x-operator-secret |
| POST | /api/admin/boxes/:id/trust |
Trust this station's hearings {trusted, sites?}: on, the box's receiving sites count for Tier A beside FIRST_PARTY_SITES; off, they stop. A box enrolled for a callsign takes only sites of that base call → {box, trust} |
sysop or x-operator-secret |
| POST | /api/admin/boxes/:id/services |
Runs this instance's services, on or off: {services} → {box, services}; off by default |
sysop or x-operator-secret |
| GET | /api/admin/boxes/:id/finds |
The finds a trusted box's sites verified at Tier A since it was trusted → {trust, count, recent: [{code, loggerCall, ts, site}]} |
sysop or x-operator-secret |
| GET | /api/admin/sites |
Trusted receiving stations {sites: [{site, source, box?, trustedBy, trustedByCall, trustedAt}]}; source is config (FIRST_PARTY_SITES, read-only), admin or box |
sysop or x-operator-secret |
| POST | /api/admin/sites |
Trust a receiving station by its site call {site}; a call preset in FIRST_PARTY_SITES → 409 |
sysop or x-operator-secret |
| DELETE | /api/admin/sites/:site |
Stop trusting a station added with POST /api/admin/sites |
sysop or x-operator-secret |
| GET | /api/admin/sites/:site/finds |
The finds a trusted station verified at Tier A since it was trusted → {site, stations, count, recent} |
sysop or x-operator-secret |
| GET | /api/admin/setup |
The first-hour setup checklist, checked live (secrets reported as set/unset only) | sysop or x-operator-secret |
| GET | /api/admin/settings |
The instance settings: { groups: [{ id, title }], settings: [{ key, group, label, hint, type, control, values, min, max, unit, options, format, maxLength, fields, default, value, source, stored }] }. source is env (the environment sets the key and wins), site (saved here) or default; stored is the saved value with at and by, kept while the environment overrides it (Instance settings) |
sysop or x-operator-secret |
| PUT · DELETE | /api/admin/settings/:key |
Save { value } (checked against the schema, 400 with the reason otherwise) or reset to the default → { setting }. 404 for a key that is not an instance setting, 409 while the environment sets it, 429 past 120 changes an hour. Each change lands in the audit log |
sysop or x-operator-secret |
| GET · POST · PATCH · DELETE | /api/admin/tool-registries · /:id · /:id/confirm |
The instance's tool registries, the project registry (builtin) first: list { source, registries, envError?, max, proxy, playersAllowed }; add { spec, authority, label? }; switch on or off or relabel { enabled?, label? } (the project registry is only switched); pin a new key { authority }; remove. 409 while TOOL_REGISTRIES sets the list. Each change lands in the audit log |
sysop or x-operator-secret |
| POST | /api/admin/mail-test |
Send one test mail { to } over the configured transport; answers { ok, transport, error? }, 502 when the server refused it (tools/admin/mail-test.mjs) |
sysop or x-operator-secret |
| GET | /api/admin/station-status |
A read-only summary for the operator's scripts: stations heard in the last hour, each port's recent packets and last hearing, the Via setting of the operator's own MeshCom node(s) (meshcomVia: on with its relays, off, or unknown until the node has sent a message), and received messages to the operator's calls (any SSID) since ?since=<unix time> (default the last hour, at most a week back, 20 at most) |
sysop or x-operator-secret |
| GET | /api/admin/federation/identity |
The records this instance publishes for its callsign identity, each with its Portal name, type and value; ?check=1 adds the read-only self-check (endpoint, A record, _aprscaching TXT, where the record sends peers), each pass/warn/fail/info with a fix |
sysop or x-operator-secret |
| GET · POST | /api/node/nodes · GET /api/node/mheard |
NET/ROM NODES table · MHeard | public read; the POST mirror takes x-ingest-secret, a service box, sysop or x-operator-secret |
| GET · POST · DELETE | /api/bbs/forward, /forward/:id, /partners, /partners/:id |
FBB forwarding rules + partners | sysop or x-operator-secret (the partner-list read also x-ingest-secret or a service box, for the ingest box's scheduler) |
Every admin route is gated server-side. Scripts reach the routes marked x-operator-secret with the operator secret; the others need a signed-in sysop.
Ingest & BBS backend¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /ingest |
Ingest positions/finds/packets | x-ingest-secret, box-signed, or a batch signed on the device |
| GET | /ingest/check |
Whether the ingest credential works: 200 {ok, instance, serviceCall, sites, box}, else 401; sites are the receiving sites this credential's frames may claim; reads and writes nothing |
x-ingest-secret, or box-signed |
| GET | /ingest/txgate |
May this box transmit under calls (comma list, up to 16; nonce, 16 to 64 characters; box, the shared secret's BOX_ID of a box without a key of its own): {calls: {CALL: {ok, reason}}}. A call passes when control-verified, not suspended (reason: "suspended", also while the box's owner is), and its base call is held by the box's operator (the account that owns the box: for a box enrolled for a callsign, that call's holder; or for the shared secret an instance operator); for the shared secret x-txgate-mac is the HMAC-SHA256 of nonce and the body under INGEST_SECRET |
x-ingest-secret, or box-signed |
| POST | /ingest/enroll |
A box enrolls with a one-time code: {code, box, key, at, sig}, the request signed with the new key → 201 {box, instance, label, callsign}; rate-limited per address |
the code |
| GET · POST | /outbox · /outbox/ack |
Box pulls / acks queued APRS-IS messages | x-ingest-secret, or a service box |
| GET · POST · POST | /api/bbs/forward/pool · /api/bbs/forward/inbound · /api/bbs/forward/sent |
FBB forwarding backend for the ingest box; the pool offers local mail only from senders whose base call is control-verified, and federation bulletins | x-ingest-secret, or a service box |
| GET · POST | /api/bbs/session · /api/bbs/kill |
Connected-mode BBS session state · end a session | x-ingest-secret, or a service box |
| POST | /api/import/:source |
Import an external catalog — see Import heritage places | sysop or x-operator-secret |
| GET | /api/import |
The import sources: { sources: [{ id, sourceName, state, needs? }] }, state one of ready, needs-permission |
sysop or x-operator-secret |
| GET | /api/admin/imports?q=&source= |
Imported places matching a code, title or listing id ({ places, removed }, at most 50 each) |
sysop or x-operator-secret |
| DELETE | /api/admin/imports/:id |
Remove one imported place with its logs, ratings and media ({ note? }); later imports of its source skip the listing |
sysop or x-operator-secret |
Accounts, identity & GDPR¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /auth/passkey/*, /auth/email/start, /auth/claim, /auth/logout |
Passkey + email sign-in, claim, sign-out. A passkey belongs to the account: claim and sign-in resolve the account holding the call's base call, so any held call or SSID of one signs in with it, and the session carries the active call (or the typed SSID of it). Each finish answers the challenge its own begin issued, named in its clientDataJSON. A registration whose credential id is already registered is refused (409). An email given at passkey registration waits for confirmation (emailPending): a confirmation link goes to it, and until it is opened the address signs nobody in. Registering a passkey for a call an account already holds needs a session of that account (another device of the same holder), and keeps the session's active call. /auth/claim and passkey registration answer with a licence result for the call (nothing is stored); /auth/claim also says whether the licensee can take the call over (claimable: an account that has not proven control holds it) and whether it is the operator's call nobody holds yet (operatorCall). A refused sign-up names why in reason: held, held_unverified (take it over with /auth/claims), operator_call (an ADMIN_CALLSIGNS call opens only through the operator's link or a claim), reserved or invalid. An email link for an account that holds no callsign any more needs the call it operates now (409 { reason: "needs_callsign" } without one). /auth/email/start { email, purpose: "account-data" } mails such an account (to its confirmed address) a link whose session only exports or erases its data through /api/account/me/export and /api/account/me/delete, for an hour; an account that holds a call gets 409 { reason: "has_callsign" }. A session is honoured only on the address of the instance it was issued on (APP_URL or an EXTRA_ORIGINS entry), so one issued over plain http never passes over https. Claim and the passkey sign-in steps are rate-limited per address |
public → session |
| GET · DELETE | /auth/passkeys · /auth/passkeys/:id |
The account's passkeys: when each was added and its WebAuthn transports, whether the account has a confirmed email (hasEmail) and whether one waits for confirmation (emailPending) · remove one, such as a lost device's. The last passkey of an account without a confirmed email is refused (409) |
session |
| GET · POST | /auth/email/verify |
The magic link (on the gateway's own origin, which is APP_URL where the two share a host): GET shows a confirm page (or { confirm: true } to an API client) and never signs in; POST { token } (JSON or the confirm form) spends the token and opens a session, and a JSON answer carries the call's licence result. A confirmation link (24 hours) also binds its address to the account that gave it; an address confirmed on another account is refused (409). A browser POST from another origin is refused. A browser (Accept: text/html, or the confirm form) sees every other outcome as a page: an expired or spent link, a suspended account, a refused callsign |
public → session |
| POST | /auth/email/change · /auth/email/resend |
Give the account a new sign-in email, or its first, { email }; resend mails the confirmation link for the address waiting again. The address waits for confirmation (pendingEmail) and the confirmed one keeps working until then; giving the confirmed address again drops a waiting one. An address confirmed on another account is refused (409). Both share one budget per account and per address (429). Without a mail provider the answer is 503 and the address still waits |
session |
| POST | /auth/claims |
Open a claim on a call { callsign } → 201 { claim, callsign, held, expiresAt }: the call is held by an account that has not proven control of it, or is an ADMIN_CALLSIGNS call nobody holds. claim is a bearer token, given once; every /verify/* start and completion takes it as claim in place of a session, and success moves the call to the claimant, verified. With a session the call joins that account; without one it opens a new account. 409 with reason: held_verified (only the sysop moves it), holder_suspended (only the sysop moves it), operator_call, unheld, yours. Open 48 h; rate-limited per address and account. On-air codes for a claim: 5 an hour per claim and 20 per address |
public or session |
| POST | /auth/claims/status |
{ claim } → { status, callsign, method }, status one of open, done, refused, expired. The first answer that finds a sign-up claim done carries the new account's session (signedIn: true) |
claim token |
| POST | /auth/logout-all |
Sign out every session of the account, on every device | session |
| GET/POST | /auth/session, /auth/callsign(s), /auth/profile |
Session + base-callsign management. GET /auth/session answers { callsign: null, accountData: true } for a data-only session, and carries passkeys (the account's count) when signed in. Signed out, ended says why the session this browser held ended: { reason: "suspended", until, why } or { reason: "released", callsign, by: "licensee" \| "sysop", note, callless }. GET/POST /auth/callsigns carry a licence result per call, beside verified |
session |
| POST | /verify/aprs/start |
Start callsign control-verification: returns { code, to, text, expiresAt, sites }, the message to transmit and the attested site calls listening for it; sends nothing. Completed when an attested site hears text on the air (a TNC or a soundcard port of the site's ingest box, or a MeshCom node hearing it directly over LoRa) — method rf_heard. 409 { reason: "no_receiving_site" } when the instance trusts no receiving station (FIRST_PARTY_SITES, Instance admin → Trusted receiving stations, or a trusted box), since nothing could hear the reply |
session (holds the call), or claim |
| GET | /verify/aprs/status?callsign= |
{ verified } for the base call |
public |
| GET | /verify/methods |
{ methods: { rf_heard, ampr_dns, lotw }, rfSites } — which methods this instance offers (rf_heard needs a trusted receiving station — in FIRST_PARTY_SITES, added under Instance admin → Trusted receiving stations, or a trusted box's site — listed in rfSites; lotw needs LOTW_CA_PEM) |
public |
| POST | /verify/ampr/start |
{ callsign } → { code, name, type, value, record, expiresAt }: the TXT record to publish at _aprscaching-verify.<call>.ampr.org (valid 48 h) |
session (holds the call), or claim |
| POST | /verify/ampr/check |
{ callsign } → looks the record up over DoH; verifies (method ampr_dns) → { verified, callsign, method, proof } when the TXT at that exact name (no CNAME) carries the current code and the answer is DNSSEC-validated (proof: "dnssec") or returned alike by every independent resolver that answered, at least 2 (proof: "<n> resolvers"). 422 with the reason otherwise (a name not published yet costs no attempt), 502 when too few resolvers answer |
session (holds the call), or claim |
| POST | /verify/lotw/start |
{ callsign } → { challenge, message, algorithm, expiresAt }: the exact message to sign (valid 15 min); 503 when no LoTW CA is configured |
session (holds the call), or claim |
| POST | /verify/lotw/complete |
{ callsign, certificates: [base64 DER…], signature: base64 } — RSASSA-PKCS1-v1_5/SHA-256 over message with the LoTW callsign certificate's key; verifies (method lotw) when the signature, the chain to a trusted LoTW CA, the dates and the certificate's callsign check out |
session (holds the call), or claim |
| POST | /verify/operator |
Verify an ADMIN_CALLSIGNS call (method operator) — the operator CLI. The answer names the holding account (holder: accountId, activeCallsign, passkeys, email, createdAt, or null); preview: true answers with it and verifies nothing |
x-operator-secret |
| POST | /auth/operator-link |
Mint a single-use, 15-minute sign-in link { callsign } → { link, callsign, account, expiresIn }; the link opens /auth/email/verify. Every call on an off-grid instance (no https APP_URL, no email), only ADMIN_CALLSIGNS calls otherwise — tools/admin/signin-link.mjs |
x-operator-secret |
| GET | /api/licence/:call |
Callsign validity from imported public licence registers: { callsign, status, source?, sourceName?, expiresAt?, checkedAt? }, status one of licensed, expired, unconfirmed. The call is normalised to its home call (OE/DL1ABC/P → DL1ABC). Never control-verification; see Licence registers |
rate-limited |
| GET | /api/licence |
The imported registers: { sources: [{ source, sourceName, rows, importedAt }] } |
rate-limited |
| POST | /api/licence/import · /api/licence/import/finish |
Register import from tools/licence/import.mjs: batches of { source, importedAt, rows: [[callsign, status, expiresAt]] } (≤ 1000), then { source, importedAt, count } closes the run and removes calls the register no longer lists (409 and no pruning when the count differs) |
x-operator-secret |
| POST · GET | /keys/register · /keys/:call |
Register a device key for a call the session's account holds · list a callsign's keys | session · public |
| POST | /api/account/:call/export, /delete, /bundle, /move, /api/account/import |
GDPR export/erase + account portability; :call is me for a data-only session's account, which holds no call; /api/account/import takes 5 per address an hour |
signed-body |
| GET/PUT | /api/prefs · /api/notify/prefs |
Preferences · notification settings | session |
| GET · POST | /api/announce |
Whether your verified finds are announced on APRS-IS ({ on }) |
session |
| GET · POST | /api/near-radio |
Whether the service call messages your stations when they are heard near a cache ({ on }) |
session |
| GET · POST | /api/watch |
Your watched callsigns and unseen-alert count · watch a callsign ({ callsign }) |
session |
| GET · POST · DELETE | /api/watch/alerts · /api/watch/seen · /api/watch/:id |
Watchlist alerts · mark seen · stop watching | session |
| GET · POST | /api/push/key · /api/push/subscribe · /api/push/unsubscribe |
Web-push public key · (un)subscribe this browser | session |
| GET · POST | /api/notify/unsubscribe?token= |
The email digest's unsubscribe link: GET answers a confirm page (or { confirm: true }) and changes nothing; POST turns the digest off, and takes the mail client's one-click request (RFC 8058) |
signed token |
| GET/POST · GET | /api/views · /v/:id |
Saved map views · resolve a shared view | session · public |
A suspended account signs in nowhere: the passkey finishes, the email link and the operator link answer 403
{ error, suspended: { reason, until } } and issue no session. Its existing sessions end when the sysop
suspends it, and a request carrying one acts as signed out. Export and erasure stay open to it through a
signed-body request, or through a data link: POST /auth/email/start with purpose: "account-data" mails a link
whose session reaches /api/account/me/export and /delete and nothing else. Once a suspended account is erased, registering, adding, switching to or claiming any of
its base calls answers 403 with reason: "suspended" and
this callsign is suspended on this instance[ until <date>]: <category> until the suspension ends.
BBS¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/bbs/messages |
Post mail or a bulletin { fromCall, toCall, body, subject?, type?, replyTo?, lifetimeSec? }; sent in fromCall's name. Body ≤ 64 KB, subject ≤ 80 characters, lifetime ≤ 30 days; 20 posts a day per account (per base call through the ingest box) |
session holding fromCall, x-ingest-secret, or a service box |
| GET | /api/bbs/messages?to= · /api/bbs/sent?from= |
A callsign's personal mail: received · sent, with read state and the partners it was forwarded to | session holding the call, x-ingest-secret, or a service box |
| POST | /api/bbs/messages/:id/read |
Mark a message read | session holding the addressee's call, x-ingest-secret, or a service box |
| GET | /api/bbs/thread/:id |
A conversation; personal messages appear only to their sender and addressee | public (filtered) |
| GET | /api/bbs/bulletins |
Bulletins | public |
| GET | /api/bbs/route |
Hierarchical routing lookup | public |
| GET · POST | /api/bbs/wp |
White Pages: look up (?call=, required) · set a callsign's home BBS ({ callsign, homeBbs }), which steers where FBB forwarding sends its mail. The operator's entry stands; one the ingest box sets, or one learned from forwarded mail's R: headers, never replaces it |
public · x-ingest-secret, a service box, sysop or x-operator-secret |
A mailbox is its callsign's: a session reaches it when its account holds the call's base call (any SSID),
and a hierarchical address (OE1TST @ OE1BBB.OE.EU) is the mailbox of its callsign. The ingest box reaches
every mailbox, since it carries the mail of the stations it hears and forwards.
Misc¶
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /health |
Readiness: {ok, db, instance, commit, schema}, schema being the newest applied migration; ?live for liveness |
public |
| GET | /source · /.well-known/source |
The running source (AGPL §13) | public |
| GET | /.well-known/security.txt |
Security contact (RFC 9116): Contact: from SECURITY_CONTACT, else mailto: + OPERATOR_EMAIL; 404 with neither |
public |
| GET | /.well-known/webauthn |
WebAuthn related origins: {origins}, APP_URL and every https EXTRA_ORIGINS entry |
public |
| GET | /imprint · /privacy |
Legal pages from OPERATOR_* |
public |
| GET | /sitemap · /sitemap.xml · /api/sitemap · /robots.txt |
Human-readable site map · XML sitemap · the same as JSON · crawler rules | public |
| GET | /support · /api/support |
Supporter recognition page · its data | public |
| GET · POST | /api/support/prefs |
Your supporter-recognition preferences | session |
| POST | /api/support/confirm |
Confirm a donation | x-operator-secret |
| GET | /feeds/*.xml |
RSS: activity, caches, bulletins, leaderboard, per-user (/feeds/u/:call.xml) |
public |
| GET | /badge/:call.svg |
Embeddable network badge | public |
| GET | /embed · /embed/qr.svg |
Embeddable map (?cache= or ?bbox=; MapLibre from the instance's web app, basemap from BASEMAP_STYLE) · its QR code |
public |
| DELETE | /api/views/:id |
Remove a saved view | session |
Scheduled tasks¶
On a cron the gateway prunes TTL'd firehose positions, the raw-packet ring and stale relay
queue entries, the APRS-IS outbox (queued items older than an hour are never sent, sent ones go after 7 days),
box commands after 7 days, expired bulletins (30 days unless they name a lifetime), the FBB forward log after
30 days, unseen watch alerts after 30 days, and stations not heard for a year that no living cache,
registered station or MeshCom node names; then pulls federation (syncAllPeers), runs push-to-hub and the relay spoke leg (both no-ops
unless configured), and sends watch-alert email digests.
Next¶
- Configuration: every setting the gateway reads.
- Federation wire format: the signed frames instances exchange.