xPlantAPI

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.

Edit on GitHub

On this page