# Submit sensor readings

> Stores readings from a device. Send one reading, or up to 500 at once as { "readings": [...] }. Batch them: a gateway that buffers readings and posts every 30 to 60 seconds uses a small fraction of the requests of one that posts each reading as it is taken.

Source: https://docs.xplantpro.com/docs/api/sensor-readings/create-sensor-readings

`POST https://app.xplantpro.com/api/v1/sensor-readings`

- Required scope: `write:sensor_readings`
- Credentials: workspace API key (`xpk_`) or device token (`xpd_`)
- Idempotency-Key: ignored on this endpoint

The response mirrors the request — one reading back for one reading sent, a list for a batch.

To make a retry safe, send `external_id` and `recorded_at` on every reading. A reading that repeats one already stored for the same device is skipped rather than stored twice. In a batch, a skipped reading is left out of the list, so the list can be shorter than the batch. A single reading that is skipped answers with the reading already stored, still `201`, with `meta.duplicate: true`.

One bad reading refuses the whole batch: if any reading fails validation, names a device or room outside the workspace, or comes from a device that is paused or retired, nothing in the request is stored.

Readings have a budget of their own on top of the request limit: 5,000 per key or device token and 10,000 per workspace in any five minutes. Over it, the answer is `429 RATE_LIMIT_EXCEEDED` with a `Retry-After` header.

See also: [Sensors and devices](https://docs.xplantpro.com/docs/guides/sensors-and-devices.md).

## Request body

The body is one of:

### A single object

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `device_id` | string (uuid) | Yes | The device that took the reading. |
| `type` | `"temperature"` \| `"humidity"` \| `"ph"` \| `"co2"` \| `"light"` \| `"other"` | Yes | What was measured. |
| `value` | number | Yes | The measured value. |
| `unit` | string | Yes | The unit the value is in, for example `celsius` or `%`. 1–20 characters. |
| `room_id` | string (uuid) | No | The growing room the reading describes. Must be a room in the workspace. |
| `recorded_at` | string (date-time) | No | When the reading was taken, as an ISO 8601 timestamp in UTC (`2026-09-25T14:00:00Z`) or with an offset (`2026-09-25T16:00:00+02:00`). Defaults to the time the request arrives. Required when you send `external_id`. |
| `notes` | string | No | A free-text note. Up to 500 characters. |
| `external_id` | string | No | Your own id for the reading. A reading with the same device, `external_id` and `recorded_at` as one already stored is skipped, so a retried batch is not stored twice. `recorded_at` is compared as a moment in time, so `2026-09-25T14:00:00Z` and `2026-09-25T16:00:00+02:00` count as the same. 1–200 characters. |

### A batch: `readings`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `readings` | object[] | Yes | Between 1 and 500 readings. Up to 500 items. |
| `readings[].device_id` | string (uuid) | Yes | The device that took the reading. |
| `readings[].type` | `"temperature"` \| `"humidity"` \| `"ph"` \| `"co2"` \| `"light"` \| `"other"` | Yes | What was measured. |
| `readings[].value` | number | Yes | The measured value. |
| `readings[].unit` | string | Yes | The unit the value is in, for example `celsius` or `%`. 1–20 characters. |
| `readings[].room_id` | string (uuid) | No | The growing room the reading describes. Must be a room in the workspace. |
| `readings[].recorded_at` | string (date-time) | No | When the reading was taken, as an ISO 8601 timestamp in UTC (`2026-09-25T14:00:00Z`) or with an offset (`2026-09-25T16:00:00+02:00`). Defaults to the time the request arrives. Required when you send `external_id`. |
| `readings[].notes` | string | No | A free-text note. Up to 500 characters. |
| `readings[].external_id` | string | No | Your own id for the reading. A reading with the same device, `external_id` and `recorded_at` as one already stored is skipped, so a retried batch is not stored twice. `recorded_at` is compared as a moment in time, so `2026-09-25T14:00:00Z` and `2026-09-25T16:00:00+02:00` count as the same. 1–200 characters. |

## Example

**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": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
        "type": "temperature",
        "value": 24.1,
        "unit": "°C",
        "room_id": "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26",
        "recorded_at": "2026-09-25T14:00:00.000Z",
        "external_id": "shelf3-temperature-20260925T140000Z"
      },
      {
        "device_id": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
        "type": "humidity",
        "value": 88,
        "unit": "%",
        "room_id": "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26",
        "recorded_at": "2026-09-25T14:00:00.000Z",
        "external_id": "shelf3-humidity-20260925T140000Z"
      }
    ]
  }'
```

**JavaScript**

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

const device = new XPlantClient({ deviceToken: process.env.XPLANT_DEVICE_TOKEN });

const stored = await device.sensorReadings.createBatch([
  {
    device_id: "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
    type: "temperature",
    value: 24.1,
    unit: "°C",
    room_id: "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26",
    recorded_at: "2026-09-25T14:00:00.000Z",
    external_id: "shelf3-temperature-20260925T140000Z",
  },
  {
    device_id: "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
    type: "humidity",
    value: 88,
    unit: "%",
    room_id: "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26",
    recorded_at: "2026-09-25T14:00:00.000Z",
    external_id: "shelf3-humidity-20260925T140000Z",
  },
]);
```

**Python**

```python
import os
import requests

resp = requests.post(
    "https://app.xplantpro.com/api/v1/sensor-readings",
    headers={"Authorization": f"Bearer {os.environ['XPLANT_DEVICE_TOKEN']}"},
    json={
        "readings": [
            {
                "device_id": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
                "type": "temperature",
                "value": 24.1,
                "unit": "°C",
                "room_id": "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26",
                "recorded_at": "2026-09-25T14:00:00.000Z",
                "external_id": "shelf3-temperature-20260925T140000Z",
            },
            {
                "device_id": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
                "type": "humidity",
                "value": 88,
                "unit": "%",
                "room_id": "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26",
                "recorded_at": "2026-09-25T14:00:00.000Z",
                "external_id": "shelf3-humidity-20260925T140000Z",
            },
        ],
    },
    timeout=10,
)
body = resp.json()
if not body["ok"]:
    raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}")
stored = body["data"]
```

## Response

`201` with `{ "ok": true, "data": … }`. `data` holds the result.

```json title="Response"
{
  "ok": true,
  "data": [
    {
      "id": "f1a7c3e9-2b4d-4f6a-8c0e-3d5b7a9c1e24",
      "device_id": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
      "team_id": "5d2e8b17-9c4a-4e3f-b6d0-1a7c9e2f4b83",
      "room_id": "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26",
      "type": "temperature",
      "value": 24.1,
      "unit": "°C",
      "recorded_at": "2026-09-25T14:00:00.000Z",
      "notes": null,
      "created_at": "2026-09-25T14:00:31.000Z"
    },
    {
      "id": "0b9d4e2f-6a8c-4d1e-9f3a-7c5e1b8d2a46",
      "device_id": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
      "team_id": "5d2e8b17-9c4a-4e3f-b6d0-1a7c9e2f4b83",
      "room_id": "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26",
      "type": "humidity",
      "value": 88,
      "unit": "%",
      "recorded_at": "2026-09-25T14:00:00.000Z",
      "notes": null,
      "created_at": "2026-09-25T14:00:31.000Z"
    }
  ]
}
```

The envelope can also carry `meta`:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `duplicate` | `true` | Yes | Sent only when one reading was sent and it repeated a reading already stored for the device under the same `external_id` and `recorded_at`. `data` is that stored reading, and nothing new was stored. Never sent for a batch. |

| Response header | Meaning |
| --- | --- |
| `X-Request-Id` | Identifies this request. Include it when you contact support. |

## Errors

| Status | Code | When |
| --- | --- | --- |
| `400` | `VALIDATION_ERROR` | The request body is not valid JSON. |
| `401` | `UNAUTHORIZED` | The key is missing, malformed or revoked, or its owner is no longer a member of the workspace. |
| `402` | `PAID_PLAN_REQUIRED` | The workspace has no paid plan. Connecting devices is included with every paid plan. |
| `403` | `FORBIDDEN` | The key lacks a scope this operation requires, or its owner's current role in the workspace cannot use it — a key never does more than its owner can in xPlant. `error` names the scope, and for a role, the role it needs. |
| `403` | `DEVICE_TOKEN_WRONG_DEVICE` | A device token was used to write about a device other than its own. |
| `404` | `NOT_FOUND` | A `device_id` or `room_id` in the request is not in the workspace. Nothing was stored. |
| `409` | `DEVICE_INGEST_DISABLED` | A device in the request is paused or retired, so its readings are refused. `error` names it; nothing was stored. |
| `422` | `VALIDATION_ERROR` | A reading failed validation. `error` names the first field, for example `readings.0.value: Expected number, received string`. |
| `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. |
| `500` | `READINGS_CREATE_FAILED` | The readings could not be stored. Retry the same request — readings that carry `external_id` are not stored twice. |

Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md).
