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

HeaderValue
AuthorizationBearer ds_live_… (device token)
Content-Typeapplication/json
X-Device-Idoptional, 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 if X-Device-Id is 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 of temperature (°C), humidity (%), pressure (hPa), co2 (ppm), voltage (V), current (A), power (W), leak (boolean as 0/1), battery (% by default, V if unit: V), signal (dBm).
    • value — required. Number. Booleans get encoded as 0 / 1.
    • label — optional, free-text. Disambiguates multiple sensors of the same type on 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}
    ]
  }'
import requests, datetime as dt
r = requests.post(
    "https://api.opensense.murzin.digital/v1/ingest",
    headers={"Authorization": "Bearer ds_live_4f3c…"},
    json={
        "device": "fridge01",
        "ts": dt.datetime.utcnow().isoformat() + "Z",
        "measurements": [
            {"type": "temperature", "value": 4.2},
            {"type": "humidity",    "value": 64.1},
        ],
    },
    timeout=5,
)
r.raise_for_status()
http_request:
  useragent: opensense-esp32
  timeout: 10s
interval:
  - interval: 60s
    then:
      - http_request.post:
          url: https://api.opensense.murzin.digital/v1/ingest
          headers:
            Authorization: 'Bearer ds_live_4f3c…'
            Content-Type: application/json
          body: |-
            { "device": "fridge01",
              "ts": "{{ now().to_iso() }}",
              "measurements": [
                { "type": "temperature", "value": {{ id(temp).state }} },
                { "type": "humidity",    "value": {{ id(hum).state  }} }
              ] }
#include <WiFi.h>
#include <HTTPClient.h>

void post(float t, float h) {
  HTTPClient http;
  http.begin("https://api.opensense.murzin.digital/v1/ingest");
  http.addHeader("Authorization", "Bearer ds_live_4f3c…");
  http.addHeader("Content-Type", "application/json");
  char buf[192];
  snprintf(buf, sizeof(buf),
    "{\"device\":\"fridge01\",\"measurements\":[{\"type\":\"temperature\",\"value\":%.2f},{\"type\":\"humidity\",\"value\":%.2f}]}",
    t, h);
  int code = http.POST((uint8_t*)buf, strlen(buf));
  http.end();
}
POST /v1/ingest HTTP/1.1
Host: api.opensense.murzin.digital
Authorization: Bearer ds_live_4f3c…
Content-Type: application/json
Content-Length: 142

{"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

HTTPcodeWhen
400bad_jsonBody is not valid JSON
400no_measurementsBody parsed but measurements array is empty
401token_invalidAuthorization header missing or wrong
403device_not_in_tokenDevice token does not own device field
422unknown_kindA type value is not in the supported set
422out_of_rangeSanity check failed (e.g. T = +900 °C)
429rate_limitedExceeded 60 req / min on this device

Sanity ranges (rejected at ingest):

KindMinMax
temperature−80 °C+200 °C
humidity0 %100 %
pressure300 hPa1100 hPa
co20 ppm10 000 ppm
voltage0 V1000 V
current−500 A+500 A
power−50 kW+50 kW
battery (%)0100
battery (V)0.560
signal−150 dBm0 dBm

Out-of-range values are rejected, not clamped. We will not silently absorb a sensor failure into a "real" reading.