POST /v1/ingest
The single endpoint every device hits
The general-purpose ingest endpoint. Use it from any device that can do an HTTPS POST: ESP32, Arduino with WiFi, a bash script on a Pi, anything. The Shelly-specific GET variant exists for devices that cannot do POST; the TTN webhook exists for LoRaWAN. Everything else uses this.
Endpoint
Headers
| Header | Value |
|---|---|
Authorization | Bearer ds_live_… (device token) |
Content-Type | application/json |
X-Device-Id | optional, overrides device id in body |
Request body
{
"device": "fridge01",
"ts": "2026-05-17T08:22:00Z",
"measurements": [
{ "type": "temperature", "value": 4.2, "label": "front" },
{ "type": "temperature", "value": 4.5, "label": "back" },
{ "type": "humidity", "value": 64.1 },
{ "type": "battery", "value": 92, "unit": "%" }
]
}
Fields:
device— required ifX-Device-Idis not set. The local id of the device (the one in the dashboard).ts— optional. If absent, server stamps with receive time. We recommend setting it whenever the device has a clock, even a coarse one.measurements— required, 1–32 entries. Each entry has:type— required. One oftemperature(°C),humidity(%),pressure(hPa),co2(ppm),voltage(V),current(A),power(W),leak(boolean as 0/1),battery(% by default,Vifunit: V),signal(dBm).value— required. Number. Booleans get encoded as0/1.label— optional, free-text. Disambiguates multiple sensors of the sametypeon one device.unit— optional. Overrides the default unit. We do not convert.
Response
200 OK:
{
"accepted": 4,
"deduplicated": 0,
"channels": [
{ "type": "temperature", "label": "front", "channel_id": "chn_4f3c1d" },
{ "type": "temperature", "label": "back", "channel_id": "chn_4f3c1e" },
{ "type": "humidity", "label": null, "channel_id": "chn_4f3c1f" },
{ "type": "battery", "label": null, "channel_id": "chn_4f3c20" }
]
}
channels[] returns the resolved channel ids so subsequent calls (read,
rule create) can address the channel directly. The first uplink on a fresh
device auto-creates channels per (type, label) combination using the
site's vertical default thresholds.
accepted is the number of measurements written. deduplicated is the
number that arrived with the same (device, ts, channel) as an existing
row (a no-op, but counted for diagnostic purposes).
Examples
curl -X POST https://api.opensense.murzin.digital/v1/ingest \
-H "Authorization: Bearer ds_live_4f3c…" \
-H "Content-Type: application/json" \
-d '{
"device": "fridge01",
"ts": "2026-05-17T08:22:00Z",
"measurements": [
{"type":"temperature","value":4.2},
{"type":"humidity","value":64.1}
]
}'
Idempotency
This endpoint is content-idempotent: a duplicate (device, ts, type, label)
is a no-op. You can safely retry a request without setting Idempotency-Key.
For at-most-once accounting in your client code (e.g. you keep a queue
and want to know whether a specific batch landed), set Idempotency-Key
explicitly; the response is cached for 24 h.
Errors
| HTTP | code | When |
|---|---|---|
| 400 | bad_json | Body is not valid JSON |
| 400 | no_measurements | Body parsed but measurements array is empty |
| 401 | token_invalid | Authorization header missing or wrong |
| 403 | device_not_in_token | Device token does not own device field |
| 422 | unknown_kind | A type value is not in the supported set |
| 422 | out_of_range | Sanity check failed (e.g. T = +900 °C) |
| 429 | rate_limited | Exceeded 60 req / min on this device |
Sanity ranges (rejected at ingest):
| Kind | Min | Max |
|---|---|---|
| temperature | −80 °C | +200 °C |
| humidity | 0 % | 100 % |
| pressure | 300 hPa | 1100 hPa |
| co2 | 0 ppm | 10 000 ppm |
| voltage | 0 V | 1000 V |
| current | −500 A | +500 A |
| power | −50 kW | +50 kW |
| battery (%) | 0 | 100 |
| battery (V) | 0.5 | 60 |
| signal | −150 dBm | 0 dBm |
Out-of-range values are rejected, not clamped. We will not silently absorb a sensor failure into a "real" reading.