# 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.

Source: https://docs.xplantpro.com/docs/guides/sensors-and-devices

**Credential:** a [device token](https://docs.xplantpro.com/docs/device-tokens.md) 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](https://docs.xplantpro.com/docs/api/sensor-readings/create-sensor-readings.md), [Send a heartbeat](https://docs.xplantpro.com/docs/api/devices/send-heartbeat.md), [Record a device event](https://docs.xplantpro.com/docs/api/devices/create-device-event.md), [List sensor readings](https://docs.xplantpro.com/docs/api/sensor-readings/list-sensor-readings.md).

## 1. Set the device up

From your own computer, with a workspace key: [register the device and create its token](https://docs.xplantpro.com/docs/device-tokens.md#setting-up-a-device). 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](https://www.xplantpro.com/en/subscriptions); 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**

```bash
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"
      }
    ]
  }'
```

**JavaScript**

```js
import { XPlantClient } from "@shmaplex/xplant-sdk";

const device = new XPlantClient({ deviceToken: process.env.XPLANT_DEVICE_TOKEN, retry: true });
const deviceId = process.env.XPLANT_DEVICE_ID;

const at = new Date().toISOString();
await device.sensorReadings.createBatch([
  { device_id: deviceId, type: "temperature", value: 23.4, unit: "C", recorded_at: at, external_id: `gw1-temperature-${at}` },
  { device_id: deviceId, type: "humidity", value: 71.2, unit: "%", recorded_at: at, external_id: `gw1-humidity-${at}` },
]);
```

**Python**

```python
import os
import requests
from datetime import datetime, timezone

# An ISO 8601 timestamp in UTC; "Z" and "+00:00" are both accepted.
at = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
device_id = os.environ["XPLANT_DEVICE_ID"]
requests.post(
    "https://app.xplantpro.com/api/v1/sensor-readings",
    headers={"Authorization": f"Bearer {os.environ['XPLANT_DEVICE_TOKEN']}"},
    json={"readings": [
        {"device_id": device_id, "type": "temperature", "value": 23.4, "unit": "C",
         "recorded_at": at, "external_id": f"gw1-temperature-{at}"},
        {"device_id": device_id, "type": "humidity", "value": 71.2, "unit": "%",
         "recorded_at": at, "external_id": f"gw1-humidity-{at}"},
    ]},
    timeout=10,
).raise_for_status()
```

> **Batch your posts**
>
> One request per reading multiplies your request count for the same data and reaches the [rate limits](https://docs.xplantpro.com/docs/rate-limits.md) 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` as an ISO 8601 timestamp** with `Z` or an offset, for example `2026-09-25T12:00:00.000Z` or `2026-09-25T14:00:00+02:00`: the time the reading was taken, not the time you send it.

**Make retries harmless.** Put `external_id` and `recorded_at` on every reading. A reading that repeats one already stored for the same device (same `external_id` and `recorded_at`) is skipped rather than stored twice: in a batch it's left out of the response list, and a single reading answers `201` with the reading already stored and `meta.duplicate: true`. (`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

```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](https://github.com/shmaplex/xplant_os/tree/main/devices/raspberry-pi/pi-gateway) in this repository is a complete, installable version. In JavaScript, the SDK's [`sensorReadings.buffer()`](https://docs.xplantpro.com/docs/sdk.md#buffering-sensor-readings-on-a-device) 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.

```bash
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. Send an `external_id` so a retry is harmless: a repeat under the same `external_id` answers `201` with the event already stored and `meta.duplicate: true`. Without one, a retried request is recorded twice.

```bash
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"}, "external_id": "pi-3-door-2-20260925T081500Z"}'
```

## 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.

```bash
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](https://docs.xplantpro.com/docs/pagination.md).
