Sensor API
Push readings from any device that can sign an HTTP request with HMAC-SHA-256. Four primary sensor types — soil moisture, temperature, humidity, barometric pressure — plus extended types for light, pH, EC and CO₂.
You'll find the same guide in the app under Profile → More → Sensors.
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 (
402otherwise). 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 Goneon 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.