Sensors and devices
Register a device, give it a device token, and post batched sensor readings, heartbeats and events from a Raspberry Pi or an ESP32.
Credential: a device token on the device; a workspace key with write:devices to set it up, and read:sensor_readings to read data back. Endpoints: Submit sensor readings, Send a heartbeat, Record a device event, List sensor readings.
1. Set the device up
From your own computer, with a workspace key: register the device and create its token. Put the device id and the xpd_ token on the device. The device never sees a workspace key.
How many devices you can register depends on your plan; registering past the allowance answers 402 DEVICE_LIMIT_REACHED.
A gateway that reads several probes is usually one device posting several reading types. If you'd rather see each probe as its own device, register each one and give the gateway a token per device.
2. Post readings, in batches
A reading is device_id, type, value and unit, plus optional recorded_at, external_id, room_id and notes. Send up to 500 in one request as {"readings": [...]}.
curl -X POST https://app.xplantpro.com/api/v1/sensor-readings \
-H "Authorization: Bearer $XPLANT_DEVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"readings": [
{
"device_id": "5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e",
"type": "temperature", "value": 23.4, "unit": "C",
"recorded_at": "2026-09-25T12:00:00Z",
"external_id": "gw1-temperature-20260925T120000"
},
{
"device_id": "5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e",
"type": "humidity", "value": 71.2, "unit": "%",
"recorded_at": "2026-09-25T12:00:00Z",
"external_id": "gw1-humidity-20260925T120000"
}
]
}'Batch your posts
One request per reading multiplies your request count for the same data and reaches the rate limits far sooner. A lab with four devices, five channels each, sampling once a minute would send about 864,000 requests a month unbatched, against a few thousand batched.
Buffer locally and flush on whichever comes first:
- every 30–60 seconds, for sensors that sample about once a minute, or
- once 500 readings are queued (the per-request maximum).
A single reading without the readings wrapper also works. Treat it as the fallback for a device that can only ever hold one pending reading, not as the default.
Send recorded_at in UTC ending in Z, for example 2026-09-25T12:00:00.000Z. A timestamp with an offset such as +00:00 is rejected with 422 VALIDATION_ERROR. In Python, datetime.now(timezone.utc).isoformat() ends in +00:00, so swap it for Z as the examples do.
Make retries harmless. Put external_id and recorded_at on every reading. Readings are de-duplicated on the device, external_id and recorded_at, so re-sending a batch after a network failure doesn't store anything twice. (Idempotency-Key isn't used on this endpoint.) Build external_id from something stable: the device, the channel and the sample time.
A buffering gateway in Python
import os
import time
from datetime import datetime, timezone
import requests
URL = "https://app.xplantpro.com/api/v1/sensor-readings"
HEADERS = {"Authorization": f"Bearer {os.environ['XPLANT_DEVICE_TOKEN']}"}
DEVICE_ID = os.environ["XPLANT_DEVICE_ID"]
FLUSH_EVERY_SECONDS = 30
MAX_BATCH = 500 # the per-request maximum
buffer = []
def queue(reading_type, value, unit):
now = datetime.now(timezone.utc)
buffer.append({
"device_id": DEVICE_ID,
"type": reading_type,
"value": value,
"unit": unit,
"recorded_at": now.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
# Stable per device, channel and second: re-sending it is a no-op.
"external_id": f"{DEVICE_ID}-{reading_type}-{int(now.timestamp())}",
})
if len(buffer) >= MAX_BATCH:
flush()
def flush():
if not buffer:
return
batch = buffer[:MAX_BATCH]
for _ in range(3):
try:
resp = requests.post(URL, headers=HEADERS, json={"readings": batch}, timeout=10)
except requests.RequestException:
time.sleep(5)
continue
if resp.status_code == 429:
time.sleep(int(resp.headers.get("Retry-After", "5")))
continue
if resp.ok:
del buffer[: len(batch)]
return
# Still failing: keep the readings and try again on the next flush.
last_flush = time.monotonic()
while True:
for reading_type, value, unit in read_sensors(): # your sensor code
queue(reading_type, value, unit)
if time.monotonic() - last_flush >= FLUSH_EVERY_SECONDS:
flush()
last_flush = time.monotonic()
time.sleep(1)The Raspberry Pi gateway in this repository is a complete, installable version. In JavaScript, the SDK's sensorReadings.buffer() does all of this for you.
3. Heartbeats and events
A heartbeat tells xPlant the device is alive and updates its last-seen time. Send one every few minutes, or with each flush.
curl -X POST "https://app.xplantpro.com/api/v1/devices/$XPLANT_DEVICE_ID/heartbeat" \
-H "Authorization: Bearer $XPLANT_DEVICE_TOKEN"A device event records something the device noticed or did: a door opening, a relay switching, a status change.
curl -X POST https://app.xplantpro.com/api/v1/device-events \
-H "Authorization: Bearer $XPLANT_DEVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"device_id": "'"$XPLANT_DEVICE_ID"'", "event_type": "door_opened", "payload": {"door": "Grow room 2"}}'4. Read readings back
From a dashboard or a script, with a workspace key holding read:sensor_readings (not the device token). Reading readings back through the API needs xPlant+ Teams or Enterprise; on Hobby and Pro Lab the data is visible in xPlant.
curl "https://app.xplantpro.com/api/v1/sensor-readings?device_id=$DEVICE_ID&type=temperature&since=2026-09-24T00:00:00Z&until=2026-09-25T00:00:00Z&limit=1000" \
-H "Authorization: Bearer $XPLANT_API_KEY"Readings come newest first. Filter by device_id, room_id and type, and set the window with since and until (both inclusive). A page holds 100 readings by default and up to 1,000; follow meta.next_cursor for the rest, as on every list.