The trust model
Two barriers, each closing a different door:
| barrier | proves | checked by |
|---|---|---|
| node PIN | you are physically on the owner's network | the node |
| backend grant | the node exists, has an owner, has a free slot | the backend |
Three questions are kept separate on every request:
| question | carried by |
|---|---|
| who are you | token (sma_…) |
| what you may do | scopes (rows on the server, not bytes in the token) |
| what format you send | the URL |
A token is 24 random bytes with an sma_ prefix — it encodes nothing. Permissions live server-side and can be edited or revoked without reissuing anything.
Pairing — how you get a token
All of this happens on the LAN, against the node's local HTTP server (port 80). You need the node's IP and its PIN. (To discover nodes, probe /info across the subnet — whatever answers is a node.)
1 — check the node
GET http://<node-ip>/info
→ 200 { "device_id": "…", "firmware": "1.00", "ws_connected": true }
ws_connected must be true — the node relays your request to the backend over its own link.
2 — request authorization
POST http://<node-ip>/ext/authorize
Authorization: Bearer <PIN>
{ "kind": "lora-gw", "name": "rooftop RAK", "scopes": "radio.frames,radio.spectrum" }
scopes is a comma-separated string, not a JSON array. Responses: 202 {"req":"<id>"} relayed, poll for the answer · 403 wrong PIN · 400 missing kind/scopes.
3 — poll for the grant
GET http://<node-ip>/ext/authorize?req=<id>
Authorization: Bearer <PIN>
→ { "status": "pending" }
→ { "status": "granted", "token": "sma_…", "id": "<attachment-id>", "exp": "<iso>" }
→ { "status": "denied", "reason": "…" }
Poll once a second; the node keeps the answer for about 5 minutes. Store the token with mode 0600 — from now on you talk to the backend directly and the node is out of the loop.
A granted-but-unused token expires after 30 minutes. Its full lifetime (30 days, renewable) starts at first use. One node vouches for at most four attachments; the owner can revoke any of them from the app, which makes the token inert immediately.
Talking to the backend
Base URL https://sensmos.com/v1/ext, every request with Authorization: Bearer sma_….
| call | purpose |
|---|---|
GET /v1/ext/me | are you still alive, what may you send, which integrations/formats the server accepts (catalog) — call it on startup |
POST /v1/ext/renew | extend the token's expiry; once a day is plenty |
POST /v1/ext/<integration>/<format> | send data → 200 {"ok":true,"accepted":n} |
Live endpoints:
| URL | scope | body |
|---|---|---|
/v1/ext/radio/rxpk | radio.frames | Semtech packet-forwarder PUSH_DATA JSON, as-is |
/v1/ext/radio/frames | radio.frames | native frame format (below) |
/v1/ext/radio/channel | radio.spectrum | one spectrum reading (below) |
Data formats
rxpk — Semtech packet forwarder, one to one. Send the object exactly as the forwarder produced it in PUSH_DATA:
{ "rxpk": [ {
"time": "2026-08-21T20:48:11Z",
"freq": 867.3, "datr": "SF9BW125", "modu": "LORA",
"rssi": -44, "lsnr": 12.0, "size": 38, "stat": 1,
"data": "4FNNT1MgY2Nk…"
} ] }
datr is parsed only for LoRa; modulation comes from modu. stat follows Semtech (1 CRC OK, -1 fail, 0 none). tmst is ignored; without time the server stamps arrival time.
frames — native format, for attachments written for us (own receiver, SDR, Meshtastic bridge):
{ "frames": [ {
"ts": 1755808091, "freq": 867.3, "sf": 9, "mode": 0,
"rssi": -44, "snr": 12.0, "len": 38, "crc": true,
"hex": "e0534d4f53…", "dir": "rx"
} ] }
mode 0 LoRa / 1 FSK; sf 0 for FSK; hex = PHY payload, first 128 bytes kept. dir: "tx" is for transmissions your gateway makes that others can hear (normal inverted-IQ downlinks prove nothing — skip them); for tx, rssi carries TX power.
channel — one spectrum reading:
{ "freq": 869.618, "bw": 62.5, "sf": 8, "sync": 18, "noise": -118, "peak": -97 }
freq required, the rest defaults to 0; noise/peak in dBm.
Limits and errors
| code | body | meaning |
|---|---|---|
| 401 | invalid_token | token unknown, expired or revoked |
| 403 | missing_scope | token valid, but not for this endpoint |
| 400 | parser message | body does not match the format |
| 404 | unknown_endpoint + catalog | no such integration/format |
| 413 | too_many | over the batch cap — 200 frames per request |
Batch on your side — once a second, or when 50 frames accumulate, whichever comes first. Application payloads stay encrypted end to end: the server works with radio metadata and recognises Sensmos frames among the traffic; it has no keys for anyone's LoRaWAN payloads and wants none.
What the data does
Frames land in the same tables and on the same map as frames heard by a node's own radio — an attachment is not a separate being, it is a better antenna for the node that vouched for it. Sensmos emergency broadcasts and sensor frames are recognised and delivered to their owners; verified Sensmos beacons build radio-coverage edges between nodes; the vouching node's data profile gains the RF category.
Ready-made attachments: LoRaWAN gateway (passive listener). Source for both agents is on GitHub.
Adding a new integration type
The server side of every integration is one declarative entry in a registry (scopes, formats, parser, handler). Candidates on the same pattern, none of them live yet: GNSS sky view and interference (gnss.sky, deliberately without position), ADS-B and AIS reception summaries per window, rtl_433, power meters. If you have hardware and want a format accepted, get in touch on Discord — a format entry is a small, reviewable change.