# Record a device event

> Records something that happened on a device — an alert, an error, a firmware update or a configuration change — with whatever detail you put in payload. Send it with the device's own token, which may only write about that device, or with a workspace key.

Source: https://docs.xplantpro.com/docs/api/devices/create-device-event

`POST https://app.xplantpro.com/api/v1/device-events`

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

To make a retry safe, send your own `external_id` for the event. An event that repeats one already stored for the same device, under the same `external_id`, is not recorded again: the answer is the stored event, still `201`, with `meta.duplicate: true`. Without `external_id` every request is recorded, so a request retried after a dropped response is recorded twice.

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `device_id` | string (uuid) | Yes | The device the event is about. |
| `event_type` | `"heartbeat"` \| `"alert"` \| `"firmware_update"` \| `"config_change"` \| `"error"` \| `"other"` | Yes | What kind of event this is. |
| `payload` | object | No | Any JSON object describing the event. Default `{}`. |
| `occurred_at` | string (date-time) | No | When it happened, 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. |
| `external_id` | string | No | Your own id for the event. An event with the same device and `external_id` as one already stored is not recorded again: the stored event is returned instead, with `meta.duplicate: true`, so a retried request records the event once. 1–200 characters. |

## Example

**curl**

```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": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
    "event_type": "alert",
    "payload": {
      "reason": "humidity_high",
      "humidity_percent": 94
    },
    "occurred_at": "2026-09-25T13:58:00.000Z",
    "external_id": "shelf3-alert-20260925T135800Z"
  }'
```

**JavaScript**

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

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

const event = await device.devices.recordEvent({
  device_id: "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
  event_type: "alert",
  payload: { reason: "humidity_high", humidity_percent: 94 },
  occurred_at: "2026-09-25T13:58:00.000Z",
  external_id: "shelf3-alert-20260925T135800Z",
});
```

**Python**

```python
import os
import requests

resp = requests.post(
    "https://app.xplantpro.com/api/v1/device-events",
    headers={"Authorization": f"Bearer {os.environ['XPLANT_DEVICE_TOKEN']}"},
    json={
        "device_id": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
        "event_type": "alert",
        "payload": {"reason": "humidity_high", "humidity_percent": 94},
        "occurred_at": "2026-09-25T13:58:00.000Z",
        "external_id": "shelf3-alert-20260925T135800Z",
    },
    timeout=10,
)
body = resp.json()
if not body["ok"]:
    raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}")
event = body["data"]
```

## Response

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | Yes | — |
| `device_id` | string (uuid) | Yes | — |
| `team_id` | string (uuid) | Yes | The id of the workspace the event belongs to. |
| `event_type` | string | Yes | One of `heartbeat`, `alert`, `firmware_update`, `config_change`, `error`, `other`. |
| `payload` | object | Yes | — |
| `occurred_at` | string | Yes | When it happened, as an ISO 8601 timestamp. |
| `created_at` | string | Yes | — |

```json title="Response"
{
  "ok": true,
  "data": {
    "id": "c7d2a9e4-1b6f-4c3a-8e5d-0f9b2a7c4e61",
    "device_id": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13",
    "team_id": "5d2e8b17-9c4a-4e3f-b6d0-1a7c9e2f4b83",
    "event_type": "alert",
    "payload": {
      "reason": "humidity_high",
      "humidity_percent": 94
    },
    "occurred_at": "2026-09-25T13:58:00.000Z",
    "created_at": "2026-09-25T13:58:01.000Z"
  }
}
```

The envelope can also carry `meta`:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `duplicate` | `true` | Yes | Sent only when the request repeated an event already stored for the device under the same `external_id`. `data` is that stored event, and nothing new was recorded. |

| 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` | `device_id` does not name a device in the workspace. |
| `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `event_type: Invalid enum value`. |
| `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. |
| `500` | `DEVICE_EVENT_CREATE_FAILED` | The event could not be saved. Retry the same request — an event that carries `external_id` is not recorded twice. |

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