Sensor API

Beet Lotse accepts batched sensor readings from any device that can sign an HTTP request with HMAC-SHA-256 — an ESP32, a Raspberry Pi or a small script.

TL;DR

POST https://beetlotse.de/api/v1/sensors/{id}/readings
Headers: X-Sensor-Id, X-Sensor-Secret, X-Sensor-Signature
Body:    {"batch_id":"...", "readings":[{"value":..,"unit":"..","recorded_at":"..."}]}
Signature: HMAC_SHA256(secret, raw_body_bytes), hex

Three steps: add the sensor in the app → put ID and secret on the device → send readings.


1. Supported sensor types

Primary types

type value Measures Unit (metric) Imperial Range
soil_moisture Soil water content "%" (0–100) — 0 to 100
temperature Air / soil temp "°C" "°F" −40 to 85
humidity Relative humidity "%" (0–100) — 0 to 100
barometric_pressure Air pressure "hPa" (= mbar) "inHg" 870 to 1085

The server stores value and unit as sent. The apps convert units for display.

Soil moisture: percent or raw

Capacitive soil-moisture probes don't produce % by themselves. They return a raw count (typically 200–800 on a 12-bit ADC). Send whichever your device has:

Mode unit sent When to use it
Percent "%" The device already knows its dry/wet baselines
Raw "raw" Capacitive probe without on-device calibration

In raw mode, calibrate once in the app: probe in dry air → "save dry value"; probe in a glass of water → "save wet value". The app then shows percent:

pct = clamp((raw - value_dry) / (value_wet - value_dry) * 100, 0, 100)

Raw values are kept, so you can recalibrate later without losing history. Automatic watering reminders use the calibrated percent.

More types

type value Unit Notes
light "lux" Ambient light
ph "pH" 0–14
ec "mS/cm" Nutrient-solution conductivity
co2 "ppm" CO₂ concentration
custom up to 20 chars Free-form unit

2. Requirements

  • Verified email address.
  • Pro plan to add sensors (402 otherwise). Sensors you added before downgrading keep working for reading.

3. Add a sensor

In the app (recommended)

Profile → More → Sensors → Add sensor. Choose what it measures, give it a name and attach it to a tray. The app then shows the sensor ID and secret key — exactly once. Copy both onto your device before closing the dialog.

Via the API

Get a token:

TOKEN=$(curl -sS -X POST https://beetlotse.de/api/v1/auth/login \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'username=<YOUR_EMAIL>' \
  --data-urlencode 'password=<YOUR_PASSWORD>' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")

Register a soil-moisture sensor for tray 3:

curl -sS -X POST https://beetlotse.de/api/v1/sensors \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Raised bed A", "type": "soil_moisture", "tray_id": 3}'

The response contains the secret — the only time you see it:

{
  "id": 12,
  "name": "Raised bed A",
  "type": "soil_moisture",
  "tray_id": 3,
  "secret": "<shown once>"
}

4. Send readings

Each POST is a batch of up to 2,000 readings, signed with the sensor's secret. Readings are deduplicated on (sensor_id, batch_id, recorded_at), so resending a batch is safe.

Headers

Header Value
X-Sensor-Id The sensor ID (same as in the path)
X-Sensor-Secret The secret from registration
X-Sensor-Signature HMAC_SHA256(secret, raw_body_bytes) hex
Content-Type application/json

Sign the exact bytes you send — don't re-serialise the JSON after computing the signature.

Body

{
  "batch_id": "any-unique-id-from-the-device",
  "readings": [
    { "value": 36.2, "unit": "%",   "recorded_at": "2026-05-29T17:00:00Z" },
    { "value": 421,  "unit": "raw", "recorded_at": "2026-05-29T17:05:00Z" }
  ]
}

Examples per type:

// soil_moisture (percent)
{ "value": 36.2, "unit": "%", "recorded_at": "2026-05-29T17:00:00Z" }

// soil_moisture (raw — calibrate in the app)
{ "value": 421, "unit": "raw", "recorded_at": "2026-05-29T17:00:00Z" }

// temperature
{ "value": 21.4, "unit": "°C", "recorded_at": "2026-05-29T17:00:00Z" }

// humidity
{ "value": 58.0, "unit": "%", "recorded_at": "2026-05-29T17:00:00Z" }

// barometric_pressure
{ "value": 1013.2, "unit": "hPa", "recorded_at": "2026-05-29T17:00:00Z" }

Complete curl example

SENSOR_ID=<YOUR_SENSOR_ID>
SECRET="<YOUR_SENSOR_SECRET>"

BODY='{"batch_id":"demo-01","readings":[{"value":36.2,"unit":"%","recorded_at":"2026-05-29T17:00:00Z"}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -sS -X POST "https://beetlotse.de/api/v1/sensors/$SENSOR_ID/readings" \
  -H "X-Sensor-Id: $SENSOR_ID" \
  -H "X-Sensor-Secret: $SECRET" \
  -H "X-Sensor-Signature: $SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Response:

{ "batch_id": "demo-01", "received": 1, "inserted": 1, "deduped": 0 }

Errors

HTTP Cause Fix
400 X-Sensor-Id doesn't match the path Use the same ID in both places
401 Wrong secret Add the sensor again; the secret is shown once
401 Wrong signature Sign the bytes you actually send
402 No Pro plan See section 2
403 Email not verified Confirm your email address
404 Sensor was deleted Add the sensor again
410 Sensor disabled Add a new sensor
422 Invalid body unit max 20 chars; recorded_at ISO-8601 with timezone

5. Example in Python

Runs on a Raspberry Pi; MicroPython works the same way with urequests and hmac:

import json, hmac, hashlib, time, requests, datetime as dt

API = "https://beetlotse.de/api/v1"
SENSOR_ID = 12                      # your sensor ID
SECRET = "<YOUR_SENSOR_SECRET>"

def now():
    return dt.datetime.now(dt.timezone.utc).isoformat()

def send_batch(readings, batch_id=None):
    batch_id = batch_id or f"py-{int(time.time())}"
    body = json.dumps({"batch_id": batch_id, "readings": readings}).encode()
    sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
    r = requests.post(
        f"{API}/sensors/{SENSOR_ID}/readings",
        headers={
            "X-Sensor-Id": str(SENSOR_ID),
            "X-Sensor-Secret": SECRET,
            "X-Sensor-Signature": sig,
            "Content-Type": "application/json",
        },
        data=body,                  # raw bytes, not the dict
        timeout=10,
    )
    r.raise_for_status()
    return r.json()

# Soil moisture in percent
print(send_batch([{"value": 36.2, "unit": "%", "recorded_at": now()}]))

Keep the unit consistent with the sensor type so the charts make sense.


6. Read readings back

curl -sS "https://beetlotse.de/api/v1/sensors/12/readings?limit=50" \
  -H "Authorization: Bearer $TOKEN"

Time range: ?from=2026-05-01T00:00:00Z&to=2026-06-01T00:00:00Z (ISO-8601 with timezone; at most 10,000 readings per response).


7. Security

  • The secret is shown only once. We store just a hash of it; if you lose it, add the sensor again.
  • Always send over HTTPS; plain HTTP is refused.
  • Leaked secret? Delete the sensor in the app. The device gets 410 Gone on its next batch.
  • Rate limit per sensor: 20 batches per minute.

8. Sensor, tray and location

A sensor belongs to your account and can be attached to one tray (e.g. a soil-moisture probe in a raised bed) or to one location (e.g. a temperature sensor for the whole greenhouse). Change the assignment in the app under Profile → More → Sensors → select the sensor.


9. This guide in the app

You'll find this guide in the app under Profile → More → Sensors. "Send by email" mails it to you together with the list of your sensor IDs — handy when you're setting up a device away from your phone.

Ready to wire up a device? See pricing or get in touch.