sondahub

APIs / Fleet

Fleet

An IoT fleet: sites, devices, telemetry and alerts — the MQTT one.

Five hundred devices of eight kinds across forty sites. The readings table holds the last window of telemetry; the live stream and the MQTT broker publish fresh readings every few seconds and accept commands back. Devices report firmware, signal and battery so there is always something to alert on. 6,248 records in all.

Connect

Base URL
https://api.sondahub.com/v1/fleet
OpenAPI 3
https://api.sondahub.com/v1/fleet/openapi.json
GraphQL
https://api.sondahub.com/v1/fleet/graphql
WebSocket
wss://api.sondahub.com/v1/fleet/ws
SSE
https://api.sondahub.com/v1/fleet/events
MQTT
wss://api.sondahub.com/mqtt
The data
sites.json, devices.json, readings.json, alerts.json, commands.json

In Sonda: Import → From a URL with the OpenAPI address and the whole API lands as a project, one request per operation with example bodies. No keys, no headers to add. More on each protocol.

Writes here are simulated: POST, PUT, PATCH and DELETE are validated, run through the real logic and answered as a real server would — then forgotten. The answer carries _note and X-Sondahub-Write: simulated. A GET afterwards will not find what you wrote.
The API describes itself
curl https://api.sondahub.com/v1/fleet

Lists and filters

Every list answers { "data": [...], "meta": { "page", "limit", "total", "pages" } } with X-Total-Count and Link headers (next, prev, first, last). These options work on every collection and every nested route:

OptionMeaningExample
page, limitPaging, 1-based; limit 1–200, default 20. offset works too.?page=3&limit=50
sortComma list of fields, - for descending. Default id here.?sort=-lat,id
field=valueEquals. Booleans as true/false, null for missing.?kind=warehouse
_ne _gt _gte _lt _lteNot equal and comparisons, on numbers, dates and strings.?lat_gte=10&lat_lt=100
_likeContains, case-insensitive.?code_like=an
_inAny of a comma list.?id_in=1,2,3
_nulltrue: missing; false: present.?kind_null=true
a.b=valueInside a JSON field, dotted.?address.line1=…
qSearch across the text fields.?q=alpine
fieldsOnly these fields back.?fields=id,code
expandEmbed related records.?expand=devices

A name that is not a field answers 400 and lists the fields. Writes answer 422 with one line per problem, 404 for a missing id, 405 with an Allow header for a verb a route does not take.

sites

A place devices are installed. 40 records — the file.

FieldTypeNotes
idread-onlyintAssigned by the server. Seed records keep their ids across restarts; records you create continue after the seed.
created_atread-onlydatetimeWhen the record was created (ISO 8601, UTC).
updated_atread-onlydatetimeWhen the record last changed.
coderequiredstring
namerequiredstring
kindenumwarehouse office plant store datacenter farm clinic depot
addressjson { line1, city, region, postal_code, country }
timezonestring
latfloat
lonfloat
device_countread-onlyint
statusenumactive maintenance decommissioned

Relations: devices → the devices whose site_id is this site. Use ?expand=devices to embed them, or the routes below.

Endpoints

GET/v1/fleet/sitesA page, with every filter, sort, search, field and expand option below.
POST/v1/fleet/sitesCreate one. Answers 201 with the record, its id and a Location header — simulated, nothing stored; 422 names each field that is wrong.
GET/v1/fleet/sites/{id}One record, with an ETag; If-None-Match earns a 304.
PATCH/v1/fleet/sites/{id}Change the fields you send.
PUT/v1/fleet/sites/{id}Replace the record; required fields must all be there.
DELETE/v1/fleet/sites/{id}Answers 200 with what would have been removed and the note (a real server’s 204 lives at /v1/utils/status/204).
GET/v1/fleet/sites/{id}/devicesIts devices, as a page with all the list options.

Try it

List with a filter
curl "https://api.sondahub.com/v1/fleet/sites?kind=office&lat_gte=10&limit=3"
One record
curl https://api.sondahub.com/v1/fleet/sites/1
Its devices
curl "https://api.sondahub.com/v1/fleet/sites/1/devices?limit=5"
Create (simulated)
curl -X POST https://api.sondahub.com/v1/fleet/sites \
  -H "Content-Type: application/json" \
  -d '{"code":"MIA-01","name":"Miami Warehouse","kind":"warehouse","timezone":"America/New_York","status":"active"}'
Change one field (simulated)
curl -X PATCH https://api.sondahub.com/v1/fleet/sites/1 \
  -H "Content-Type: application/json" \
  -d '{"kind":"office"}'
Delete (simulated)
curl -X DELETE https://api.sondahub.com/v1/fleet/sites/1

devices

A device on a site. Its MQTT topics are fleet/{serial}/telemetry (published by the broker) and fleet/{serial}/commands (you publish). 500 records — the file.

FieldTypeNotes
idread-onlyintAssigned by the server. Seed records keep their ids across restarts; records you create continue after the seed.
created_atread-onlydatetimeWhen the record was created (ISO 8601, UTC).
updated_atread-onlydatetimeWhen the record last changed.
serialrequiredstringUnique. Also the device’s MQTT topic segment.
namestring
typerequiredenumthermostat power_meter air_quality water_meter gateway door_sensor vibration gps_tracker
modelstring
site_idrequiredint → sites
firmwarestring
statusenumonline offline degraded provisioning retired
battery_pctintnull for mains-powered devices.
min 0, max 100
rssi_dbmint
ipstring
macstring
tagsjson string[]
configjson { report_interval_s, thresholds? }
installed_atdatetime
last_seen_atdatetime

Relations: site → one site through site_id; readings → the readings whose device_id is this device; alerts → the alerts whose device_id is this device. Use ?expand=site,readings,alerts to embed them, or the routes below.

Endpoints

GET/v1/fleet/devicesA page, with every filter, sort, search, field and expand option below.
POST/v1/fleet/devicesCreate one. Answers 201 with the record, its id and a Location header — simulated, nothing stored; 422 names each field that is wrong.
GET/v1/fleet/devices/{id}One record, with an ETag; If-None-Match earns a 304.
PATCH/v1/fleet/devices/{id}Change the fields you send.
PUT/v1/fleet/devices/{id}Replace the record; required fields must all be there.
DELETE/v1/fleet/devices/{id}Answers 200 with what would have been removed and the note (a real server’s 204 lives at /v1/utils/status/204).
GET/v1/fleet/devices/{id}/siteThe site this record points at.
GET/v1/fleet/devices/{id}/readingsIts readings, as a page with all the list options.
GET/v1/fleet/devices/{id}/alertsIts alerts, as a page with all the list options.

Try it

List with a filter
curl "https://api.sondahub.com/v1/fleet/devices?type=power_meter&battery_pct_gte=1&expand=site&limit=3"
One record
curl https://api.sondahub.com/v1/fleet/devices/1?expand=site
Its readings
curl "https://api.sondahub.com/v1/fleet/devices/1/readings?limit=5"
Create (simulated)
curl -X POST https://api.sondahub.com/v1/fleet/devices \
  -H "Content-Type: application/json" \
  -d '{"serial":"TH200-7K2M4Q","name":"Thermostat, floor 2 east","type":"thermostat","model":"TH-200","site_id":1,"firmware":"2.5.2","status":"online","ip":"10.4.12.77","mac":"02:8f:1c:4a:9e:31"}'
Change one field (simulated)
curl -X PATCH https://api.sondahub.com/v1/fleet/devices/1 \
  -H "Content-Type: application/json" \
  -d '{"type":"power_meter"}'
Delete (simulated)
curl -X DELETE https://api.sondahub.com/v1/fleet/devices/1

readings

Telemetry. metrics is an object whose keys depend on the device type (a thermostat reports temperature, humidity and setpoint; a power meter voltage, current, power_kw and energy_kwh). 4,908 records — the file.

FieldTypeNotes
idread-onlyintAssigned by the server. Seed records keep their ids across restarts; records you create continue after the seed.
created_atread-onlydatetimeWhen the record was created (ISO 8601, UTC).
updated_atread-onlydatetimeWhen the record last changed.
device_idrequiredint → devices
recorded_atrequireddatetime
metricsrequiredjson { [metric]: number | boolean }
qualityenumgood estimated suspect

Relations: device → one device through device_id. Use ?expand=device to embed them, or the routes below.

Endpoints

GET/v1/fleet/readingsA page, with every filter, sort, search, field and expand option below.
POST/v1/fleet/readingsCreate one. Answers 201 with the record, its id and a Location header — simulated, nothing stored; 422 names each field that is wrong.
GET/v1/fleet/readings/{id}One record, with an ETag; If-None-Match earns a 304.
PATCH/v1/fleet/readings/{id}Change the fields you send.
PUT/v1/fleet/readings/{id}Replace the record; required fields must all be there.
DELETE/v1/fleet/readings/{id}Answers 200 with what would have been removed and the note (a real server’s 204 lives at /v1/utils/status/204).
GET/v1/fleet/readings/{id}/deviceThe device this record points at.

Try it

List with a filter
curl "https://api.sondahub.com/v1/fleet/readings?quality=estimated&expand=device&limit=3"
One record
curl https://api.sondahub.com/v1/fleet/readings/1?expand=device
Create (simulated)
curl -X POST https://api.sondahub.com/v1/fleet/readings \
  -H "Content-Type: application/json" \
  -d '{"device_id":1,"recorded_at":"2026-09-30T12:00:00Z","metrics":{},"quality":"good"}'
Change one field (simulated)
curl -X PATCH https://api.sondahub.com/v1/fleet/readings/1 \
  -H "Content-Type: application/json" \
  -d '{"quality":"estimated"}'
Delete (simulated)
curl -X DELETE https://api.sondahub.com/v1/fleet/readings/1

alerts

Something a device or a site needs looked at. Acknowledge one with PATCH {"status":"acknowledged"}. 500 records — the file.

PATCH to acknowledged or resolved stamps the matching timestamp.
FieldTypeNotes
idread-onlyintAssigned by the server. Seed records keep their ids across restarts; records you create continue after the seed.
created_atread-onlydatetimeWhen the record was created (ISO 8601, UTC).
updated_atread-onlydatetimeWhen the record last changed.
device_idrequiredint → devices
site_idint → sites
severityrequiredenuminfo warning critical
kindrequiredenumoffline low_battery threshold tamper firmware weak_signal
messagestring
statusenumopen acknowledged resolved muted
opened_atdatetime
acknowledged_atdatetime
resolved_atdatetime
acknowledged_bystring

Relations: device → one device through device_id; site → one site through site_id. Use ?expand=device,site to embed them, or the routes below.

Endpoints

GET/v1/fleet/alertsA page, with every filter, sort, search, field and expand option below.
POST/v1/fleet/alertsCreate one. Answers 201 with the record, its id and a Location header — simulated, nothing stored; 422 names each field that is wrong.
GET/v1/fleet/alerts/{id}One record, with an ETag; If-None-Match earns a 304.
PATCH/v1/fleet/alerts/{id}Change the fields you send.
PUT/v1/fleet/alerts/{id}Replace the record; required fields must all be there.
DELETE/v1/fleet/alerts/{id}Answers 200 with what would have been removed and the note (a real server’s 204 lives at /v1/utils/status/204).
GET/v1/fleet/alerts/{id}/deviceThe device this record points at.
GET/v1/fleet/alerts/{id}/siteThe site this record points at.

Try it

List with a filter
curl "https://api.sondahub.com/v1/fleet/alerts?severity=warning&expand=device&limit=3"
One record
curl https://api.sondahub.com/v1/fleet/alerts/1?expand=device
Create (simulated)
curl -X POST https://api.sondahub.com/v1/fleet/alerts \
  -H "Content-Type: application/json" \
  -d '{"device_id":1,"severity":"info","kind":"offline","status":"open"}'
Change one field (simulated)
curl -X PATCH https://api.sondahub.com/v1/fleet/alerts/1 \
  -H "Content-Type: application/json" \
  -d '{"severity":"warning"}'
Delete (simulated)
curl -X DELETE https://api.sondahub.com/v1/fleet/alerts/1

commands

A command sent to a device (reboot, set a config value, request a report). POST one and the answer carries the device’s acknowledgement. 300 records — the file.

POST answers acked with a result when the device is online (a real device would take a second; with nothing stored there is no later, so the answer is the acknowledgement), queued otherwise.
FieldTypeNotes
idread-onlyintAssigned by the server. Seed records keep their ids across restarts; records you create continue after the seed.
created_atread-onlydatetimeWhen the record was created (ISO 8601, UTC).
updated_atread-onlydatetimeWhen the record last changed.
device_idrequiredint → devices
actionrequiredenumreboot set_config report_now update_firmware identify
paramsjson object
statusenumqueued sent acked failed expired
issued_bystring
sent_atdatetime
acked_atdatetime
resultjson object

Relations: device → one device through device_id. Use ?expand=device to embed them, or the routes below.

Endpoints

GET/v1/fleet/commandsA page, with every filter, sort, search, field and expand option below.
POST/v1/fleet/commandsCreate one. Answers 201 with the record, its id and a Location header — simulated, nothing stored; 422 names each field that is wrong.
GET/v1/fleet/commands/{id}One record, with an ETag; If-None-Match earns a 304.
PATCH/v1/fleet/commands/{id}Change the fields you send.
PUT/v1/fleet/commands/{id}Replace the record; required fields must all be there.
DELETE/v1/fleet/commands/{id}Answers 200 with what would have been removed and the note (a real server’s 204 lives at /v1/utils/status/204).
GET/v1/fleet/commands/{id}/deviceThe device this record points at.

Try it

List with a filter
curl "https://api.sondahub.com/v1/fleet/commands?action=set_config&expand=device&limit=3"
One record
curl https://api.sondahub.com/v1/fleet/commands/1?expand=device
Create (simulated)
curl -X POST https://api.sondahub.com/v1/fleet/commands \
  -H "Content-Type: application/json" \
  -d '{"device_id":1,"action":"reboot","status":"queued"}'
Change one field (simulated)
curl -X PATCH https://api.sondahub.com/v1/fleet/commands/1 \
  -H "Content-Type: application/json" \
  -d '{"action":"set_config"}'
Delete (simulated)
curl -X DELETE https://api.sondahub.com/v1/fleet/commands/1

WebSocket and SSE

The same stream two ways: the world's own activity, one tick a second, generated for your connection alone. Both push JSON text messages; SSE names each one with event: and numbers it with id:. ?topics=a,b narrows either.

TopicWhat arrivesHow often
telemetryA fresh reading from one of the devices, with its serial, type and metrics.2 s
alertsAn alert opening or resolving.~15 s
devicesA device going online, offline or degraded.~10 s
WebSocket
wss://api.sondahub.com/v1/fleet/ws?topics=telemetry

> {"type":"hello","api":"fleet","topics":[…],"subscribed":[…]}
> {"type":"event","topic":"telemetry","api":"fleet","ts":"…","data":{…}}
< {"type":"subscribe","topics":["telemetry"]}   # narrow to some topics
< {"type":"ping"}                            # → {"type":"pong"}
< anything else                             # → echoed back as {"type":"echo"}
Server-Sent Events
curl -N "https://api.sondahub.com/v1/fleet/events?topics=telemetry"

retry: 3000
id: 1
event: telemetry
data: {"type":"event","topic":"telemetry",…}

MQTT

A broker over WebSocket at wss://api.sondahub.com/mqtt, MQTT 3.1.1 and 5, subprotocol mqtt, any username and password. QoS 0 and 1 (2 is handshaken and delivered at 1), retained messages, + and #, topic aliases. It serves one connection at a time — what you publish reaches your own subscriptions, as on a real broker, but not another client's, because sondahub keeps no shared state.

The broker publishes

TopicWhat
fleet/{serial}/telemetryReadings, JSON, every 2 s from a rotating set of devices. Subscribe to fleet/+/telemetry for all of them.
fleet/{serial}/statusRetained. online | offline | degraded, changing now and then.
fleet/{serial}/ackThe answer to a command you published.
fleet/alertsAlerts as they open and resolve.

You publish

TopicWhat
fleet/{serial}/commandsPublish {"action":"reboot"} (or set_config, report_now, identify) and the device acknowledges on fleet/{serial}/ack.
anything elseA plain broker: whatever you publish reaches your own matching subscriptions, retained flag honoured.

Also $SYS/broker/clients/connected and $SYS/broker/uptime every couple of seconds. Retained messages: up to 500 per connection.

In Sonda’s MQTT pane
Broker    wss://api.sondahub.com/mqtt
Version   5 (or 3.1.1)
Subscribe fleet/+/telemetry, fleet/alerts, $SYS/#
Publish   fleet/TH200-7K2M4Q/commands   {"action":"reboot"}
          → the device answers on fleet/TH200-7K2M4Q/ack

GraphQL

One endpoint, https://api.sondahub.com/v1/fleet/graphql: POST {"query", "variables"} or GET ?query=. Introspection is on, so Sonda's GraphQL mode loads the schema; the SDL is a click away. Every collection is a paged query with the same filter, sort and q options as REST (operators as suffixes: price_lt), a by-id query, relation fields both ways, and create, update, replace and delete mutations — simulated like every write, with the note in extensions.

A query
curl https://api.sondahub.com/v1/fleet/graphql -H "Content-Type: application/json" -d '{"query": "{ devices(limit: 3, sort: \"-id\", filter: { type: thermostat }) { total data { id serial name type site { code } readings(limit: 2) { id } } } }"}'
{
  devices(limit: 3, sort: "-id", filter: { type: thermostat }) {
    total
    data {
      id serial name type
      site { code }
      readings(limit: 2) { id }
    }
  }
}