/ ext guide
Attachments API
Internal draft — not official documentation. This page is a working note, not a public commitment: endpoints, fields and limits may change without notice.

EXT — plug hardware into a node

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.

Trust model

Two barriers, three questions

Each barrier closes a different door:

barrierproveschecked by
node PINyou are physically on the owner's networkthe node
backend grantthe node exists, has an owner, has a free slotthe backend

Three questions are kept separate on every request:

questioncarried by
who are youtoken (sma_…)
what are you allowed to doscopes (rows in the backend, not bytes in the token)
what format are you sendingthe 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 (the owner has both).

1

Check the node

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.

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. An array parses to an empty string on the node and you get 400 kind and scopes required.
responsemeaning
202 { "req": "<id>" }request relayed to the backend, poll for the answer
403wrong PIN
400missing 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 can vouch for at most 4 attachments; the owner can revoke any of them from the app, which makes the token inert immediately.

Backend

Talking to the backend

Base URL: https://sensmos.com/v1/ext. Every request carries:

Authorization: Bearer sma_…

Introspection

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).

Renewal

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.

Sending data

POST /v1/ext/<integration>/<format>
→ 200 { "ok": true, "accepted": <n> }

The integration and format come from the catalog. Today:

URLscopebody
/v1/ext/radio/rxpkradio.framesSemtech packet-forwarder PUSH_DATA JSON, as-is
/v1/ext/radio/framesradio.framesour native frame format (below)
/v1/ext/radio/channelradio.spectrumone spectrum reading (below)
Formats

Data formats

rxpk — Semtech packet forwarder, one to one

Send 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 format

For 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.

Limits

Limits and errors

codebodymeaning
401invalid_tokentoken unknown, expired or revoked
403missing_scopetoken valid, but not for this endpoint
400parser messagebody does not match the format
404unknown_endpoint + catalogno such integration/format
413too_manyover 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.

Effect

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. Verified Sensmos beacons build radio-coverage edges between nodes; the vouching node's data profile gains the RF category.

Future

Future integrations — drafts

Nothing below is live. These are drafts of what the next integrations are expected to look like, published so hardware owners can see the direction and argue about field names before anything ships. The backend does not accept these endpoints yet.

GNSS — sky view & interference draft: POST /v1/ext/gnss/sky · scope gnss.sky

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
} }

ADS-B — aircraft reception draft: POST /v1/ext/adsb/summary · scope air.traffic

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 } }

AIS — ship reception draft: POST /v1/ext/ais/summary · scope sea.traffic

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 } }

Adding a new integration type

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.