An attachment is any device or program that measures something useful but is not, and will not be, a Sensmos node: a LoRaWAN concentrator, an SDR, a GNSS receiver, a power meter. It has no wallet and no attestation of its own. Instead, a Sensmos node on the same LAN vouches for it, and the backend issues it a narrow, revocable token.
This page documents the integration contract end to end: pairing, tokens and scopes, and the data formats the backend accepts.
Each barrier closes 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 are you allowed to do | scopes (rows in the backend, not bytes in the token) |
| what format are you sending | 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.
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 (the owner has both).
GET http://<node-ip>/info
→ 200 { "device_id": "…", "firmware": "0.88", "ws_connected": true }
ws_connected must be true — the node relays your request
to the backend over its WebSocket link; without it there is nothing to ask for
approval. To discover nodes, probe /info across the subnet — whatever
answers is a node.
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. An array parses to an empty string on the node and you get
400 kind and scopes required.| response | meaning |
|---|---|
202 { "req": "<id>" } | request relayed to the backend, poll for the answer |
403 | wrong PIN |
400 | missing kind/scopes |
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 can vouch for at most 4 attachments; the owner can revoke any of them from the app, which makes the token inert immediately.
Base URL: https://sensmos.com/v1/ext. Every request carries:
Authorization: Bearer sma_…
GET /v1/ext/me
→ { "id": "…", "node": "209f5ca1", "kind": "lora-gw", "name": "…",
"scopes": ["radio.frames"], "expires_at": "…", "catalog": { … } }
Call it on startup: it tells you whether you are still alive, what you may send,
and what integrations/formats the server currently accepts (catalog).
POST /v1/ext/renew → { "ok": true, … }
Extends the token's expiry. Call it once a day; a token left alone expires 30 days after first use.
POST /v1/ext/<integration>/<format>
→ 200 { "ok": true, "accepted": <n> }
The integration and format come from the catalog. Today:
| URL | scope | body |
|---|---|---|
/v1/ext/radio/rxpk | radio.frames | Semtech packet-forwarder PUSH_DATA JSON, as-is |
/v1/ext/radio/frames | radio.frames | our native frame format (below) |
/v1/ext/radio/channel | radio.spectrum | one spectrum reading (below) |
rxpk — Semtech packet forwarder, one to oneSend the JSON object exactly as the forwarder produced it in PUSH_DATA:
{ "rxpk": [ {
"time": "2026-08-21T20:48:11Z", // optional, only GPS gateways have it
"freq": 867.3, "datr": "SF9BW125", "modu": "LORA",
"rssi": -44, "lsnr": 12.0, "size": 38, "stat": 1,
"data": "4FNNT1MgY2Nk…" // base64 PHY payload
} ] }
Notes from the parser: datr is only parsed for LoRa
(SF9BW125); modulation is read from modu, not guessed from
datr. stat follows Semtech: 1 CRC OK,
-1 CRC fail, 0 no CRC. tmst is ignored
(concentrator tick counter, not wall time) — without time the server
stamps arrival time.
frames — native formatFor attachments written for us (own receiver, SDR, a mesh bridge) that prefer to normalise on their side:
{ "frames": [ {
"ts": 1755808091, // unix seconds, optional
"freq": 867.3, // MHz
"sf": 9, // 0 for FSK
"mode": 0, // 0 LoRa, 1 FSK
"rssi": -44, "snr": 12.0,
"len": 38, // bytes on air
"crc": true,
"hex": "e0534d4f53…", // PHY payload, hex, first 128 bytes kept
"dir": "rx" // "rx" heard | "tx" transmitted by your gateway
} ] }
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 is required, the rest defaults to 0.
noise/peak in dBm.
| 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 — for example once per 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 beacons among the traffic; it has no keys for anyone's LoRaWAN payloads and wants none.
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. Verified Sensmos beacons build radio-coverage edges between nodes; the vouching node's data profile gains the RF category.
What a cheap GNSS receiver sees of the sky: satellite counts per constellation, signal quality, and the interference indicators most receivers already report. Deliberately no position — position affects coverage rewards and needs its own decision before any attachment may report it.
{ "sky": {
"ts": 1755808091,
"sats_visible": 17, "sats_used": 9,
"constellations": { "gps": 8, "galileo": 5, "glonass": 3, "beidou": 1 },
"hdop": 0.9,
"snr_avg": 38, "snr_max": 47, // dB-Hz
"agc": 0.62, // automatic gain, 0..1
"jam_ind": 12 // receiver jamming indicator, 0..255
} }
A per-window summary of what the antenna hears, not a feed of individual aircraft — range and message rate say everything about the receiver, which is what the network cares about.
{ "window_s": 60,
"aircraft_seen": 14, "msgs": 4210,
"max_range_km": 187.4,
"min_alt_ft": 2100, "max_alt_ft": 41000,
"farthest": { "hex": "4d2228", "range_km": 187.4, "alt_ft": 38000 } }
Same idea on the water: a reception summary per window.
{ "window_s": 300,
"vessels_seen": 9, "msgs": 512,
"max_range_km": 42.7,
"classes": { "a": 6, "b": 3 } }
The server side of every integration is one declarative entry in a registry
(scopes, formats, parser, handler) — the drafts above, rtl_433 and
power-meter inputs are all candidates on this exact pattern. If you have hardware and
want a format accepted, get in touch — a format entry is a small, reviewable change.