Skip to content

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/txgate and its own /api/box/:id endpoints.
  • 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