# Overview > What the xPlant API is for, how it's shaped, and where to start. Source: https://docs.xplantpro.com/docs The xPlant API connects your lab's own systems to your [xPlant](https://www.xplantpro.com) workspace: schedulers and spreadsheets, bench stations and label scanners, sensors and gateways, and storefronts. Anything your integration can do, it does inside one workspace, with only the permissions you gave its key. ## The shape of every call - **One host.** Everything lives under `https://app.xplantpro.com/api/v1`. (`www.xplantpro.com` is the marketing site; API calls there are refused.) - **One header.** `Authorization: Bearer `. Workspace keys start with `xpk_`; device tokens start with `xpd_`. - **One envelope.** Success is `{ "ok": true, "data": … }`. Failure is `{ "ok": false, "data": null, "error": "…", "code": "STABLE_CODE" }`. Branch on `code`. - **Scopes you choose.** A key can do exactly what its scopes allow, and nothing else. There is no default set. - **Bounded by your plan and your role.** Teams and Enterprise include the whole API; Hobby and Pro Lab keys can connect devices only. A key never does more than its owner can in xPlant. See [Plans and access](https://docs.xplantpro.com/docs/authentication.md#plans-and-access). ## Start here - [Quickstart](https://docs.xplantpro.com/docs/quickstart.md): Create a key and make your first call in five minutes. - [Authentication and API keys](https://docs.xplantpro.com/docs/authentication.md): Keys, device tokens, and keeping them safe. - [Scopes](https://docs.xplantpro.com/docs/scopes.md): All 34 scopes and what each one unlocks. - [API reference](https://docs.xplantpro.com/docs/api.md): Every endpoint, generated from the OpenAPI spec. ## What you can build - [Sync tasks](https://docs.xplantpro.com/docs/guides/sync-tasks.md): Drive the bench queue from your own scheduler, without overriding people. - [Push demand signals](https://docs.xplantpro.com/docs/guides/demand-signals.md): Tell xPlant what's selling so the right work rises to the top. - [Record transfers and stages](https://docs.xplantpro.com/docs/guides/transfers-and-stages.md): Log subcultures and stage changes from a script or a scanner. - [Run an SOP from a bench station](https://docs.xplantpro.com/docs/guides/sop-runs.md): Start runs, confirm steps, and post measurements with units. - [Label scanning](https://docs.xplantpro.com/docs/guides/label-scanning.md): Resolve a QR or barcode to its record, and log the scan. - [Sensors and devices](https://docs.xplantpro.com/docs/guides/sensors-and-devices.md): Stream readings from a Pi or an ESP32 with a device token. - [Equipment events](https://docs.xplantpro.com/docs/guides/equipment-events.md): Let an autoclave or a pH meter report usage and calibration. - [Pull change history](https://docs.xplantpro.com/docs/guides/change-history.md): Mirror plant and explant edits into your own system. ## Help Email [support@xplantpro.com](mailto:support@xplantpro.com), or see [xPlant support](https://www.xplantpro.com/en/support). For what each plan includes, see [plans and pricing](https://www.xplantpro.com/en/subscriptions). For security issues, see the repository's [security policy](https://github.com/shmaplex/xplant_os/blob/main/SECURITY.md). --- # Quickstart > Create an API key and make your first three calls, in about five minutes. Source: https://docs.xplantpro.com/docs/quickstart You need an xPlant workspace on **xPlant+ Teams** or **Enterprise** ([compare plans](https://www.xplantpro.com/en/subscriptions)), which include the whole API (Hobby and Pro Lab keys can connect devices only), and a role of `member` or above to grant the scopes below. ## 1. Create a key 1. In xPlant, open **Settings → Integrations → [API Keys](https://app.xplantpro.com/settings/integrations/api-keys)**. 2. Create a key, give it a name you'll recognise later (for example "Quickstart"), and tick **Read workspace**, **Read plants** and **Read tasks**. 3. Copy the key. It starts with `xpk_live_` or `xpk_dev_`, and **it's shown only once**. Store it in an environment variable rather than in code: ```bash export XPLANT_API_KEY="xpk_live_…" ``` > **There is no sandbox** > > Keys labelled `xpk_dev_` and `xpk_live_` work identically and both act on your real workspace. Test writes against records you're happy to change. ## 2. Check the key `GET /me` tells you who the key is, which scopes it holds, and which it can use right now (`effectiveScopes`, after your role and plan). It needs no scope, so it's the safest first call. **curl** ```bash curl https://app.xplantpro.com/api/v1/me \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const me = await client.me.get(); console.log(me.key.name, me.scopes); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/me", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) print(resp.json()) ``` You should see: ```json { "ok": true, "data": { "key": { "name": "Quickstart", "prefix": "xpk_live_abcd", "status": "active" }, "scopes": ["read:workspace", "read:plants", "read:tasks"], "effectiveScopes": ["read:workspace", "read:plants", "read:tasks"], "role": "member", "apiAccess": "full", "workspace": { "id": "…" }, "user": { "id": "…" } } } ``` If you get `401 UNAUTHORIZED`, the key is wrong or has been revoked. If you get `402 PAID_PLAN_REQUIRED`, your workspace's plan doesn't include this call; see [Plans and access](https://docs.xplantpro.com/docs/authentication.md#plans-and-access). ## 3. Read some data List the first page of plants: **curl** ```bash curl "https://app.xplantpro.com/api/v1/plants?limit=5" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js const plants = await client.plants.list({ limit: 5 }); for (const plant of plants) console.log(plant.id, plant.name); ``` **Python** ```python resp = requests.get( "https://app.xplantpro.com/api/v1/plants", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"limit": 5}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{body['code']}: {body['error']}") for plant in body["data"]: print(plant["id"], plant["name"]) ``` Every response has the same shape: `ok`, `data`, and on failure `error` and `code`. Read [Requests and responses](https://docs.xplantpro.com/docs/requests-and-responses.md) once and you know how every endpoint behaves. ## 4. See a scope error on purpose Your key has no `write:tasks` scope, so creating a task fails, and the error tells you exactly what's missing: ```bash curl -X POST https://app.xplantpro.com/api/v1/tasks \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Check the Alocasia line"}' ``` ```json { "ok": false, "data": null, "error": "Missing scope: write:tasks", "code": "FORBIDDEN" } ``` Branch on `code` (`FORBIDDEN`), not on the message. A key's scopes are fixed when it's created, so when an integration needs another scope, create a key that has it, switch over, and revoke the old one. ## Next steps - [Scopes](https://docs.xplantpro.com/docs/scopes.md): Pick exactly the scopes your integration needs. - [Device tokens](https://docs.xplantpro.com/docs/device-tokens.md): Connecting a Pi or an ESP32? Give it a device token, not a key. - [Guides](https://docs.xplantpro.com/docs/guides.md): Worked examples for the common integrations. - [API reference](https://docs.xplantpro.com/docs/api.md): Every endpoint, parameter and error code. --- # Use with AI tools > Every page is available as plain Markdown, and the whole API fits in one file for an assistant to read. Source: https://docs.xplantpro.com/docs/ai-tools These docs are written to be read by people and by AI assistants alike. ## On every page At the top of each page: - **Copy page as Markdown** puts the page on your clipboard as clean Markdown, ready to paste into a chat. - **Open in ChatGPT** and **Open in Claude** start a new conversation that already points at the page's Markdown. - **View .md** opens the raw Markdown. Any page's Markdown lives at its address plus `.md`. For example, [/docs/quickstart](https://docs.xplantpro.com/docs/quickstart.md) is also at `/docs/quickstart.md`. ## The whole API at once | File | What it is | | --- | --- | | [/llms.txt](/llms.txt) | A map of the docs: every page with a one-line summary and a link to its Markdown. | | [/llms-full.txt](/llms-full.txt) | Every page, in order, in one Markdown file. Hand this to an assistant to give it the whole API. | | [/openapi.json](/openapi.json) | The OpenAPI 3 spec, for code generators and tools that read specs. | ## Tips - Tell the assistant which language and library you're using ("Python with requests", "the @shmaplex/xplant-sdk JavaScript SDK"). - Ask it to branch on `code`, not on the `error` message, and to send `Idempotency-Key` on writes that support it. - **Never paste an API key or device token into a chat.** Use a placeholder like `$XPLANT_API_KEY` and keep the real value in your environment. --- # Authentication and API keys > How to create a key, send it, keep it safe, and tell the two kinds of credential apart. Source: https://docs.xplantpro.com/docs/authentication Every request carries a credential as a bearer token: ```http Authorization: Bearer xpk_live_… ``` There are two kinds of credential, and they are not interchangeable. | | Workspace API key | Device token | | --- | --- | --- | | Prefix | `xpk_live_` or `xpk_dev_` | `xpd_live_` or `xpd_dev_` | | Can do | Whatever its [scopes](https://docs.xplantpro.com/docs/scopes.md) allow, across the workspace | Post readings, events and heartbeats for **one** device, nothing else | | Lives on | A server, a script, an integration you run | The device itself: a Raspberry Pi, a gateway, a microcontroller | | Created in | Settings → Integrations → API Keys | With the API, using a workspace key (see [Device tokens](https://docs.xplantpro.com/docs/device-tokens.md)) | ## Plans and access Three things bound what a key can do: **its scopes**, **its owner's role**, and **the workspace's plan**. A key never does more than its owner can in xPlant. ### By plan | Plan | What API keys can do | | --- | --- | | xPlant+ Teams, Enterprise | The whole API: every scope. | | Hobby, Pro Lab | Connect devices only: `read:devices`, `write:devices`, `write:sensor_readings` and `write:device_events`. Keys can register devices, manage their tokens, and post their readings and events. They can't read sensor readings back; that data is visible in xPlant. | | Free | No API access. Every request answers `402 PAID_PLAN_REQUIRED`. | A call outside what the plan includes answers `402 PAID_PLAN_REQUIRED`. See [plans](https://www.xplantpro.com/en/subscriptions) to upgrade. Your plan's allowances still apply too: registering devices beyond the plan's allowance answers `402 DEVICE_LIMIT_REACHED`. ### By role A key acts with its owner's role in the workspace, as it is at the time of each request. If the owner's role changes, the key follows. | Scopes | Minimum role of the key's owner | | --- | --- | | `read:` scopes, except the two below | Any active member | | `write:` scopes, except the one below | `member` | | `read:pricing`, `read:commerce`, `write:demand` | `manager` | Roles rank `owner` > `admin` > `manager` > `member` > `viewer` > `guest`. A call that needs more than the owner's role answers `403 FORBIDDEN`, and the message names the scope and the role it needs, for example: ```json { "ok": false, "data": null, "error": "This key's owner is a viewer in this workspace, which cannot use write:tasks (needs member or above). A key never does more than its owner can in xPlant.", "code": "FORBIDDEN" } ``` The wording can change; branch on `code`. As a rule of thumb, **`402` is about the plan, and `403` is about the role or a missing scope.** ### When you create a key You can only grant scopes that your role and your plan allow. A scope above your role is refused with `403`, and one above your plan with `402`, and both messages name the scopes involved. ### Check before you call [`GET /me`](https://docs.xplantpro.com/docs/api/account/get-me.md) returns the key's `scopes` (what it was created with), `effectiveScopes` (what it can use right now, after the role and plan limits), the owner's `role`, and `apiAccess`: `full` on Teams and Enterprise, `devices` on plans whose keys can connect devices only. ### Devices after a plan lapses Device tokens keep posting readings, events and heartbeats after a plan lapses, so sensors don't go quiet. New devices and new tokens follow the plan's device allowance. Enterprise customers can scope organisation-specific integrations and API requirements with us: [support@xplantpro.com](mailto:support@xplantpro.com). ## Creating a key 1. Open **Settings → Integrations → [API Keys](https://app.xplantpro.com/settings/integrations/api-keys)** in xPlant. 2. Name the key after what will use it ("Nightly task sync", "Grow room dashboard"). You'll thank yourself when you need to revoke one. 3. Choose its scopes. A key gets exactly what you tick, and nothing by default. See [Scopes](https://docs.xplantpro.com/docs/scopes.md) for what each one allows and some common setups. 4. Copy the key straight away. **It is shown once.** If you lose it, create a new one and revoke the old. **Scopes are fixed when a key is created.** To change what an integration can do, create a new key with the scopes it needs, move the integration to it, then revoke the old key. Rotating a key in xPlant issues a new secret with the same name, scopes and environment. A workspace can have up to 10 active keys at a time. ## Live and dev keys The `xpk_live_` and `xpk_dev_` prefixes are labels for your own bookkeeping. **Both act on your real workspace, with the same data and the same limits. There is no sandbox.** Use a dev key to keep test traffic separate in your records, not to protect production data. ## What a key can reach - **One workspace.** A key belongs to the workspace it was created in and can never read or write another workspace's records. If you belong to several labs, each needs its own key. - **Its scopes, and nothing more.** No scope implies another; `write:tasks` does not grant `read:tasks`. - **A record in another workspace looks the same as one that doesn't exist.** Both answer `404 NOT_FOUND`, so a key can't be used to probe for ids. Call [`GET /me`](https://docs.xplantpro.com/docs/api/account/get-me.md) to see which key you're using and every scope it holds. It needs no scope. ## When a key stops working | You get | Because | | --- | --- | | `401 UNAUTHORIZED` | The key is missing, mistyped or revoked, or the person who created it has left the workspace. | | `402 PAID_PLAN_REQUIRED` | The workspace's plan doesn't include this call. Teams and Enterprise include the whole API; Hobby and Pro Lab keys can connect devices only; Free has no API access. See [Plans and access](#plans-and-access). | | `403 FORBIDDEN` | The key lacks the scope this endpoint needs, or its owner's role is below what the scope needs. The `error` text says which, e.g. `Missing scope: write:tasks`. | | `403 DEVICE_TOKEN_NOT_ACCEPTED` | You sent a device token to an endpoint that needs a workspace key. | A key is tied to the member who created it. When someone leaves the workspace, their keys stop working, so create keys for long-running integrations from an account that will stay. ## Keeping keys safe - Keep keys in environment variables or a secrets manager, never in source code or a repository. - **Never put a workspace key on a device.** A box on a shelf, in a room other people walk through, should carry a [device token](https://docs.xplantpro.com/docs/device-tokens.md) that can only post its own readings. - Never embed a key in a web page or a mobile app. Anyone who opens it can read it. Call the API from your own backend instead. - Give each integration its own key with only the scopes it needs, so you can revoke one without breaking the others. - If a key leaks, revoke it in **Settings → Integrations → API Keys** at once, then create a replacement. --- # Scopes > What each of the 34 scopes allows, and the endpoints it unlocks. Source: https://docs.xplantpro.com/docs/scopes A key carries exactly the scopes you choose when you create it in [Settings → Integrations → API Keys](https://app.xplantpro.com/settings/integrations/api-keys). There is no default set, and no scope implies another: `write:tasks` does not grant `read:tasks`. **Scopes are fixed when a key is created.** To change what an integration can do, create a new key with the scopes it needs, move the integration to it, then revoke the old key. **A key never does more than its owner can in xPlant.** Each scope also needs a minimum role from the key's owner and a plan that includes it (both shown below). Above the owner's role, a call answers `403 FORBIDDEN`; outside the plan, `402 PAID_PLAN_REQUIRED`. [`GET /me`](https://docs.xplantpro.com/docs/api/account/get-me.md) lists the key's `effectiveScopes`: what it can use right now. See [Plans and access](https://docs.xplantpro.com/docs/authentication.md#plans-and-access). Scope names follow `:`. Grant each integration only what it calls. A request without the scope it needs gets `403 FORBIDDEN`, and the message names the missing scope, for example `Missing scope: write:tasks`. To see what a key holds before you call anything, use [`GET /me`](https://docs.xplantpro.com/docs/api/account/get-me.md); it needs no scope. Device tokens (`xpd_`) don't carry scopes. They can only write readings, heartbeats and events for their own device. See [Device tokens](https://docs.xplantpro.com/docs/device-tokens.md). ## Common setups | Integration | Scopes | | --- | --- | | Read-only dashboard | `read:workspace`, `read:plants`, `read:explants`, `read:sensor_readings` | | Task sync from your scheduler | `read:tasks`, `write:tasks`, `write:demand` | | Bench station running SOPs | `read:sops`, `write:sop_runs`, `read:sop_runs`, `write:sop_steps`, `read:labels`, `write:label_scans` | | Transfer and stage logging | `read:plants`, `read:explants`, `read:transfers`, `write:transfers` | | Device provisioning (run once, off the device) | `read:devices`, `write:devices` | | Mirror change history into your warehouse | `read:events`, `read:plants`, `read:explants` | ## All scopes ### Workspace | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:workspace` | Read workspace and lab settings | Any member | Teams, Enterprise | [`GET /workspaces`](https://docs.xplantpro.com/docs/api/account/list-workspaces.md) | ### Plants | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:plants` | Read plant records | Any member | Teams, Enterprise | [`GET /plants`](https://docs.xplantpro.com/docs/api/plants/list-plants.md)
[`GET /plants/{id}`](https://docs.xplantpro.com/docs/api/plants/get-plant.md) | | `write:plants` | Create and update plant records | `member` | Teams, Enterprise | [`POST /plants`](https://docs.xplantpro.com/docs/api/plants/create-plant.md)
[`PATCH /plants/{id}`](https://docs.xplantpro.com/docs/api/plants/update-plant.md) | ### Explants | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:explants` | Read explant records | Any member | Teams, Enterprise | [`GET /explants`](https://docs.xplantpro.com/docs/api/explants/list-explants.md)
[`GET /explants/{id}`](https://docs.xplantpro.com/docs/api/explants/get-explant.md) | | `write:explants` | Create and update explant records | `member` | Teams, Enterprise | [`POST /explants`](https://docs.xplantpro.com/docs/api/explants/create-explant.md)
[`PATCH /explants/{id}`](https://docs.xplantpro.com/docs/api/explants/update-explant.md) | ### Contaminations | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:contaminations` | Read contamination logs | Any member | Teams, Enterprise | [`GET /contaminations`](https://docs.xplantpro.com/docs/api/contaminations/list-contaminations.md)
[`GET /contaminations/{id}`](https://docs.xplantpro.com/docs/api/contaminations/get-contamination.md) | | `write:contaminations` | Submit contamination observations | `member` | Teams, Enterprise | [`POST /contaminations`](https://docs.xplantpro.com/docs/api/contaminations/create-contamination.md) | ### Tasks | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:tasks` | Read scheduled tasks and due work | Any member | Teams, Enterprise | [`GET /tasks`](https://docs.xplantpro.com/docs/api/tasks/list-tasks.md)
[`GET /tasks/{id}`](https://docs.xplantpro.com/docs/api/tasks/get-task.md)
[`GET /tasks/demand`](https://docs.xplantpro.com/docs/api/tasks/list-demand-signals.md) | | `write:tasks` | Create, update, complete, and reopen tasks | `member` | Teams, Enterprise | [`POST /tasks`](https://docs.xplantpro.com/docs/api/tasks/create-task.md)
[`PATCH /tasks/{id}`](https://docs.xplantpro.com/docs/api/tasks/update-task.md) | | `write:demand` | Push sales/order demand numbers per genus for task prioritization | `manager` | Teams, Enterprise | [`POST /tasks/demand`](https://docs.xplantpro.com/docs/api/tasks/create-demand-signal.md) | ### Comments | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:comments` | Read notes and comments on lab records | Any member | Teams, Enterprise | [`GET /comments`](https://docs.xplantpro.com/docs/api/comments/list-comments.md) | | `write:comments` | Add notes and comments to lab records | `member` | Teams, Enterprise | [`POST /comments`](https://docs.xplantpro.com/docs/api/comments/create-comment.md) | ### Media | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:assets` | Read photos and media attached to lab records | Any member | Teams, Enterprise | [`GET /assets`](https://docs.xplantpro.com/docs/api/media/list-assets.md)
[`GET /assets/{id}`](https://docs.xplantpro.com/docs/api/media/get-asset.md) | | `write:assets` | Attach photos and media to lab records | `member` | Teams, Enterprise | [`POST /assets`](https://docs.xplantpro.com/docs/api/media/create-asset.md) | | `read:media_recipes` | Read media recipe data | Any member | Teams, Enterprise | [`GET /media-recipes`](https://docs.xplantpro.com/docs/api/media/list-media-recipes.md)
[`GET /media-recipes/{id}`](https://docs.xplantpro.com/docs/api/media/get-media-recipe.md) | | `write:media_recipes` | Create and update media recipes | `member` | Teams, Enterprise | [`POST /media-recipes`](https://docs.xplantpro.com/docs/api/media/create-media-recipe.md)
[`PATCH /media-recipes/{id}`](https://docs.xplantpro.com/docs/api/media/update-media-recipe.md) | ### Transfers | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:transfers` | Read transfer and stage history for plants and explants | Any member | Teams, Enterprise | [`GET /stages`](https://docs.xplantpro.com/docs/api/transfers-and-stages/list-stages.md)
[`GET /transfers`](https://docs.xplantpro.com/docs/api/transfers-and-stages/list-transfers.md) | | `write:transfers` | Record transfers and advance the stage of plants and explants | `member` | Teams, Enterprise | [`POST /stages`](https://docs.xplantpro.com/docs/api/transfers-and-stages/create-stage.md)
[`POST /transfers`](https://docs.xplantpro.com/docs/api/transfers-and-stages/create-transfer.md) | ### SOPs | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:sops` | Read SOP templates and steps | Any member | Teams, Enterprise | [`GET /sops`](https://docs.xplantpro.com/docs/api/sops/list-sops.md)
[`GET /sops/{id}`](https://docs.xplantpro.com/docs/api/sops/get-sop.md) | | `read:sop_runs` | Read SOP execution history: who ran which step, when | Any member | Teams, Enterprise | [`GET /sop-runs/{id}`](https://docs.xplantpro.com/docs/api/sops/get-sop-run.md) | | `write:sop_runs` | Create and advance SOP run sessions | `member` | Teams, Enterprise | [`POST /sop-runs`](https://docs.xplantpro.com/docs/api/sops/create-sop-run.md) | | `write:sop_steps` | Post confirmations, scans and measurements against a step of an SOP run | `member` | Teams, Enterprise | [`POST /sop-runs/{id}/steps/{stepId}/events`](https://docs.xplantpro.com/docs/api/sops/create-sop-step-event.md)
[`POST /sop-runs/{id}/steps/{stepId}/measurements`](https://docs.xplantpro.com/docs/api/sops/create-sop-step-measurement.md) | ### Labels | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:labels` | Resolve QR/barcode label codes to entity records | Any member | Teams, Enterprise | [`GET /labels/resolve`](https://docs.xplantpro.com/docs/api/labels/resolve-label.md) | | `write:label_scans` | Submit label scan events | `member` | Teams, Enterprise | [`POST /label-scans`](https://docs.xplantpro.com/docs/api/labels/create-label-scan.md) | ### Devices | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:devices` | List registered devices and their status | Any member | All paid plans | [`GET /devices`](https://docs.xplantpro.com/docs/api/devices/list-devices.md)
[`GET /devices/{deviceId}/tokens`](https://docs.xplantpro.com/docs/api/devices/list-device-tokens.md) | | `write:devices` | Register devices, send heartbeats, and manage device tokens | `member` | All paid plans | [`POST /devices`](https://docs.xplantpro.com/docs/api/devices/create-device.md)
[`POST /devices/{deviceId}/heartbeat`](https://docs.xplantpro.com/docs/api/devices/send-heartbeat.md)
[`POST /devices/{deviceId}/tokens`](https://docs.xplantpro.com/docs/api/devices/create-device-token.md)
[`DELETE /devices/{deviceId}/tokens/{tokenId}`](https://docs.xplantpro.com/docs/api/devices/revoke-device-token.md) | | `read:sensor_readings` | Query historical sensor readings | Any member | Teams, Enterprise | [`GET /sensor-readings`](https://docs.xplantpro.com/docs/api/sensor-readings/list-sensor-readings.md) | | `write:sensor_readings` | Submit environmental sensor readings (temperature, humidity, etc.) | `member` | All paid plans | [`POST /sensor-readings`](https://docs.xplantpro.com/docs/api/sensor-readings/create-sensor-readings.md) | | `write:device_events` | Submit device status events | `member` | All paid plans | [`POST /device-events`](https://docs.xplantpro.com/docs/api/devices/create-device-event.md) | ### Equipment | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:equipment` | Read the lab equipment library and its maintenance history | Any member | Teams, Enterprise | [`GET /equipment`](https://docs.xplantpro.com/docs/api/equipment/list-equipment.md)
[`GET /equipment/{id}`](https://docs.xplantpro.com/docs/api/equipment/get-equipment.md)
[`GET /equipment/{id}/events`](https://docs.xplantpro.com/docs/api/equipment/list-equipment-events.md) | | `write:equipment_events` | Record that a piece of equipment was used, calibrated, serviced or faulted | `member` | Teams, Enterprise | [`POST /equipment/{id}/events`](https://docs.xplantpro.com/docs/api/equipment/create-equipment-event.md) | ### Commercial | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:pricing` | Read culture line prices, pricing tiers, and price history | `manager` | Teams, Enterprise | [`GET /pricing/culture-lines`](https://docs.xplantpro.com/docs/api/commerce/list-culture-line-prices.md)
[`GET /pricing/events`](https://docs.xplantpro.com/docs/api/commerce/list-price-events.md) | | `read:commerce` | Read store order lines and sell-through summaries | `manager` | Teams, Enterprise | [`GET /commerce/order-lines`](https://docs.xplantpro.com/docs/api/commerce/list-order-lines.md)
[`GET /commerce/sell-through`](https://docs.xplantpro.com/docs/api/commerce/get-sell-through.md) | ### History | Scope | Allows | Minimum role | Plans | Endpoints | | --- | --- | --- | --- | --- | | `read:events` | Read plant and explant edit history (audit events) | Any member | Teams, Enterprise | [`GET /events`](https://docs.xplantpro.com/docs/api/change-history/list-change-events.md) | ### No scope needed - [`GET /me`](https://docs.xplantpro.com/docs/api/account/get-me.md): Get the calling key --- # Device tokens > Give each device an xpd_ token that can post its own readings and nothing else. Never put a workspace key on a device. Source: https://docs.xplantpro.com/docs/device-tokens A device token (`xpd_…`) is a credential for **one registered device**. It can post that device's sensor readings, events and heartbeats, and it is refused everywhere else. A workspace key (`xpk_…`) can read your plants, explants and tasks; a device token can't read anything. > **Never put a workspace key on a device** > > A Raspberry Pi on a bench or an ESP32 in a grow room can be unplugged and carried off, or its SD card copied. Whatever credential it holds should be worth as little as possible to whoever gets it. ## What a device token can do A device token is accepted on exactly these endpoints, and only for its own device: - [`POST /device-events`](https://docs.xplantpro.com/docs/api/devices/create-device-event.md): Record a device event - [`POST /devices/{deviceId}/heartbeat`](https://docs.xplantpro.com/docs/api/devices/send-heartbeat.md): Send a heartbeat - [`POST /sensor-readings`](https://docs.xplantpro.com/docs/api/sensor-readings/create-sensor-readings.md): Submit sensor readings Anything else answers `403 DEVICE_TOKEN_NOT_ACCEPTED`. Writing about a different device (another `device_id`, even one entry inside a batch) answers `403 DEVICE_TOKEN_WRONG_DEVICE` and rejects the whole request. Device tokens don't carry scopes; what they can do is fixed. Each token is rate limited like a key (1,000 requests per minute), and its traffic also counts toward your workspace's shared limit. See [Rate limits](https://docs.xplantpro.com/docs/rate-limits.md). ## Setting up a device Do this once, from a machine you control, with a workspace key that has `write:devices` (and `read:devices` if you want to list tokens later). ### 1. Register the device **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/devices \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Grow room Pi", "type": "gateway", "hardware": "Raspberry Pi 4B"}' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const device = await client.devices.register({ name: "Grow room Pi", type: "gateway", hardware: "Raspberry Pi 4B", }); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/devices", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={"name": "Grow room Pi", "type": "gateway", "hardware": "Raspberry Pi 4B"}, timeout=10, ) device = resp.json()["data"] ``` Keep the device's `id`. ### 2. Create its token **curl** ```bash curl -X POST "https://app.xplantpro.com/api/v1/devices/$DEVICE_ID/tokens" \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Grow room Pi"}' ``` **JavaScript** ```js const { token } = await client.devices.createToken(device.id, { name: "Grow room Pi" }); ``` **Python** ```python resp = requests.post( f"https://app.xplantpro.com/api/v1/devices/{device['id']}/tokens", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={"name": "Grow room Pi"}, timeout=10, ) token = resp.json()["data"]["token"] ``` ```json { "ok": true, "data": { "token": "xpd_live_…", "prefix": "xpd_live_9f3a" } } ``` **The token is returned once and never again.** Put it on the device now (in its config file or secrets store, not in code you commit). Listing a device's tokens later shows prefixes, status and last use, but never the secret. ### 3. Use it on the device **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 '{"device_id": "'"$DEVICE_ID"'", "type": "temperature", "value": 23.4, "unit": "C"}' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; // The SDK refuses a deviceToken that doesn't start with xpd_. const device = new XPlantClient({ deviceToken: process.env.XPLANT_DEVICE_TOKEN }); await device.sensorReadings.create({ device_id: process.env.XPLANT_DEVICE_ID, type: "temperature", value: 23.4, unit: "C", }); await device.devices.heartbeat(process.env.XPLANT_DEVICE_ID); ``` **Python** ```python resp = requests.post( "https://app.xplantpro.com/api/v1/sensor-readings", headers={"Authorization": f"Bearer {os.environ['XPLANT_DEVICE_TOKEN']}"}, json={"device_id": os.environ["XPLANT_DEVICE_ID"], "type": "temperature", "value": 23.4, "unit": "C"}, timeout=10, ) ``` ## One device or many? A gateway that reads several probes is usually **one device** posting several reading types (`temperature`, `humidity`, `co2`…) under its own `device_id`, with one token. That's the recommended setup. If you want each probe to appear as its own device in xPlant (probes in different rooms, for example), register each probe and create a token per probe. The gateway then holds one token per device and uses the matching one for each probe's readings. ## Losing or retiring a token - **Lost or leaked a token?** Create a new one for the same device, switch the device over, then [revoke the old one](https://docs.xplantpro.com/docs/api/devices/revoke-device-token.md). A revoked token is refused from its next request. [List the device's tokens](https://docs.xplantpro.com/docs/api/devices/list-device-tokens.md) to find its id by prefix and last use. - **Taking a device out of service** revokes all of its tokens in the same step, and so does deleting it. There's nothing extra to remember. --- # Requests and responses > The base URL, headers, and the one response envelope every endpoint uses. Source: https://docs.xplantpro.com/docs/requests-and-responses ## Base URL ```text https://app.xplantpro.com/api/v1 ``` Always call `app.xplantpro.com`. `www.xplantpro.com` is the marketing site and answers `401` to every `/api` path. ## Headers | Header | When | Value | | --- | --- | --- | | `Authorization` | Every request | `Bearer ` | | `Content-Type` | Requests with a body | `application/json` | | `Idempotency-Key` | Optional, on [writes that support it](https://docs.xplantpro.com/docs/idempotency.md) | A string you choose per logical write | Every response carries an `X-Request-Id` header that identifies the request. Log it, and include it when you contact [support](mailto:support@xplantpro.com): it lets us find exactly that call. Request bodies and responses are JSON. Timestamps are ISO 8601 in UTC, for example `2026-09-25T12:00:00Z`. Ids are UUIDs. ## The envelope Every response, from every endpoint, has the same outer shape. **Success** ```json { "ok": true, "data": { "id": "5b0f6f3e-…", "title": "Transfer the Alocasia line onto fresh medium" } } ``` `data` holds the result: an object for a single record, an array for a list. Some endpoints add a `meta` object alongside `data` to explain something about the result; [Update a task](https://docs.xplantpro.com/docs/api/tasks/update-task.md) is one. **Failure** ```json { "ok": false, "data": null, "error": "Missing scope: write:tasks", "code": "FORBIDDEN" } ``` - `code` is stable. Write your error handling against it. - `error` is written for people. Log it and show it, but don't match on it: the wording can change. - The HTTP status matches the kind of failure (`401`, `403`, `404`, `422`, `429`…). See [Errors](https://docs.xplantpro.com/docs/errors.md) for every code. ## Reading responses safely **JavaScript** ```js import { XPlantClient, XPlantError } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); try { const tasks = await client.tasks.list({ status: "todo" }); } catch (err) { if (err instanceof XPlantError) { // err.status, err.code, err.message, err.retryAfter, err.requestId if (err.code === "RATE_LIMIT_EXCEEDED") { // wait err.retryAfter seconds, then try again } } throw err; } ``` **Python** ```python resp = requests.get(url, headers=headers, timeout=10) body = resp.json() if not body["ok"]: if body["code"] == "RATE_LIMIT_EXCEEDED": time.sleep(int(resp.headers.get("Retry-After", "5"))) raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']} (request {resp.headers.get('X-Request-Id')})") data = body["data"] ``` The SDK throws an `XPlantError` for every API failure, carrying `status`, `code`, `message` and `retryAfter`. Network failures (no response at all) reject with the underlying fetch error instead. ## Workspace scoping Every request acts inside the workspace the key belongs to. There is no workspace id to pass and no way to reach another workspace. An id from another workspace answers `404`, exactly as if it didn't exist. --- # Errors > Every error code the API returns, what it means, and what your integration should do about it. Source: https://docs.xplantpro.com/docs/errors A failed request answers with a non-2xx status and this body: ```json { "ok": false, "data": null, "error": "Missing scope: write:tasks", "code": "FORBIDDEN" } ``` **Branch on `code`, never on `error`.** Codes are stable; the `error` text is written for people and may be reworded. Several different failures can share a status (a `403` can be a missing scope, the owner's role, or the wrong kind of credential), so the status alone isn't enough either. As a rule of thumb, `402` is about the plan and `403` is about the role or a missing scope. ## Error codes | Status | Code | Meaning | | --- | --- | --- | | `400` | `VALIDATION_ERROR` | The request body is not valid JSON. Or: `barcode` is missing or blank. | | `401` | `UNAUTHORIZED` | The key is missing, malformed or revoked, or its owner is no longer a member of the workspace. | | `402` | `DEVICE_LIMIT_REACHED` | The workspace has connected every device its plan includes — retire a device it no longer uses, or contact support to raise the limit — or its plan includes no connected devices at all. `error` says which. Nothing was registered. | | `402` | `FEATURE_NOT_INCLUDED` | Culture line pricing, which includes sell-through, is not included in your plan. Or: Equipment calibration and maintenance records are not included in your plan. Or: A calibration or maintenance record was sent, and equipment calibration is not included in your plan. Or: Culture line pricing is not included in your plan. | | `402` | `PAID_PLAN_REQUIRED` | The workspace's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. Or: The workspace has no paid plan. Connecting devices is included with every paid plan. | | `402` | `PLAN_LIMIT_REACHED` | The key's owner has created as many explants as their plan allows. Or: The key's owner has created as many plants as their plan allows. `error` says which limit. | | `403` | `DEVICE_TOKEN_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `403` | `DEVICE_TOKEN_WRONG_DEVICE` | A device token was used to write about a device other than its own. | | `403` | `EXPLANT_WRITE_FORBIDDEN` | The explant was created by someone else, and the key's owner is not a manager or owner of the lab. | | `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. Or: The key's owner is below the member role in the workspace. A key never does more than its owner can in xPlant; `error` names the role needed. | | `403` | `MEDIA_RECIPE_NOT_OWNER` | The key's owner can see the recipe but did not create it. Only a recipe's creator can change it. | | `403` | `PLANT_WRITE_FORBIDDEN` | The plant was created by someone else, and the key's owner is not a manager or owner of the lab. | | `404` | `NOT_FOUND` | `target_id` does not name a record of that kind in the key's workspace. Or: No file with this id is attached to a record in the key's workspace. Or: `entity_id` names no record of that `entity_type` in the key's workspace. Or: `entity_id` names no record of that `entity_type` in the workspace, or `parent_id` names no comment on it; `error` says which. Or: `product_link_id` names no store product in the key's workspace. Or: `plant_id` names no culture line in the key's workspace. Or: `plant_id` or `explant_id` names no plant or explant in the key's workspace; `error` says which. Or: No record with this id exists in the key's workspace. Or: `device_id` does not name a device in the workspace. Or: `room_id` does not name a growing room in the workspace. Nothing was registered. Or: No device with this id is registered in the key's workspace. Or: No token with this id belongs to this device in the key's workspace. Or: The equipment, or a use's `subject_id`, is not in the key's workspace. Or: The plant or explant named in the scan is not in this workspace. Or: Nothing in this workspace matches the code. Or: No recipe with this id is visible to the key's owner in the workspace: it does not exist there, or it is another member's private recipe. Or: A `device_id` or `room_id` in the request is not in the workspace. Nothing was stored. Or: The SOP, or the plant named in `plant_id`, is not in this workspace. Or: No run with this id exists in the key's workspace, or the run id is not a well-formed id. Or: No SOP with this id exists in the key's workspace, or `id` is not a well-formed id. Or: The plant or explant is not in this workspace. Or: The plant or explant, or the growing room named in `room_id`, is not in this workspace. Nothing was changed. Or: No task with this id exists in the key's workspace, or `id` is not a well-formed id. | | `409` | `CONFLICT` | The task's discussion is locked. Only the task's creator or a lab manager can comment. | | `409` | `DEVICE_INGEST_DISABLED` | A device in the request is paused or retired, so its readings are refused. `error` names it; nothing was stored. | | `409` | `DUPLICATE_ENTRY` | Another explant in the workspace already uses this `external_id`, including one that has been deleted. Or: Another plant in the workspace already uses this `external_id`, including one that has been deleted. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `409` | `SOP_RUN_CLOSED` | The run is complete. A completed run's record is final and takes no more evidence. | | `409` | `SOP_RUN_NOT_EFFECTIVE` | The SOP has no version in force, so there is nothing approved to run. Put a version into force in xPlant, then start the run. | | `413` | `PAYLOAD_TOO_LARGE` | The image is larger than 10 MB. | | `415` | `UNSUPPORTED_MEDIA_TYPE` | The file is not a JPEG, PNG, WebP or GIF image. The type is read from the file itself. | | `422` | `IMAGE_URL_FETCH_FAILED` | `image_url` could not be downloaded: it redirected, answered with an error status, took longer than 10 seconds, or its host could not be found. `error` says which. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. Or: `target` or `target_id` is missing or not valid. Or: The `Idempotency-Key` header is malformed. Or: A field failed validation. `error` names the first one, for example `title: title is required`. Or: `target_id` does not name a record of that kind in the key's workspace. Or: `image_url` is not an `https` address on the public internet. Or: `entity_type` or `entity_id` is missing or malformed. Or: A mentioned user is not an active member of the workspace, or a linked record is not in it. Or: A query parameter is malformed, or `from` is later than `to`. `error` names it. Or: A filter is malformed; `error` names it. Or: `custom_fields` names a field your lab has not defined, or gives a field a value of the wrong kind. `error` names the field. Or: A field failed validation. `error` names the first one, for example `event_type: Invalid enum value`. Or: A field failed validation. `error` names the first one, for example `name: String must contain at least 1 character(s)`. Or: A field failed validation, such as a `name` longer than 120 characters. `error` names it. Or: A query parameter is malformed, `from` is later than `to`, or `offset` was sent without `kind`. `error` names the parameter. Or: `entity` is missing or is not `plant` or `explant`, or `since` is not a valid timestamp. Or: `plant_id` is not a plant in the workspace. Or: `custom_fields` names a field the lab has not set up, gives a field a value that does not fit it, or totals more than 10,000 bytes. `error` names the field. Or: A field failed validation, for example a missing `barcode` or a `scanned_at` that is not a UTC timestamp. `error` gives the first problem but does not name the field. Or: `status` is not one of the recipe statuses. Or: A query parameter is malformed. `error` names it. Or: A query parameter is malformed: `device_id` or `room_id` is not a UUID, `since` or `until` is not a timestamp, `since` is later than `until`, or `limit` is not a whole number. `error` names the parameter, for example `device_id: must be a UUID`. Or: A reading failed validation. `error` names the first field, for example `readings.0.value: Expected number, received string`. Or: A field failed validation, for example `sop_id` is missing or is not an id. `error` gives the first problem but does not name the field. Or: A field failed validation — an `event_type` outside the list, or a `recorded_at` that is not a UTC timestamp. `error` gives the first problem but does not name the field. Or: A field failed validation — a missing `unit`, a `value` that is not a number, or a `recorded_at` that is not a UTC timestamp. `error` gives the first problem but does not name the field. Or: Neither or both of `plant_id` and `explant_id` were sent. Or: A field failed validation, or neither or both of `plant_id` and `explant_id` were sent. `error` names the first problem, for example `stage: stage is required`. Or: A field failed validation, or neither or both of `plant_id` and `explant_id` were sent. `error` names the first problem, for example `transfer_cycle: Number must be greater than or equal to 1`. Or: `custom_fields` names a field your lab has not defined, gives a field a value of the wrong kind, or was sent for a plant transfer. `error` names the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `5xx` | `…_FAILED` | Something failed on our side; each endpoint names its own code. Retry later; don't branch on the specific code. | ## What to do about each | Code | Retry? | What to do | | --- | --- | --- | | `VALIDATION_ERROR` | No | Fix the request. The `error` text names the first field that failed, e.g. `title: title is required`. | | `UNAUTHORIZED` | No | Check the key. It may be mistyped or revoked, or its creator may have left the workspace. | | `PAID_PLAN_REQUIRED` | No | The workspace's plan doesn't include this call. Teams and Enterprise include the whole API; Hobby and Pro Lab keys can connect devices only; Free has no API access. See [Plans and access](https://docs.xplantpro.com/docs/authentication.md#plans-and-access). | | `DEVICE_LIMIT_REACHED` | No | Registering this device would go past your plan's device allowance. Retire a device you no longer use, or see [plans](https://www.xplantpro.com/en/subscriptions). | | `FORBIDDEN` | No | Either the key lacks the scope named in `error` (use a key that has it), or the owner's role is below what the scope needs (the message names the role; see [Plans and access](https://docs.xplantpro.com/docs/authentication.md#plans-and-access)). | | `DEVICE_TOKEN_NOT_ACCEPTED` | No | This endpoint needs a workspace key. Device tokens only write readings, events and heartbeats. | | `DEVICE_TOKEN_WRONG_DEVICE` | No | The token belongs to a different device than the `device_id` you sent. | | `NOT_FOUND` | No | The id doesn't exist in this workspace (or belongs to another one; the two look the same on purpose). | | `IDEMPOTENCY_IN_FLIGHT` | Yes | The first request with this `Idempotency-Key` is still running. Wait `Retry-After` seconds (1) and send the same request again. | | `RATE_LIMIT_EXCEEDED` | Yes | Wait the `Retry-After` seconds, then continue. See [Rate limits](https://docs.xplantpro.com/docs/rate-limits.md). | | `…_FAILED` (5xx) | Yes, later | Something went wrong on our side. Retry with backoff; for a write, reuse the same `Idempotency-Key`. Don't branch on the specific code. | Each endpoint's page lists the errors it can return. When you contact support about a failed call, include its `X-Request-Id` response header (`err.requestId` in the SDK). It identifies exactly that request. ## Retrying well - Retry only `429`, `409 IDEMPOTENCY_IN_FLIGHT`, `5xx` and network failures. Everything else fails the same way the second time. - Honour `Retry-After`: it's a whole number of seconds. - Back off exponentially on `5xx` and network errors, and cap the number of attempts. - Before retrying a write, make sure a repeat is harmless: send an [`Idempotency-Key`](https://docs.xplantpro.com/docs/idempotency.md), or for sensor readings an `external_id` with `recorded_at`. The JavaScript SDK does all of this when you pass `retry: true` to the client. --- # Rate limits > 1,000 requests a minute per key, 3,000 per workspace, and what happens when you go over. Source: https://docs.xplantpro.com/docs/rate-limits Every `/api/v1` request counts against two budgets, per minute: | Budget | Limit | | --- | --- | | Per API key or device token | 1,000 requests per minute | | Per workspace, across all its keys and tokens | 3,000 requests per minute | Going over either answers: ```http HTTP/1.1 429 Too Many Requests Retry-After: 12 { "ok": false, "data": null, "error": "…", "code": "RATE_LIMIT_EXCEEDED" } ``` `Retry-After` is the number of whole seconds until the window resets. Wait that long, then carry on. Retrying immediately just spends more of the budget. The `error` text says which budget ran out, so one runaway integration is easy to tell apart from a busy lab. These are ceilings to stop abuse and runaway loops, set well above normal use. Polling fifteen endpoints every five seconds is 180 requests a minute. ## Sensor readings have their own budget `POST /sensor-readings` also counts **readings**, separately from requests, per five minutes: | Budget | Limit | | --- | --- | | Per API key or device token | 5,000 readings per 5 minutes | | Per workspace | 10,000 readings per 5 minutes | One request can carry up to 500 readings, so a batching gateway can reach the readings budget while barely touching the request one. Both answer the same `429 RATE_LIMIT_EXCEEDED` with `Retry-After`. ## Staying well inside - **Batch sensor readings.** Buffer locally and post every 30–60 seconds, or whenever 500 are queued. One request per reading uses your budget hundreds of times faster for the same data. See [Sensors and devices](https://docs.xplantpro.com/docs/guides/sensors-and-devices.md). - **Sync incrementally.** Use `since` on [change history](https://docs.xplantpro.com/docs/guides/change-history.md) rather than re-reading everything. - **Page with the maximum `limit`** (200) when you really do need everything. - **Give each integration its own key**, so one busy job can't starve the others of their per-key budget. The only rate-limit header is `Retry-After`, sent with a `429` (and with `409 IDEMPOTENCY_IN_FLIGHT`). There are no remaining-quota headers. --- # Idempotency > Send an Idempotency-Key on a write so retrying it after a timeout can't create a duplicate. Source: https://docs.xplantpro.com/docs/idempotency A network can fail after the server has done the work but before you get the answer. Retrying then risks doing the work twice: two tasks, two scans, two runs started. An `Idempotency-Key` header makes the retry safe. ```bash curl -X POST https://app.xplantpro.com/api/v1/tasks \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: bench-3-0f2a7c91" \ -H "Content-Type: application/json" \ -d '{"title": "Check jar 47"}' ``` A repeat with the same key returns **the first response**, with the same status and body, and the response header `Idempotent-Replay: true`. The write happens once. ## Which endpoints honour it The header is honoured on these endpoints, and only these: - [`POST /assets`](https://docs.xplantpro.com/docs/api/media/create-asset.md): Attach a media file - [`POST /comments`](https://docs.xplantpro.com/docs/api/comments/create-comment.md): Add a comment - [`POST /contaminations`](https://docs.xplantpro.com/docs/api/contaminations/create-contamination.md): Record a contamination - [`POST /devices`](https://docs.xplantpro.com/docs/api/devices/create-device.md): Register a device - [`POST /equipment/{id}/events`](https://docs.xplantpro.com/docs/api/equipment/create-equipment-event.md): Record an equipment event - [`POST /explants`](https://docs.xplantpro.com/docs/api/explants/create-explant.md): Create an explant - [`POST /label-scans`](https://docs.xplantpro.com/docs/api/labels/create-label-scan.md): Record a label scan - [`POST /media-recipes`](https://docs.xplantpro.com/docs/api/media/create-media-recipe.md): Create a media recipe - [`POST /plants`](https://docs.xplantpro.com/docs/api/plants/create-plant.md): Create a plant - [`POST /sop-runs`](https://docs.xplantpro.com/docs/api/sops/create-sop-run.md): Start an SOP run - [`POST /sop-runs/{id}/steps/{stepId}/events`](https://docs.xplantpro.com/docs/api/sops/create-sop-step-event.md): Record step evidence - [`POST /sop-runs/{id}/steps/{stepId}/measurements`](https://docs.xplantpro.com/docs/api/sops/create-sop-step-measurement.md): Record a step measurement - [`POST /stages`](https://docs.xplantpro.com/docs/api/transfers-and-stages/create-stage.md): Advance a stage - [`POST /tasks`](https://docs.xplantpro.com/docs/api/tasks/create-task.md): Create a task - [`POST /tasks/demand`](https://docs.xplantpro.com/docs/api/tasks/create-demand-signal.md): Record a demand signal - [`POST /transfers`](https://docs.xplantpro.com/docs/api/transfers-and-stages/create-transfer.md): Record a transfer Everywhere else the header is ignored. For sensor readings, make retries safe with an `external_id` and `recorded_at` on every reading instead: readings are de-duplicated on the device, the `external_id` and the time. ## The rules | | | | --- | --- | | Format | 8–255 characters from `A–Z a–z 0–9 . _ : ~ -`. A malformed key answers `422 VALIDATION_ERROR` rather than being silently ignored. | | Scope | Your key plus the endpoint. The same string on two endpoints is two different writes, and two keys never collide. | | How long | 24 hours. After that, the same key starts a fresh write. | | Still running | If the first request hasn't finished, a repeat answers `409 IDEMPOTENCY_IN_FLIGHT` with `Retry-After: 1`. Wait and send it again; don't assume it failed. | | Failed writes | Aren't replayed. If the first attempt failed, the next attempt runs for real, so a passing glitch isn't pinned to your key for a day. | | No header | The write runs unprotected. Idempotency is opt-in. | ## Choosing keys Make one key per **intent**, not per attempt. Every retry of the same logical write sends the same value; a new write sends a new one. Good keys come from your own data: `station-3-run-wk38-start`, `scanner-3-`, `nightly-sync-2026-09-25-task-8812`. A random UUID generated once, stored with the pending write and reused on each retry, also works. The JavaScript SDK takes `{ idempotencyKey }` as the last argument of any write, and with `retry: true` it generates a key for you and reuses it across its own retries. --- # Pagination > Every list pages by cursor. Pass meta.next_cursor back as cursor until it's null. Source: https://docs.xplantpro.com/docs/pagination List endpoints return one page at a time. `data` is a plain array, and there's no total count. ## Page with the cursor Every list response carries `meta.next_cursor`. Pass it back unchanged as `cursor`, with the same filters, to get the next page. It's `null` on the last page. ```json { "ok": true, "data": [ … ], "meta": { "next_cursor": "eyJ…" } } ``` ```bash curl "https://app.xplantpro.com/api/v1/plants?limit=200" \ -H "Authorization: Bearer $XPLANT_API_KEY" # then the same request with &cursor=, until next_cursor is null ``` - Treat the cursor as opaque: don't parse it, build it or store it for long. - Keep the same filters and `limit` for every page of one walk. - A cursor that has expired or no longer applies answers `422 INVALID_CURSOR`: start again from the first page. - `cursor` can't be combined with an `offset` above 0; that answers `422`. A cursor picks up exactly where the last page ended, so records created while you're paging don't shift what you see. ## Page size | List | `limit` default | `limit` maximum | | --- | --- | --- | | Most lists | 50 | 200 | | [Sensor readings](https://docs.xplantpro.com/docs/api/sensor-readings/list-sensor-readings.md) | 100 | 1,000 | | [Devices](https://docs.xplantpro.com/docs/api/devices/list-devices.md) and [device tokens](https://docs.xplantpro.com/docs/api/devices/list-device-tokens.md) | 200 | 200 | Values above the maximum are capped. A missing, zero, negative or non-numeric `limit` falls back to the default. ## In code The SDK's list methods follow the cursor for you. Awaiting a list gives you one page; iterating it gives you every row: **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); // One page, as an array. const firstPage = await client.plants.list({ limit: 200 }); // Every row, fetched a page at a time as you go. for await (const plant of client.plants.list()) { console.log(plant.id); } // Page by page, with a cursor you can save and resume from later. for await (const { data, nextCursor, hasMore } of client.explants.list().pages()) { await save(data, nextCursor); } const resumed = await client.explants.list({ cursor: savedCursor }); // Filters go in the same object: for await (const event of client.events.list({ entity: "explant" })) { // … } ``` **Python** ```python import os import requests HEADERS = {"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"} def all_items(path, params=None, limit=200): """Walks a list by its cursor.""" params = {**(params or {}), "limit": limit} while True: body = requests.get( f"https://app.xplantpro.com/api/v1{path}", headers=HEADERS, params=params, timeout=10 ).json() if not body["ok"]: raise RuntimeError(f"{body['code']}: {body['error']}") yield from body["data"] next_cursor = (body.get("meta") or {}).get("next_cursor") if not next_cursor: return params["cursor"] = next_cursor for plant in all_items("/plants"): print(plant["id"]) ``` ## Sensor readings `GET /sensor-readings` returns readings newest first. Narrow the window with `since` and `until` (both inclusive; a `since` later than `until` answers `422`), then page through it with the cursor like any other list. ## Offset Lists still accept `offset` (records to skip) for older integrations, but offsets count records at the moment you ask: a record added ahead of your position shifts the next page, so you can see one twice or miss one. Use the cursor for anything new. To keep a mirror exact, sync from [change history](https://docs.xplantpro.com/docs/guides/change-history.md) with `since`. --- # Guides > Worked examples for the integrations labs build most often. Source: https://docs.xplantpro.com/docs/guides Each guide walks through one job end to end, with the scopes it needs and code in curl, JavaScript and Python. - [Sync tasks](https://docs.xplantpro.com/docs/guides/sync-tasks.md): Drive the bench queue from your own scheduler, without overriding people. - [Push demand signals](https://docs.xplantpro.com/docs/guides/demand-signals.md): Tell xPlant what's selling so the right work rises to the top. - [Record transfers and stages](https://docs.xplantpro.com/docs/guides/transfers-and-stages.md): Log subcultures and stage changes from a script or a scanner. - [Run an SOP from a bench station](https://docs.xplantpro.com/docs/guides/sop-runs.md): Start runs, confirm steps, and post measurements with units. - [Label scanning](https://docs.xplantpro.com/docs/guides/label-scanning.md): Resolve a QR or barcode to its record, and log the scan. - [Sensors and devices](https://docs.xplantpro.com/docs/guides/sensors-and-devices.md): Stream readings from a Pi or an ESP32 with a device token. - [Equipment events](https://docs.xplantpro.com/docs/guides/equipment-events.md): Let an autoclave or a pH meter report usage, calibration and maintenance. - [Pull change history](https://docs.xplantpro.com/docs/guides/change-history.md): Mirror plant and explant edits into your own system. --- # Sync tasks > Create, prioritise and complete bench work from your own scheduler, without overriding the order people set by hand. Source: https://docs.xplantpro.com/docs/guides/sync-tasks **Scopes:** `read:tasks`, `write:tasks`. **Endpoints:** [List tasks](https://docs.xplantpro.com/docs/api/tasks/list-tasks.md), [Create a task](https://docs.xplantpro.com/docs/api/tasks/create-task.md), [Get a task](https://docs.xplantpro.com/docs/api/tasks/get-task.md), [Update a task](https://docs.xplantpro.com/docs/api/tasks/update-task.md). The task endpoints let a lab plan production from its own data (sales demand, pipeline time, success rates) instead of by hand, while people at the bench keep the final say. ## Create work Set the priority, the queue position and the assignee in the same call, so a scheduler places work in one request. Link the task to the plant or explant it's about with `entity_type` and `entity_id` (send both or neither). **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/tasks \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: sync-2026-09-25-line-0412-transfer" \ -H "Content-Type: application/json" \ -d '{ "title": "Transfer the Alocasia line onto fresh medium", "category": "transfer", "priority": "high", "priority_rank": 1.5, "due_date": "2026-10-02T09:00:00Z", "assigned_to": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "genus": "Alocasia" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY, retry: true }); const task = await client.tasks.create( { title: "Transfer the Alocasia line onto fresh medium", category: "transfer", priority: "high", priority_rank: 1.5, due_date: "2026-10-02T09:00:00Z", assigned_to: "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", genus: "Alocasia", }, { idempotencyKey: "sync-2026-09-25-line-0412-transfer" }, ); ``` **Python** ```python import os import requests BASE = "https://app.xplantpro.com/api/v1" HEADERS = {"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"} resp = requests.post( f"{BASE}/tasks", headers={**HEADERS, "Idempotency-Key": "sync-2026-09-25-line-0412-transfer"}, json={ "title": "Transfer the Alocasia line onto fresh medium", "category": "transfer", "priority": "high", "priority_rank": 1.5, "due_date": "2026-10-02T09:00:00Z", "assigned_to": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "genus": "Alocasia", }, timeout=10, ) task = resp.json()["data"] ``` An `Idempotency-Key` built from your own data (the sync run, the line, the job) means a nightly sync that retries after a timeout can't create the same task twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). `assigned_to` must be an active member of the workspace. Anything else is rejected with `422 VALIDATION_ERROR` rather than silently dropped. ## Ordering: labels, ranks and the hand-placed rule Two fields position work: - `priority` is the label: `low`, `medium`, `high`, `urgent`. - `priority_rank` is the actual place in the queue. Lower sorts first, and it's a decimal, so a scheduler can drop a task between two neighbours (between `1` and `2`, send `1.5`) without renumbering everything. Send `null` to clear it and let the label decide. **Anything written through the API counts as automatic ordering, and automatic ordering never overrides a person.** If someone has placed a task by hand in xPlant, an ordering change from the API is skipped instead of applied. `priority_source` on every task tells you who owns its order; `manual` means a person placed it. A skip is not an error. A nightly sync that failed on every hand-placed task would be unusable, so the response is still `200` and the rest of your change still applies: **JavaScript** ```js const { task, skipped } = await client.tasks.update(taskId, { priority: "urgent", priority_rank: 0.5 }); if (skipped) { console.log(`Left "${task.title}" where someone put it by hand.`); } ``` **Python** ```python resp = requests.patch( f"{BASE}/tasks/{task_id}", headers=HEADERS, json={"priority": "urgent", "priority_rank": 0.5}, timeout=10, ) body = resp.json() report = (body.get("meta") or {}).get("priority_write") if report and not report["applied"]: print(report["message"]) ``` When a change touches ordering, the response carries `meta.priority_write`: `applied` (true or false), `reason` (`manual_override` when skipped), the task's `priority_source` (`default`, `auto` or `manual`), and a plain-language `message` that's safe to put in a sync log. When a change doesn't touch ordering, `meta` is left out. To take a hand-placed task back under automatic control, send `release: true` with the change. The ordering then applies and the task's order becomes automatic again. ```bash curl -X PATCH "https://app.xplantpro.com/api/v1/tasks/$TASK_ID" \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"priority": "high", "priority_rank": 1500, "release": true}' ``` Only release a task when a person has asked for it. The rule exists so that the bench can overrule a model. ## Move work along the board `workflow_status` is the board column: `backlog`, `todo`, `in_progress`, `waiting_blocked`, `review`, `done`. Complete a task by setting it to `done`: ```bash curl -X PATCH "https://app.xplantpro.com/api/v1/tasks/$TASK_ID" \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"workflow_status": "done"}' ``` ## Read the queue List tasks earliest-due first, optionally by column or assignee: **JavaScript** ```js for await (const task of client.tasks.list({ status: "todo" })) { console.log(task.due_date, task.priority, task.title); } ``` **Python** ```python resp = requests.get(f"{BASE}/tasks", headers=HEADERS, params={"status": "todo", "limit": 200}, timeout=10) for task in resp.json()["data"]: print(task["due_date"], task["priority"], task["title"]) ``` Lists leave out `entity_link` to stay small. Fetch a single task to see which plant or explant it's about. ## A nightly sync, in outline 1. Pull your own plan (orders, forecasts, pipeline) and work out what should be on the bench. 2. Read the open tasks (`status=todo`, `backlog`, `in_progress`) and match them to your plan. Keep your own mapping from your job ids to xPlant task ids. 3. Create what's missing, with an `Idempotency-Key` derived from your job id. 4. Update priorities for the rest. Expect some `skipped` results; those are tasks a person has placed, and that's working as intended. 5. If you also push demand numbers, send them as [demand signals](https://docs.xplantpro.com/docs/guides/demand-signals.md) and let xPlant weigh them. --- # Push demand signals > Send xPlant a demand number per genus, from orders, sales or forecasts, so it can weigh the task queue toward what sells. Source: https://docs.xplantpro.com/docs/guides/demand-signals **Scopes:** `write:demand` to send, `read:tasks` to read back. **Endpoints:** [Record a demand signal](https://docs.xplantpro.com/docs/api/tasks/create-demand-signal.md), [List demand signals](https://docs.xplantpro.com/docs/api/tasks/list-demand-signals.md). A demand signal says how much demand there is for a genus right now: open orders, sales velocity, a forecast, whatever you measure. xPlant uses **the latest signal for each genus** when it scores the task queue. Send one whenever your number changes; history is kept. ## Send a signal **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/tasks/demand \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "genus": "Alocasia", "demand_score": 82, "source": "Online store, last 30 days", "source_type": "orders", "observed_at": "2026-09-24T00:00:00Z" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); await client.taskDemand.record({ genus: "Alocasia", demand_score: 82, source: "Online store, last 30 days", source_type: "orders", observed_at: "2026-09-24T00:00:00Z", }); ``` **Python** ```python import os import requests requests.post( "https://app.xplantpro.com/api/v1/tasks/demand", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={ "genus": "Alocasia", "demand_score": 82, "source": "Online store, last 30 days", "source_type": "orders", "observed_at": "2026-09-24T00:00:00Z", }, timeout=10, ).raise_for_status() ``` | Field | Required | Notes | | --- | --- | --- | | `genus` | Yes | The genus the number is about. | | `demand_score` | Yes | Zero or more. Use one consistent scale across genera (units on order, or a 0–100 index); what matters is the comparison. | | `source` | Yes | Where the number came from, in words people will recognise later. | | `source_type` | No | Your own short category, such as `orders` or `forecast`. | | `observed_at` | No | When the number was true, as an ISO 8601 timestamp. | ## Read it back The history for one genus, newest first, or just its current value: ```bash curl "https://app.xplantpro.com/api/v1/tasks/demand?genus=Alocasia¤t=true" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` With `current=true` (and a `genus`), `data` is a single reading whose `id` is `current`. ## Patterns that work - **Send on change, not on a timer.** A signal that hasn't changed adds history without adding information. - **One source per scale.** If you mix a store's order counts with a forecast index, send them under different `source` values so the history stays readable. - **Pair it with task sync.** Demand says what matters; [task sync](https://docs.xplantpro.com/docs/guides/sync-tasks.md) puts the work on the bench. --- # Record transfers and stages > Log subcultures to fresh media and move plants and explants through tissue-culture stages from a script or a scanner. Source: https://docs.xplantpro.com/docs/guides/transfers-and-stages **Scopes:** `read:transfers`, `write:transfers` (and `read:plants` / `read:explants` to look records up). **Endpoints:** [Record a transfer](https://docs.xplantpro.com/docs/api/transfers-and-stages/create-transfer.md), [List transfers](https://docs.xplantpro.com/docs/api/transfers-and-stages/list-transfers.md), [Advance a stage](https://docs.xplantpro.com/docs/api/transfers-and-stages/create-stage.md), [List stages](https://docs.xplantpro.com/docs/api/transfers-and-stages/list-stages.md). ## Find the record first Transfers and stages belong to a plant or an explant. If your own system knows a batch by its own code, look it up with `externalId` rather than keeping a separate id mapping: **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const explant = await client.explants.findByExternalId("LINE-0412"); // record or null ``` **curl** ```bash curl "https://app.xplantpro.com/api/v1/explants?externalId=LINE-0412" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **Python** ```python import os import requests BASE = "https://app.xplantpro.com/api/v1" HEADERS = {"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"} matches = requests.get(f"{BASE}/explants", headers=HEADERS, params={"externalId": "LINE-0412"}, timeout=10).json()["data"] explant = matches[0] if matches else None ``` A scanner can also [resolve a label](https://docs.xplantpro.com/docs/guides/label-scanning.md) straight to the record. ## Record a transfer A transfer is a subculture onto fresh media. Give exactly one of `plant_id` or `explant_id`. The transfer cycle counts up from the record's history unless you set `transfer_cycle` yourself. **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/transfers \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "explant_id": "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", "from_location": "Shelf A2", "to_location": "Shelf B1", "notes": "Routine subculture" }' ``` **JavaScript** ```js const transfer = await client.transfers.create({ explant_id: explant.id, from_location: "Shelf A2", to_location: "Shelf B1", notes: "Routine subculture", }); ``` **Python** ```python transfer = requests.post( f"{BASE}/transfers", headers=HEADERS, json={"explant_id": explant["id"], "from_location": "Shelf A2", "to_location": "Shelf B1"}, timeout=10, ).json()["data"] ``` Optional fields: `transfer_date`, `from_location`, `to_location`, `transfer_cycle`, `notes`. ## Advance a stage One call completes the current stage and makes the new one current. `stage` is required; give exactly one of `plant_id` or `explant_id`. Send `stage` as a key from your lab's stage list. Plants and explants have separate lists: an explant might move to `multiplication` or `root_induction`, a plant to `production`. Use the stages your lab has set up in xPlant. **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/stages \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"explant_id": "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", "stage": "multiplication"}' ``` **JavaScript** ```js await client.stages.advance({ explant_id: explant.id, stage: "multiplication" }); ``` **Python** ```python requests.post( f"{BASE}/stages", headers=HEADERS, json={"explant_id": explant["id"], "stage": "multiplication"}, timeout=10, ).raise_for_status() ``` Optional fields: `entered_on`, `room_id`, `notes`. ## Read the history Both lists take one of `plant_id` or `explant_id` and return the most recent first: ```bash curl "https://app.xplantpro.com/api/v1/transfers?explant_id=$EXPLANT_ID" -H "Authorization: Bearer $XPLANT_API_KEY" curl "https://app.xplantpro.com/api/v1/stages?plant_id=$PLANT_ID" -H "Authorization: Bearer $XPLANT_API_KEY" ``` ## A transfer counter at the bench A common build is a scanner plus a button: scan the vessel, press "transferred". The scanner [resolves the label](https://docs.xplantpro.com/docs/guides/label-scanning.md) to a record, and the button posts a transfer for it. Device tokens can't record transfers, so a station like this needs a workspace key. Give the station its own key holding only `read:labels`, `write:label_scans` and `write:transfers`, so that a lost station exposes as little as possible and can be revoked on its own. See [Bench stations and keys](https://docs.xplantpro.com/docs/guides/sop-runs.md#bench-stations-and-keys). --- # Run an SOP from a bench station > Start a run of the SOP in force, confirm steps with scans, and record measurements with their units. Source: https://docs.xplantpro.com/docs/guides/sop-runs **Scopes:** `read:sops`, `write:sop_runs`, `write:sop_steps`, and `read:sop_runs` to read a run back. **Endpoints:** [Get an SOP](https://docs.xplantpro.com/docs/api/sops/get-sop.md), [Start an SOP run](https://docs.xplantpro.com/docs/api/sops/create-sop-run.md), [Record step evidence](https://docs.xplantpro.com/docs/api/sops/create-sop-step-event.md), [Record a step measurement](https://docs.xplantpro.com/docs/api/sops/create-sop-step-measurement.md), [Get an SOP run](https://docs.xplantpro.com/docs/api/sops/get-sop-run.md). A bench station (a tablet, a Pi with a touchscreen, a scanner) can walk a technician through an SOP and record what actually happened: who confirmed each step, what was scanned, which pH was measured. ## 1. Load the SOP **curl** ```bash curl "https://app.xplantpro.com/api/v1/sops/$SOP_ID" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY, retry: true }); const sop = await client.sops.get(sopId); if (!sop.version) { throw new Error(`"${sop.title}" has no version in force yet, so there is nothing to run.`); } for (const step of sop.version.steps) console.log(step); ``` **Python** ```python import os import requests BASE = "https://app.xplantpro.com/api/v1" HEADERS = {"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"} sop = requests.get(f"{BASE}/sops/{sop_id}", headers=HEADERS, timeout=10).json()["data"] if sop["version"] is None: raise SystemExit(f"{sop['title']} has no version in force yet") ``` You always get **the version the lab works from right now**. You never get a draft, or a version that has been approved but hasn't taken effect yet. If no version is in force, `version` is `null` and there are no steps: show that to the technician rather than presenting an empty procedure. `currentVersion` on the SOP is the highest version number that exists; `version.version` is the one in force. They often differ. ## 2. Start a run **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/sop-runs \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: station-3-wk38-start" \ -H "Content-Type: application/json" \ -d '{"sop_id": "9d2c4b6a-8e1f-4a3b-b5c7-d9e1f3a5b7c9", "batch_code": "WK-38"}' ``` **JavaScript** ```js const run = await client.sopRuns.start( { sop_id: sopId, batch_code: "WK-38" }, { idempotencyKey: "station-3-wk38-start" }, ); ``` **Python** ```python run = requests.post( f"{BASE}/sop-runs", headers={**HEADERS, "Idempotency-Key": "station-3-wk38-start"}, json={"sop_id": sop_id, "batch_code": "WK-38"}, timeout=10, ).json()["data"] ``` There is deliberately no version argument. **A run always follows the version in force**, because a run is a record of what someone did, and what they did was follow the lab's current procedure. An SOP with no version in force answers `409 SOP_RUN_NOT_EFFECTIVE` (not `404`: the SOP exists, it just can't be run yet). ## 3. Record evidence against steps A scanner confirms a step: **curl** ```bash curl -X POST "https://app.xplantpro.com/api/v1/sop-runs/$RUN_ID/steps/step-3/events" \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: station-3-wk38-step3-scan" \ -H "Content-Type: application/json" \ -d '{"event_type": "scanned", "payload": {"code": "LINE-0412"}}' ``` **JavaScript** ```js await client.sopRuns.recordStepEvent( run.id, "step-3", { event_type: "scanned", payload: { code: "LINE-0412" } }, { idempotencyKey: "station-3-wk38-step3-scan" }, ); ``` **Python** ```python requests.post( f"{BASE}/sop-runs/{run['id']}/steps/step-3/events", headers={**HEADERS, "Idempotency-Key": "station-3-wk38-step3-scan"}, json={"event_type": "scanned", "payload": {"code": "LINE-0412"}}, timeout=10, ).raise_for_status() ``` Step events cover confirmations, scans, skips, notes and device states. A meter posts a reading against the same step. **The unit is required** and has no default: a number without a unit isn't a measurement. **curl** ```bash curl -X POST "https://app.xplantpro.com/api/v1/sop-runs/$RUN_ID/steps/step-3/measurements" \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: station-3-wk38-step3-ph" \ -H "Content-Type: application/json" \ -d '{"metric": "ph", "value": 5.7, "unit": "pH"}' ``` **JavaScript** ```js await client.sopRuns.recordMeasurement( run.id, "step-3", { metric: "ph", value: 5.7, unit: "pH" }, { idempotencyKey: "station-3-wk38-step3-ph" }, ); ``` **Python** ```python requests.post( f"{BASE}/sop-runs/{run['id']}/steps/step-3/measurements", headers={**HEADERS, "Idempotency-Key": "station-3-wk38-step3-ph"}, json={"metric": "ph", "value": 5.7, "unit": "pH"}, timeout=10, ).raise_for_status() ``` Measurements appear on the run's timeline as `measured` events, alongside the confirmations, so [Get an SOP run](https://docs.xplantpro.com/docs/api/sops/get-sop-run.md) reads as one story from start to finish. ## Runs are append-only Nothing on a run can be edited or deleted. To correct a mistake, record another event that says so. Once a run is **completed**, further writes answer `409 SOP_RUN_CLOSED`: its record is what happened. Use an `Idempotency-Key` on every write from a station. Bench networks are flaky, and a retried scan must not count twice. ## Bench stations and keys Device tokens only cover sensor readings, device events and heartbeats, so a station that runs SOPs needs a workspace key. Keep the damage from a lost or copied station small: - Give each station **its own key**, named after the station, holding only what it uses. For SOP runs that's `read:sops`, `write:sop_runs` and `write:sop_steps`; add `read:labels` and `write:label_scans` if it scans, and `write:transfers` if it records transfers. - Keep the key in the station's configuration or secrets store, never in code or a shared repository. - If a station goes missing, revoke its key in **Settings → Integrations → API Keys**. Nothing else stops working. --- # Label scanning > Resolve a scanned QR code or barcode to the plant or explant it labels, and record that the scan happened. Source: https://docs.xplantpro.com/docs/guides/label-scanning **Scopes:** `read:labels` to resolve, `write:label_scans` to record. **Endpoints:** [Resolve a label](https://docs.xplantpro.com/docs/api/labels/resolve-label.md), [Record a label scan](https://docs.xplantpro.com/docs/api/labels/create-label-scan.md). Scanning a label is two separate questions, and the API answers them separately: 1. **What is this?** Resolving turns the scanned value into a record. It leaves no trace. 2. **Who scanned it, where, when?** Recording a scan adds it to the history. Resolving alone would let a scanner find a jar without anything saying anyone had been at the shelf. ## Resolve **curl** ```bash curl "https://app.xplantpro.com/api/v1/labels/resolve?barcode=LINE-0412" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const match = await client.labels.resolve("LINE-0412"); // { record_type: "explant", record_id: "…", display_name: "…", url: "https://app.xplantpro.com/…" } ``` **Python** ```python import os import requests BASE = "https://app.xplantpro.com/api/v1" HEADERS = {"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"} match = requests.get(f"{BASE}/labels/resolve", headers=HEADERS, params={"barcode": "LINE-0412"}, timeout=10).json()["data"] ``` Plant labels are matched first, then explant labels, within your workspace. You get `record_type` (`plant` or `explant`), `record_id`, `display_name`, and a `url` that opens the record in xPlant. A code that matches no label answers `404 NOT_FOUND`: show the technician "unknown label" rather than retrying. ## Record the scan **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/label-scans \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: scanner-3-0f2a7c91" \ -H "Content-Type: application/json" \ -d '{"barcode": "LINE-0412", "explant_id": "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", "context": "Shelf 3"}' ``` **JavaScript** ```js await client.labels.recordScan( { barcode: "LINE-0412", [match.record_type === "plant" ? "plant_id" : "explant_id"]: match.record_id, context: "Shelf 3", }, { idempotencyKey: `scanner-3-${crypto.randomUUID()}` }, ); ``` **Python** ```python import uuid field = "plant_id" if match["record_type"] == "plant" else "explant_id" requests.post( f"{BASE}/label-scans", headers={**HEADERS, "Idempotency-Key": f"scanner-3-{uuid.uuid4()}"}, json={"barcode": "LINE-0412", field: match["record_id"], "context": "Shelf 3"}, timeout=10, ).raise_for_status() ``` - `context` is free text: where the scan happened, or why. - `scanned_at` is optional; send it if the scanner queued the scan while offline. - You don't send `resolved`. It's true when the body names a record. - Scans are history: they can't be edited or deleted. A correction is another scan. Generate the `Idempotency-Key` once per physical scan and reuse it if you retry, so a flaky network can't record one scan twice. ## Hardware Most USB and Bluetooth scanners act as keyboards: they "type" the code and press Enter. Read a line from standard input (or a focused text field) and you have the value. The [ESP32 scan station](https://github.com/shmaplex/xplant_os/tree/main/devices/arduino/esp32-scan-station) in this repository is a starting point for a standalone scanner. --- # 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 # UTC ending in "Z": isoformat()'s "+00:00" is rejected. 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` 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 ```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. ```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"}}' ``` ## 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). --- # Equipment events > Let an autoclave, a balance or a pH meter report when it was used, calibrated or maintained. Source: https://docs.xplantpro.com/docs/guides/equipment-events **Scopes:** `write:equipment_events` to record, `read:equipment` to look equipment up. **Endpoints:** [Record an equipment event](https://docs.xplantpro.com/docs/api/equipment/create-equipment-event.md), [List equipment](https://docs.xplantpro.com/docs/api/equipment/list-equipment.md), [List equipment events](https://docs.xplantpro.com/docs/api/equipment/list-equipment-events.md). Equipment that can call a webhook, or a small script beside it, can keep xPlant's equipment records current without anyone typing them in. You need the equipment's id: find it with [List equipment](https://docs.xplantpro.com/docs/api/equipment/list-equipment.md). ## Two kinds of event The two kinds answer different questions, and xPlant keeps them apart: - **Usage** (`kind: "used"`) answers *how hard has this been worked?* It records use against a subject, such as the batch or run it was used for. - **Maintenance** (`kind: "calibration"` or `"preventive_maintenance"`) answers *is it fit to use?* It carries an `outcome`: `pass`, `pass_after_adjustment`, `out_of_tolerance`, `fail` or `not_performed`. Calibration and maintenance records are part of equipment calibration, which not every plan includes. Recording an event doesn't move the equipment's calibration or maintenance due dates; those follow the lab's schedules in xPlant. ## Record a calibration **curl** ```bash curl -X POST "https://app.xplantpro.com/api/v1/equipment/$EQUIPMENT_ID/events" \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: ph-meter-2-cal-2026-09-25" \ -H "Content-Type: application/json" \ -d '{ "kind": "calibration", "outcome": "pass", "performed_at": "2026-09-25T07:30:00Z", "result_summary": "Two-point calibration, pH 4.01 and 7.00" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY, retry: true }); await client.equipment.recordEvent( equipmentId, { kind: "calibration", outcome: "pass", performed_at: "2026-09-25T07:30:00Z", result_summary: "Two-point calibration, pH 4.01 and 7.00", }, { idempotencyKey: "ph-meter-2-cal-2026-09-25" }, ); ``` **Python** ```python import os import requests requests.post( f"https://app.xplantpro.com/api/v1/equipment/{equipment_id}/events", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "ph-meter-2-cal-2026-09-25", }, json={ "kind": "calibration", "outcome": "pass", "performed_at": "2026-09-25T07:30:00Z", "result_summary": "Two-point calibration, pH 4.01 and 7.00", }, timeout=10, ).raise_for_status() ``` Maintenance fields: `outcome`, `performed_at`, `performed_by_name`, `result_summary`, `notes`. [List equipment events](https://docs.xplantpro.com/docs/api/equipment/list-equipment-events.md) reads the history back. ## Record a use ```bash curl -X POST "https://app.xplantpro.com/api/v1/equipment/$EQUIPMENT_ID/events" \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: autoclave-2-cycle-4411" \ -H "Content-Type: application/json" \ -d '{"kind": "used", "subject_type": "media_batch", "subject_id": "'"$MEDIA_BATCH_ID"'", "notes": "Media sterilisation cycle"}' ``` Usage fields: `subject_type`, `subject_id`, `subject_label`, `used_at`, `notes`. `subject_type` is one of `sop_log`, `media_batch`, `plant_transfer`, `explant_transfer` or `contamination_log`: what the equipment was used for. ## Good to know - Events are append-only. A correction is another event. - Equipment in another workspace answers `404`, the same as equipment that doesn't exist. - The endpoint honours `Idempotency-Key`. Build it from something the equipment already numbers, like a cycle count or a calibration date, so a webhook that fires twice records once. --- # Pull change history > Mirror every plant and explant edit into your own database, incrementally. Source: https://docs.xplantpro.com/docs/guides/change-history **Scopes:** `read:events` (plus `read:plants` / `read:explants` to fetch full records). **Endpoint:** [List change history](https://docs.xplantpro.com/docs/api/change-history/list-change-events.md). Every edit to a plant or explant is kept as an event. Reading them incrementally is the cheapest way to keep a copy of your lab's records in a warehouse, a spreadsheet or another system. ## How it works - `entity` is required: `plant` or `explant`. They're separate histories, so pull each one on its own. - Events come **oldest first**, and `since` is exclusive: you get events created strictly after it. - Save the `created_at` of the last event you processed, and pass it back as `since` next time. You only get what changed. - Page through a big catch-up with the cursor: pass `meta.next_cursor` back as `cursor` until it's `null`. **Python** ```python import os import requests BASE = "https://app.xplantpro.com/api/v1" HEADERS = {"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"} def pull(entity, since): """Returns new events for one entity type, and the cursor to save.""" events = [] params = {"entity": entity, "limit": 200} if since: params["since"] = since while True: body = requests.get(f"{BASE}/events", headers=HEADERS, params=params, timeout=10).json() if not body["ok"]: raise RuntimeError(f"{body['code']}: {body['error']}") events.extend(body["data"]) next_cursor = (body.get("meta") or {}).get("next_cursor") if not next_cursor: break params["cursor"] = next_cursor newest = events[-1]["created_at"] if events else since return events, newest # Load these from your own storage between runs. cursors = {"plant": None, "explant": None} for entity in ("plant", "explant"): events, cursors[entity] = pull(entity, cursors[entity]) for event in events: apply_to_my_copy(entity, event) # your code # Save cursors for next time. ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY, retry: true }); async function pull(entity, since) { let cursor = since; for await (const event of client.events.list({ entity, since })) { await applyToMyCopy(entity, event); // your code cursor = event.created_at; } return cursor; // save it for the next run } const plantCursor = await pull("plant", savedPlantCursor); const explantCursor = await pull("explant", savedExplantCursor); ``` **curl** ```bash curl "https://app.xplantpro.com/api/v1/events?entity=plant&since=2026-09-24T00:00:00Z&limit=200" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` ## Tips - **Store the cursor only after you've applied the events.** If your job crashes halfway, the next run picks up where the last successful one ended. - **Overlap, then de-duplicate.** Events written together share a timestamp, and a write can land a moment after its timestamp. Start `since` about a minute before the newest `created_at` you've seen, and store events keyed by their id so the overlap is harmless. - **Run it on a schedule** (every few minutes to hourly) rather than in a tight loop. It's well inside the [rate limits](https://docs.xplantpro.com/docs/rate-limits.md) either way. --- # API overview > Every v1 endpoint, the scope it needs, and what it returns. Source: https://docs.xplantpro.com/docs/api Base URL: `https://app.xplantpro.com/api/v1`. Send your key as `Authorization: Bearer ` on every request. Request and response bodies are JSON. Every response uses the same envelope: ```json { "ok": true, "data": { } } { "ok": false, "data": null, "error": "Missing scope: write:tasks", "code": "FORBIDDEN" } ``` Read `data` on success. On failure, branch on `code`, which is stable; `error` is for people and its wording can change. See [Errors](https://docs.xplantpro.com/docs/errors.md). The machine-readable spec is [openapi.json](/openapi.json) (OpenAPI 3). ## Account | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /me`](https://docs.xplantpro.com/docs/api/account/get-me.md) | Get the calling key | none | | [`GET /workspaces`](https://docs.xplantpro.com/docs/api/account/list-workspaces.md) | Get the workspace | `read:workspace` | ## Plants | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /plants`](https://docs.xplantpro.com/docs/api/plants/list-plants.md) | List plants | `read:plants` | | [`POST /plants`](https://docs.xplantpro.com/docs/api/plants/create-plant.md) | Create a plant | `write:plants` | | [`GET /plants/{id}`](https://docs.xplantpro.com/docs/api/plants/get-plant.md) | Get a plant | `read:plants` | | [`PATCH /plants/{id}`](https://docs.xplantpro.com/docs/api/plants/update-plant.md) | Update a plant | `write:plants` | ## Explants | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /explants`](https://docs.xplantpro.com/docs/api/explants/list-explants.md) | List explants | `read:explants` | | [`POST /explants`](https://docs.xplantpro.com/docs/api/explants/create-explant.md) | Create an explant | `write:explants` | | [`GET /explants/{id}`](https://docs.xplantpro.com/docs/api/explants/get-explant.md) | Get an explant | `read:explants` | | [`PATCH /explants/{id}`](https://docs.xplantpro.com/docs/api/explants/update-explant.md) | Update an explant | `write:explants` | ## Transfers and stages | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /stages`](https://docs.xplantpro.com/docs/api/transfers-and-stages/list-stages.md) | List stages | `read:transfers` | | [`POST /stages`](https://docs.xplantpro.com/docs/api/transfers-and-stages/create-stage.md) | Advance a stage | `write:transfers` | | [`GET /transfers`](https://docs.xplantpro.com/docs/api/transfers-and-stages/list-transfers.md) | List transfers | `read:transfers` | | [`POST /transfers`](https://docs.xplantpro.com/docs/api/transfers-and-stages/create-transfer.md) | Record a transfer | `write:transfers` | ## Change history | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /events`](https://docs.xplantpro.com/docs/api/change-history/list-change-events.md) | List change history | `read:events` | ## Contaminations | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /contaminations`](https://docs.xplantpro.com/docs/api/contaminations/list-contaminations.md) | List contaminations | `read:contaminations` | | [`POST /contaminations`](https://docs.xplantpro.com/docs/api/contaminations/create-contamination.md) | Record a contamination | `write:contaminations` | | [`GET /contaminations/{id}`](https://docs.xplantpro.com/docs/api/contaminations/get-contamination.md) | Get a contamination | `read:contaminations` | ## Tasks and demand | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /tasks`](https://docs.xplantpro.com/docs/api/tasks/list-tasks.md) | List tasks | `read:tasks` | | [`POST /tasks`](https://docs.xplantpro.com/docs/api/tasks/create-task.md) | Create a task | `write:tasks` | | [`GET /tasks/{id}`](https://docs.xplantpro.com/docs/api/tasks/get-task.md) | Get a task | `read:tasks` | | [`PATCH /tasks/{id}`](https://docs.xplantpro.com/docs/api/tasks/update-task.md) | Update a task | `write:tasks` | | [`GET /tasks/demand`](https://docs.xplantpro.com/docs/api/tasks/list-demand-signals.md) | List demand signals | `read:tasks` | | [`POST /tasks/demand`](https://docs.xplantpro.com/docs/api/tasks/create-demand-signal.md) | Record a demand signal | `write:demand` | ## Comments | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /comments`](https://docs.xplantpro.com/docs/api/comments/list-comments.md) | List comments | `read:comments` | | [`POST /comments`](https://docs.xplantpro.com/docs/api/comments/create-comment.md) | Add a comment | `write:comments` | ## Media | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /assets`](https://docs.xplantpro.com/docs/api/media/list-assets.md) | List media files | `read:assets` | | [`POST /assets`](https://docs.xplantpro.com/docs/api/media/create-asset.md) | Attach a media file | `write:assets` | | [`GET /assets/{id}`](https://docs.xplantpro.com/docs/api/media/get-asset.md) | Get a media file | `read:assets` | | [`GET /media-recipes`](https://docs.xplantpro.com/docs/api/media/list-media-recipes.md) | List media recipes | `read:media_recipes` | | [`POST /media-recipes`](https://docs.xplantpro.com/docs/api/media/create-media-recipe.md) | Create a media recipe | `write:media_recipes` | | [`GET /media-recipes/{id}`](https://docs.xplantpro.com/docs/api/media/get-media-recipe.md) | Get a media recipe | `read:media_recipes` | | [`PATCH /media-recipes/{id}`](https://docs.xplantpro.com/docs/api/media/update-media-recipe.md) | Update a media recipe | `write:media_recipes` | ## SOPs and runs | Endpoint | What it does | Scope | | --- | --- | --- | | [`POST /sop-runs`](https://docs.xplantpro.com/docs/api/sops/create-sop-run.md) | Start an SOP run | `write:sop_runs` | | [`GET /sop-runs/{id}`](https://docs.xplantpro.com/docs/api/sops/get-sop-run.md) | Get an SOP run | `read:sop_runs` | | [`POST /sop-runs/{id}/steps/{stepId}/events`](https://docs.xplantpro.com/docs/api/sops/create-sop-step-event.md) | Record step evidence | `write:sop_steps` | | [`POST /sop-runs/{id}/steps/{stepId}/measurements`](https://docs.xplantpro.com/docs/api/sops/create-sop-step-measurement.md) | Record a step measurement | `write:sop_steps` | | [`GET /sops`](https://docs.xplantpro.com/docs/api/sops/list-sops.md) | List SOPs | `read:sops` | | [`GET /sops/{id}`](https://docs.xplantpro.com/docs/api/sops/get-sop.md) | Get an SOP | `read:sops` | ## Labels | Endpoint | What it does | Scope | | --- | --- | --- | | [`POST /label-scans`](https://docs.xplantpro.com/docs/api/labels/create-label-scan.md) | Record a label scan | `write:label_scans` | | [`GET /labels/resolve`](https://docs.xplantpro.com/docs/api/labels/resolve-label.md) | Resolve a label | `read:labels` | ## Devices | Endpoint | What it does | Scope | | --- | --- | --- | | [`POST /device-events`](https://docs.xplantpro.com/docs/api/devices/create-device-event.md) | Record a device event | `write:device_events` | | [`GET /devices`](https://docs.xplantpro.com/docs/api/devices/list-devices.md) | List devices | `read:devices` | | [`POST /devices`](https://docs.xplantpro.com/docs/api/devices/create-device.md) | Register a device | `write:devices` | | [`POST /devices/{deviceId}/heartbeat`](https://docs.xplantpro.com/docs/api/devices/send-heartbeat.md) | Send a heartbeat | `write:devices` | | [`GET /devices/{deviceId}/tokens`](https://docs.xplantpro.com/docs/api/devices/list-device-tokens.md) | List device tokens | `read:devices` | | [`POST /devices/{deviceId}/tokens`](https://docs.xplantpro.com/docs/api/devices/create-device-token.md) | Create a device token | `write:devices` | | [`DELETE /devices/{deviceId}/tokens/{tokenId}`](https://docs.xplantpro.com/docs/api/devices/revoke-device-token.md) | Revoke a device token | `write:devices` | ## Sensor readings | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /sensor-readings`](https://docs.xplantpro.com/docs/api/sensor-readings/list-sensor-readings.md) | List sensor readings | `read:sensor_readings` | | [`POST /sensor-readings`](https://docs.xplantpro.com/docs/api/sensor-readings/create-sensor-readings.md) | Submit sensor readings | `write:sensor_readings` | ## Equipment | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /equipment`](https://docs.xplantpro.com/docs/api/equipment/list-equipment.md) | List equipment | `read:equipment` | | [`GET /equipment/{id}`](https://docs.xplantpro.com/docs/api/equipment/get-equipment.md) | Get a piece of equipment | `read:equipment` | | [`GET /equipment/{id}/events`](https://docs.xplantpro.com/docs/api/equipment/list-equipment-events.md) | List equipment events | `read:equipment` | | [`POST /equipment/{id}/events`](https://docs.xplantpro.com/docs/api/equipment/create-equipment-event.md) | Record an equipment event | `write:equipment_events` | ## Pricing and sell-through | Endpoint | What it does | Scope | | --- | --- | --- | | [`GET /commerce/order-lines`](https://docs.xplantpro.com/docs/api/commerce/list-order-lines.md) | List order lines | `read:commerce` | | [`GET /commerce/sell-through`](https://docs.xplantpro.com/docs/api/commerce/get-sell-through.md) | Get sell-through | `read:commerce` | | [`GET /pricing/culture-lines`](https://docs.xplantpro.com/docs/api/commerce/list-culture-line-prices.md) | List culture line prices | `read:pricing` | | [`GET /pricing/events`](https://docs.xplantpro.com/docs/api/commerce/list-price-events.md) | List price changes | `read:pricing` | --- # Get the calling key > Who the calling key is: its name, the workspace it acts in, the scopes it holds, and which of them it can use right now. Make this the first call an integration makes: it confirms the key works and tells you in one answer what it may do, instead of one 403 at a time. Source: https://docs.xplantpro.com/docs/api/account/get-me `GET https://app.xplantpro.com/api/v1/me` - Required scope: none - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint A key never does more than its owner can in xPlant. `effectiveScopes` drops any scope above the owner's current role, and on a plan that includes connected devices only (`apiAccess: "devices"`) it keeps just the device scopes. It needs no scope, and it returns nothing about the workspace beyond its id. ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/me \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const me = await client.me.get(); console.log(me.scopes); // e.g. ["read:plants", "write:sensor_readings"] ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/me", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") me = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `key` | object | Yes | The key this request was made with. | | `key.id` | string (uuid) | Yes | The key's id. | | `key.name` | string | Yes | The name the key was given when it was created. | | `key.prefix` | string | Yes | The visible start of the key, as xPlant's key list shows it. Never the whole key. | | `key.environment` | string | Yes | `production` for an `xpk_live_` key, `development` for an `xpk_dev_` key. Both act on the same workspace. | | `key.status` | string | Yes | Always `active` here: a revoked key is refused before this endpoint answers. | | `key.lastUsedAt` | string \| null | Yes | When the key was used before this request, or null if this is its first. | | `key.createdAt` | string | Yes | When the key was created. | | `scopes` | string[] | Yes | Every scope the key was created with, such as `read:plants` or `write:transfers`. | | `effectiveScopes` | string[] | Yes | The scopes the key can use right now: its scopes, capped at what its owner's current role may do in xPlant and at what the workspace's plan includes. Check here before calling rather than discovering a limit from a `403` or a `402`. | | `role` | string | Yes | The key owner's role in the workspace as of this request: `owner`, `admin`, `manager`, `member`, `viewer` or `guest`. A key never does more than this role can in xPlant. | | `apiAccess` | `"full"` \| `"devices"` | Yes | `full` when the workspace's plan includes the API (xPlant+ Teams and Enterprise). `devices` on plans that include connected devices only: the key can register devices, manage their tokens, and post their readings and events. | | `workspace` | object | Yes | The workspace the key acts in. A key belongs to exactly one. | | `workspace.id` | string (uuid) | Yes | The workspace's id. | | `user` | object | Yes | The xPlant account the key belongs to. | | `user.id` | string \| null (uuid) | Yes | The person the key belongs to. | ```json title="Response" { "ok": true, "data": { "key": { "id": "3f6a2c1e-9b7d-4e21-8c5a-1d0e7f9b2a64", "name": "Bench station 3", "prefix": "xpk_live_4f1c9e2ab70", "environment": "production", "status": "active", "lastUsedAt": "2026-09-24T17:02:11.000Z", "createdAt": "2026-08-30T09:15:00.000Z" }, "scopes": [ "read:plants", "read:transfers", "write:transfers" ], "effectiveScopes": [ "read:plants", "read:transfers", "write:transfers" ], "role": "member", "apiAccess": "full", "workspace": { "id": "8c2d4f6a-1b3e-4a5c-9d7e-0f1a2b3c4d5e" }, "user": { "id": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60" } } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `403` | `DEVICE_TOKEN_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | This endpoint can also answer a `5xx` with a `…_FAILED` code, which is safe to retry later. Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get the workspace > The lab this key acts in, with its name — so an integration can show which lab it is writing to, and stop if it has been pointed at the wrong one. Source: https://docs.xplantpro.com/docs/api/account/list-workspaces `GET https://app.xplantpro.com/api/v1/workspaces` - Required scope: `read:workspace` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint A key belongs to one workspace, so the list has exactly one entry even when the person who created the key belongs to several labs. It is a list so that your code does not change shape if that ever changes. Takes no paging parameters. ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/workspaces \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const [workspace] = await client.workspaces.list(); console.log(workspace.name); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/workspaces", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") workspaces = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. Lists have no total: a page shorter than your `limit` is the last one. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `name` | string | Yes | The lab's name as it appears in xPlant. | | `type` | string | Yes | `lab` for a lab, `team` for a group inside one. | ```json title="Response" { "ok": true, "data": [ { "id": "8c2d4f6a-1b3e-4a5c-9d7e-0f1a2b3c4d5e", "name": "Riverside Propagation Lab", "type": "lab" } ] } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `WORKSPACE_QUERY_FAILED` | The workspace could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List plants > The workspace's plants, newest first, a page at a time. Pass externalId to find the one plant filed under your own identifier instead; the answer is still a list, with that plant or nothing, and meta.next_cursor is always null. Source: https://docs.xplantpro.com/docs/api/plants/list-plants `GET https://app.xplantpro.com/api/v1/plants` - Required scope: `read:plants` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint The SDK takes `external_id`; the query parameter is `externalId`. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `externalId` | string | No | Your own identifier for a plant, matched ignoring case. Returns that one plant, or an empty list; paging does not apply. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/plants?limit=50" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const plants = await client.plants.list({ limit: 50 }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/plants", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"limit": 50}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") plants = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `name` | string | Yes | What the lab calls the plant: its common name, or its species when it has none. | | `species` | string | Yes | — | | `status` | string | Yes | Where the plant stands in the lab, usually one of `active`, `dormant`, `harvested`, `contaminated`, `failed`, `in_culture`, `ready_for_transfer`, `quarantined`, `archived`. | | `workspace_id` | string \| null | Yes | The workspace the plant belongs to. | | `created_at` | string \| null | Yes | ISO 8601 timestamp. | | `external_id` | string \| null | Yes | Your own identifier for the plant, exactly as it was given, or null when it has none. | | `custom_fields` | object | Yes | The lab's own fields for this record, keyed by each field's key as set up in the lab's settings. Values are text, numbers, `true` or `false`, or dates written as `YYYY-MM-DD`; a field left blank is absent or `null`. Empty when the lab has set up no fields. | ```json title="Response" { "ok": true, "data": [ { "id": "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", "name": "Zebra Alocasia", "species": "Alocasia zebrina", "status": "active", "workspace_id": "3a9e7c21-5d4b-4c8f-a1e6-9b2d0f4c7e13", "created_at": "2026-09-25T09:30:00.000Z", "external_id": "LINE-0412", "custom_fields": { "mother_stock_tray": "B4", "weeks_in_culture": 12 } } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `PLANT_QUERY_FAILED` | The plants could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Create a plant > Adds a plant to the workspace, recorded as created by the key's owner, and starts it in initial_stage. Source: https://docs.xplantpro.com/docs/api/plants/create-plant `POST https://app.xplantpro.com/api/v1/plants` - Required scope: `write:plants` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) Give it your own identifier in `external_id` to find it again with `GET /api/v1/plants?externalId=`. Fill in the lab's own fields with `custom_fields`: each value is checked against its field, and the whole request is refused if one does not fit. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `species` | string | Yes | The plant's species, for example `Alocasia zebrina`. 1–200 characters. | | `common_name` | string \| null | No | The name the lab calls the plant. Without one, the plant is shown by its species. Up to 200 characters. | | `genus` | string \| null | No | Up to 100 characters. | | `family` | string \| null | No | Up to 100 characters. | | `cultivar` | string \| null | No | Up to 200 characters. | | `source` | string \| null | No | Where the plant came from — a supplier, a nursery or another collection. Up to 200 characters. | | `notes` | string \| null | No | Up to 5000 characters. | | `status` | `"active"` \| `"dormant"` \| `"harvested"` \| `"contaminated"` \| `"failed"` \| `"in_culture"` \| `"ready_for_transfer"` \| `"quarantined"` \| `"archived"` | No | Where the plant stands in the lab. New plants default to `active`. | | `initial_stage` | `"Mother Block"` \| `"Acclimation"` \| `"Production"` \| `"Cold Storage"` \| `"Quarantine"` \| `"Propagation"` \| `"Hardening Off"` \| `"Greenhouse"` \| `"Field"` \| `"Discarded"` | No | The stage the plant starts in. Defaults to `Mother Block`. | | `external_id` | string \| null | No | Your own identifier for the plant, such as a line or accession code from your own records. It must be unique in the workspace, ignoring case, and it is how `GET /api/v1/plants?externalId=` finds the plant again. Send `null` to clear it. 1–100 characters. | | `custom_fields` | object | No | Values for the lab's own fields, keyed by each field's key as set up in the lab's settings. Each value must fit its field: text, a number, `true` or `false`, a date, or one of a pick-list's options (a date is stored as `YYYY-MM-DD`). A key the lab has not set up is refused, and the values may total at most 10,000 bytes. Send `null` as a value to leave a field empty. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/plants \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: import-2026-09-25-row-0412" \ -H "Content-Type: application/json" \ -d '{ "species": "Alocasia zebrina", "common_name": "Zebra Alocasia", "genus": "Alocasia", "source": "Stock line from the lab'\''s own collection", "initial_stage": "Mother Block", "external_id": "LINE-0412", "custom_fields": { "mother_stock_tray": "B4", "weeks_in_culture": 12 } }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const { plant, warning } = await client.plants.create( { species: "Alocasia zebrina", common_name: "Zebra Alocasia", genus: "Alocasia", source: "Stock line from the lab's own collection", initial_stage: "Mother Block", external_id: "LINE-0412", custom_fields: { mother_stock_tray: "B4", weeks_in_culture: 12 }, }, { idempotencyKey: "import-2026-09-25-row-0412" }, ); if (warning) console.warn(warning); // the first stage couldn't be recorded ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/plants", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "import-2026-09-25-row-0412", }, json={ "species": "Alocasia zebrina", "common_name": "Zebra Alocasia", "genus": "Alocasia", "source": "Stock line from the lab's own collection", "initial_stage": "Mother Block", "external_id": "LINE-0412", "custom_fields": {"mother_stock_tray": "B4", "weeks_in_culture": 12}, }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") plant = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `name` | string | Yes | What the lab calls the plant: its common name, or its species when it has none. | | `species` | string | Yes | — | | `status` | string | Yes | Where the plant stands in the lab, usually one of `active`, `dormant`, `harvested`, `contaminated`, `failed`, `in_culture`, `ready_for_transfer`, `quarantined`, `archived`. | | `workspace_id` | string \| null | Yes | The workspace the plant belongs to. | | `created_at` | string \| null | Yes | ISO 8601 timestamp. | | `external_id` | string \| null | Yes | Your own identifier for the plant, exactly as it was given, or null when it has none. | | `custom_fields` | object | Yes | The lab's own fields for this record, keyed by each field's key as set up in the lab's settings. Values are text, numbers, `true` or `false`, or dates written as `YYYY-MM-DD`; a field left blank is absent or `null`. Empty when the lab has set up no fields. | ```json title="Response" { "ok": true, "data": { "id": "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", "name": "Zebra Alocasia", "species": "Alocasia zebrina", "status": "active", "workspace_id": "3a9e7c21-5d4b-4c8f-a1e6-9b2d0f4c7e13", "created_at": "2026-09-25T09:30:00.000Z", "external_id": "LINE-0412", "custom_fields": { "mother_stock_tray": "B4", "weeks_in_culture": 12 } } } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `warning` | string | Yes | Sent only when the plant was saved but its first stage could not be set. Record one with `POST /api/v1/stages`. | | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `402` | `PLAN_LIMIT_REACHED` | The key's owner has created as many plants as their plan allows. `error` says which limit. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `409` | `DUPLICATE_ENTRY` | Another plant in the workspace already uses this `external_id`, including one that has been deleted. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `422` | `VALIDATION_ERROR` | `custom_fields` names a field the lab has not set up, gives a field a value that does not fit it, or totals more than 10,000 bytes. `error` names the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `PLANT_CREATE_FAILED` | The plant could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get a plant > One plant, with the lab's own fields. Source: https://docs.xplantpro.com/docs/api/plants/get-plant `GET https://app.xplantpro.com/api/v1/plants/{id}` - Required scope: `read:plants` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/plants/0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90 \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const plant = await client.plants.get("0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90"); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/plants/0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") plant = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `name` | string | Yes | What the lab calls the plant: its common name, or its species when it has none. | | `species` | string | Yes | — | | `status` | string | Yes | Where the plant stands in the lab, usually one of `active`, `dormant`, `harvested`, `contaminated`, `failed`, `in_culture`, `ready_for_transfer`, `quarantined`, `archived`. | | `workspace_id` | string \| null | Yes | The workspace the plant belongs to. | | `created_at` | string \| null | Yes | ISO 8601 timestamp. | | `external_id` | string \| null | Yes | Your own identifier for the plant, exactly as it was given, or null when it has none. | | `custom_fields` | object | Yes | The lab's own fields for this record, keyed by each field's key as set up in the lab's settings. Values are text, numbers, `true` or `false`, or dates written as `YYYY-MM-DD`; a field left blank is absent or `null`. Empty when the lab has set up no fields. | ```json title="Response" { "ok": true, "data": { "id": "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", "name": "Zebra Alocasia", "species": "Alocasia zebrina", "status": "active", "workspace_id": "3a9e7c21-5d4b-4c8f-a1e6-9b2d0f4c7e13", "created_at": "2026-09-25T09:30:00.000Z", "external_id": "LINE-0412", "custom_fields": { "mother_stock_tray": "B4", "weeks_in_culture": 12 } } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No record with this id exists in the key's workspace. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `PLANT_QUERY_FAILED` | The plant could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Update a plant > Changes only the fields you send. Send null to clear an optional one. Source: https://docs.xplantpro.com/docs/api/plants/update-plant `PATCH https://app.xplantpro.com/api/v1/plants/{id}` - Required scope: `write:plants` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint `custom_fields` changes only the keys you name: the plant's other values stay as they are, and `null` clears a field. A key can change the plants its owner created. Changing a teammate's plant needs the key's owner to be a manager or owner of the lab. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `species` | string | No | 1–200 characters. | | `common_name` | string \| null | No | Up to 200 characters. | | `genus` | string \| null | No | Up to 100 characters. | | `family` | string \| null | No | Up to 100 characters. | | `cultivar` | string \| null | No | Up to 200 characters. | | `source` | string \| null | No | Up to 200 characters. | | `notes` | string \| null | No | Up to 5000 characters. | | `status` | `"active"` \| `"dormant"` \| `"harvested"` \| `"contaminated"` \| `"failed"` \| `"in_culture"` \| `"ready_for_transfer"` \| `"quarantined"` \| `"archived"` | No | Where the plant stands in the lab. New plants default to `active`. | | `external_id` | string \| null | No | Your own identifier for the plant, such as a line or accession code from your own records. It must be unique in the workspace, ignoring case, and it is how `GET /api/v1/plants?externalId=` finds the plant again. Send `null` to clear it. 1–100 characters. | | `custom_fields` | object | No | Values for the lab's own fields, keyed by each field's key. Only the keys you send change; the plant's other values stay as they are. Send `null` as a value to clear that field. Each value must fit its field, a key the lab has not set up is refused, and the record's values together may total at most 10,000 bytes. | ## Example **curl** ```bash curl -X PATCH https://app.xplantpro.com/api/v1/plants/0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90 \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "ready_for_transfer", "custom_fields": { "weeks_in_culture": 13 } }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const plant = await client.plants.update( "0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90", { status: "ready_for_transfer", custom_fields: { weeks_in_culture: 13 }, }, ); ``` **Python** ```python import os import requests resp = requests.patch( "https://app.xplantpro.com/api/v1/plants/0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={ "status": "ready_for_transfer", "custom_fields": {"weeks_in_culture": 13}, }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") plant = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `name` | string | Yes | What the lab calls the plant: its common name, or its species when it has none. | | `species` | string | Yes | — | | `status` | string | Yes | Where the plant stands in the lab, usually one of `active`, `dormant`, `harvested`, `contaminated`, `failed`, `in_culture`, `ready_for_transfer`, `quarantined`, `archived`. | | `workspace_id` | string \| null | Yes | The workspace the plant belongs to. | | `created_at` | string \| null | Yes | ISO 8601 timestamp. | | `external_id` | string \| null | Yes | Your own identifier for the plant, exactly as it was given, or null when it has none. | | `custom_fields` | object | Yes | The lab's own fields for this record, keyed by each field's key as set up in the lab's settings. Values are text, numbers, `true` or `false`, or dates written as `YYYY-MM-DD`; a field left blank is absent or `null`. Empty when the lab has set up no fields. | ```json title="Response" { "ok": true, "data": { "id": "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", "name": "Zebra Alocasia", "species": "Alocasia zebrina", "status": "ready_for_transfer", "workspace_id": "3a9e7c21-5d4b-4c8f-a1e6-9b2d0f4c7e13", "created_at": "2026-09-25T09:30:00.000Z", "external_id": "LINE-0412", "custom_fields": { "mother_stock_tray": "B4", "weeks_in_culture": 13 } } } ``` | 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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `403` | `PLANT_WRITE_FORBIDDEN` | The plant was created by someone else, and the key's owner is not a manager or owner of the lab. | | `404` | `NOT_FOUND` | No record with this id exists in the key's workspace. | | `409` | `DUPLICATE_ENTRY` | Another plant in the workspace already uses this `external_id`, including one that has been deleted. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `422` | `VALIDATION_ERROR` | `custom_fields` names a field the lab has not set up, gives a field a value that does not fit it, or totals more than 10,000 bytes. `error` names the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `PLANT_UPDATE_FAILED` | The change could not be saved. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List explants > The workspace's explants, newest first, a page at a time. Pass externalId to find the one culture filed under your own identifier instead; the answer is still a list, with that explant or nothing, and meta.next_cursor is always null. Source: https://docs.xplantpro.com/docs/api/explants/list-explants `GET https://app.xplantpro.com/api/v1/explants` - Required scope: `read:explants` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint The SDK takes `external_id`; the query parameter is `externalId`. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `externalId` | string | No | Your own identifier for a culture, matched ignoring case against the identifier you gave it, then its batch number, then its label. Returns that one explant, or an empty list; paging does not apply. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/explants?externalId=LINE-0412" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const explants = await client.explants.list({ external_id: "LINE-0412" }); // Or resolve one of your own codes straight to a record (or null): const batch = await client.explants.findByExternalId("LINE-0412"); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/explants", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"externalId": "LINE-0412"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") explants = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `label` | string \| null | Yes | The culture's label — the name the lab knows it by. | | `external_id` | string \| null | Yes | Your identifier for the culture: the `external_id` you gave it, otherwise its batch number, otherwise its label. Null when it has none of these. | | `status` | string | Yes | Where the culture stands, usually one of `active`, `establishing`, `growing`, `needs_subculture`, `quarantined`, `senescing`, `discarded`, `retired`, `lost`. | | `plant_id` | string \| null | Yes | The plant the explant was taken from, when one is recorded. | | `workspace_id` | string \| null | Yes | The workspace the explant belongs to. | | `initial_count` | integer \| null | Yes | The count recorded when the culture was started. | | `current_count` | integer \| null | Yes | The culture's count as last recorded. | | `created_at` | string \| null | Yes | ISO 8601 timestamp. | | `custom_fields` | object | Yes | The lab's own fields for this record, keyed by each field's key as set up in the lab's settings. Values are text, numbers, `true` or `false`, or dates written as `YYYY-MM-DD`; a field left blank is absent or `null`. Empty when the lab has set up no fields. | ```json title="Response" { "ok": true, "data": [ { "id": "b7e4a2c9-1f3d-4e8b-8a6c-5d2f9e1b7c34", "label": "Zebrina line A", "external_id": "LINE-0412-A", "status": "establishing", "plant_id": "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", "workspace_id": "3a9e7c21-5d4b-4c8f-a1e6-9b2d0f4c7e13", "initial_count": null, "current_count": null, "created_at": "2026-09-25T10:05:00.000Z", "custom_fields": { "medium_lot": "MS-2609-03", "cytokinin_added": true } } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `EXPLANT_QUERY_FAILED` | The explants could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Create an explant > Adds an explant to the workspace, recorded as created by the key's owner. Link it to the plant it was taken from with plant_id. Source: https://docs.xplantpro.com/docs/api/explants/create-explant `POST https://app.xplantpro.com/api/v1/explants` - Required scope: `write:explants` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) Give it your own identifier in `external_id` to find it again with `GET /api/v1/explants?externalId=`. Fill in the lab's own fields with `custom_fields`: each value is checked against its field, and the whole request is refused if one does not fit. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `label` | string | Yes | The culture's label — the name the lab knows it by. 1–200 characters. | | `plant_id` | string \| null (uuid) | No | The plant the explant was taken from. It must be a plant in the same workspace. | | `batch_number` | string \| null | No | Up to 100 characters. | | `notes` | string \| null | No | Up to 5000 characters. | | `status` | `"active"` \| `"establishing"` \| `"growing"` \| `"needs_subculture"` \| `"quarantined"` \| `"senescing"` \| `"discarded"` \| `"retired"` \| `"lost"` | No | Where the culture stands. New explants default to `active`. | | `external_id` | string \| null | No | Your own identifier for this culture, such as a batch or line code from your own records. It must be unique in the workspace, ignoring case, and it is how `GET /api/v1/explants?externalId=` finds the explant again. Send `null` to clear it. 1–100 characters. | | `custom_fields` | object | No | Values for the lab's own fields, keyed by each field's key as set up in the lab's settings. Each value must fit its field: text, a number, `true` or `false`, a date, or one of a pick-list's options (a date is stored as `YYYY-MM-DD`). A key the lab has not set up is refused, and the values may total at most 10,000 bytes. Send `null` as a value to leave a field empty. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/explants \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: import-2026-09-25-line-0412" \ -H "Content-Type: application/json" \ -d '{ "label": "Zebrina line A", "plant_id": "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", "status": "establishing", "external_id": "LINE-0412-A", "custom_fields": { "medium_lot": "MS-2609-03", "cytokinin_added": true } }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const explant = await client.explants.create( { label: "Zebrina line A", plant_id: "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", status: "establishing", external_id: "LINE-0412-A", custom_fields: { medium_lot: "MS-2609-03", cytokinin_added: true }, }, { idempotencyKey: "import-2026-09-25-line-0412" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/explants", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "import-2026-09-25-line-0412", }, json={ "label": "Zebrina line A", "plant_id": "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", "status": "establishing", "external_id": "LINE-0412-A", "custom_fields": {"medium_lot": "MS-2609-03", "cytokinin_added": True}, }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") explant = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `label` | string \| null | Yes | The culture's label — the name the lab knows it by. | | `external_id` | string \| null | Yes | Your identifier for the culture: the `external_id` you gave it, otherwise its batch number, otherwise its label. Null when it has none of these. | | `status` | string | Yes | Where the culture stands, usually one of `active`, `establishing`, `growing`, `needs_subculture`, `quarantined`, `senescing`, `discarded`, `retired`, `lost`. | | `plant_id` | string \| null | Yes | The plant the explant was taken from, when one is recorded. | | `workspace_id` | string \| null | Yes | The workspace the explant belongs to. | | `initial_count` | integer \| null | Yes | The count recorded when the culture was started. | | `current_count` | integer \| null | Yes | The culture's count as last recorded. | | `created_at` | string \| null | Yes | ISO 8601 timestamp. | | `custom_fields` | object | Yes | The lab's own fields for this record, keyed by each field's key as set up in the lab's settings. Values are text, numbers, `true` or `false`, or dates written as `YYYY-MM-DD`; a field left blank is absent or `null`. Empty when the lab has set up no fields. | ```json title="Response" { "ok": true, "data": { "id": "b7e4a2c9-1f3d-4e8b-8a6c-5d2f9e1b7c34", "label": "Zebrina line A", "external_id": "LINE-0412-A", "status": "establishing", "plant_id": "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", "workspace_id": "3a9e7c21-5d4b-4c8f-a1e6-9b2d0f4c7e13", "initial_count": null, "current_count": null, "created_at": "2026-09-25T10:05:00.000Z", "custom_fields": { "medium_lot": "MS-2609-03", "cytokinin_added": true } } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `402` | `PLAN_LIMIT_REACHED` | The key's owner has created as many explants as their plan allows. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `409` | `DUPLICATE_ENTRY` | Another explant in the workspace already uses this `external_id`, including one that has been deleted. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `422` | `VALIDATION_ERROR` | `plant_id` is not a plant in the workspace. | | `422` | `VALIDATION_ERROR` | `custom_fields` names a field the lab has not set up, gives a field a value that does not fit it, or totals more than 10,000 bytes. `error` names the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `EXPLANT_CREATE_FAILED` | The explant could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get an explant > One explant, with the lab's own fields. Source: https://docs.xplantpro.com/docs/api/explants/get-explant `GET https://app.xplantpro.com/api/v1/explants/{id}` - Required scope: `read:explants` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/explants/7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const explant = await client.explants.get("7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e"); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/explants/7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") explant = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `label` | string \| null | Yes | The culture's label — the name the lab knows it by. | | `external_id` | string \| null | Yes | Your identifier for the culture: the `external_id` you gave it, otherwise its batch number, otherwise its label. Null when it has none of these. | | `status` | string | Yes | Where the culture stands, usually one of `active`, `establishing`, `growing`, `needs_subculture`, `quarantined`, `senescing`, `discarded`, `retired`, `lost`. | | `plant_id` | string \| null | Yes | The plant the explant was taken from, when one is recorded. | | `workspace_id` | string \| null | Yes | The workspace the explant belongs to. | | `initial_count` | integer \| null | Yes | The count recorded when the culture was started. | | `current_count` | integer \| null | Yes | The culture's count as last recorded. | | `created_at` | string \| null | Yes | ISO 8601 timestamp. | | `custom_fields` | object | Yes | The lab's own fields for this record, keyed by each field's key as set up in the lab's settings. Values are text, numbers, `true` or `false`, or dates written as `YYYY-MM-DD`; a field left blank is absent or `null`. Empty when the lab has set up no fields. | ```json title="Response" { "ok": true, "data": { "id": "b7e4a2c9-1f3d-4e8b-8a6c-5d2f9e1b7c34", "label": "Zebrina line A", "external_id": "LINE-0412-A", "status": "establishing", "plant_id": "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", "workspace_id": "3a9e7c21-5d4b-4c8f-a1e6-9b2d0f4c7e13", "initial_count": null, "current_count": null, "created_at": "2026-09-25T10:05:00.000Z", "custom_fields": { "medium_lot": "MS-2609-03", "cytokinin_added": true } } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No record with this id exists in the key's workspace. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `EXPLANT_QUERY_FAILED` | The explant could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Update an explant > Changes only the fields you send. Send null to clear an optional one. Source: https://docs.xplantpro.com/docs/api/explants/update-explant `PATCH https://app.xplantpro.com/api/v1/explants/{id}` - Required scope: `write:explants` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint `custom_fields` changes only the keys you name: the explant's other values stay as they are, and `null` clears a field. A key can change the explants its owner created. Changing a teammate's explant needs the key's owner to be a manager or owner of the lab. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `label` | string | No | 1–200 characters. | | `batch_number` | string \| null | No | Up to 100 characters. | | `notes` | string \| null | No | Up to 5000 characters. | | `status` | `"active"` \| `"establishing"` \| `"growing"` \| `"needs_subculture"` \| `"quarantined"` \| `"senescing"` \| `"discarded"` \| `"retired"` \| `"lost"` | No | Where the culture stands. New explants default to `active`. | | `external_id` | string \| null | No | Your own identifier for this culture, such as a batch or line code from your own records. It must be unique in the workspace, ignoring case, and it is how `GET /api/v1/explants?externalId=` finds the explant again. Send `null` to clear it. 1–100 characters. | | `custom_fields` | object | No | Values for the lab's own fields, keyed by each field's key. Only the keys you send change; the explant's other values stay as they are. Send `null` as a value to clear that field. Each value must fit its field, a key the lab has not set up is refused, and the record's values together may total at most 10,000 bytes. | ## Example **curl** ```bash curl -X PATCH https://app.xplantpro.com/api/v1/explants/7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "growing", "custom_fields": { "cytokinin_added": false } }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const explant = await client.explants.update( "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", { status: "growing", custom_fields: { cytokinin_added: false }, }, ); ``` **Python** ```python import os import requests resp = requests.patch( "https://app.xplantpro.com/api/v1/explants/7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={ "status": "growing", "custom_fields": {"cytokinin_added": False}, }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") explant = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `label` | string \| null | Yes | The culture's label — the name the lab knows it by. | | `external_id` | string \| null | Yes | Your identifier for the culture: the `external_id` you gave it, otherwise its batch number, otherwise its label. Null when it has none of these. | | `status` | string | Yes | Where the culture stands, usually one of `active`, `establishing`, `growing`, `needs_subculture`, `quarantined`, `senescing`, `discarded`, `retired`, `lost`. | | `plant_id` | string \| null | Yes | The plant the explant was taken from, when one is recorded. | | `workspace_id` | string \| null | Yes | The workspace the explant belongs to. | | `initial_count` | integer \| null | Yes | The count recorded when the culture was started. | | `current_count` | integer \| null | Yes | The culture's count as last recorded. | | `created_at` | string \| null | Yes | ISO 8601 timestamp. | | `custom_fields` | object | Yes | The lab's own fields for this record, keyed by each field's key as set up in the lab's settings. Values are text, numbers, `true` or `false`, or dates written as `YYYY-MM-DD`; a field left blank is absent or `null`. Empty when the lab has set up no fields. | ```json title="Response" { "ok": true, "data": { "id": "b7e4a2c9-1f3d-4e8b-8a6c-5d2f9e1b7c34", "label": "Zebrina line A", "external_id": "LINE-0412-A", "status": "growing", "plant_id": "8c3f2d1a-6b4e-4f7a-9d2c-1e5b7a9c3f60", "workspace_id": "3a9e7c21-5d4b-4c8f-a1e6-9b2d0f4c7e13", "initial_count": null, "current_count": null, "created_at": "2026-09-25T10:05:00.000Z", "custom_fields": { "medium_lot": "MS-2609-03", "cytokinin_added": false } } } ``` | 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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `403` | `EXPLANT_WRITE_FORBIDDEN` | The explant was created by someone else, and the key's owner is not a manager or owner of the lab. | | `404` | `NOT_FOUND` | No record with this id exists in the key's workspace. | | `409` | `DUPLICATE_ENTRY` | Another explant in the workspace already uses this `external_id`, including one that has been deleted. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `422` | `VALIDATION_ERROR` | `custom_fields` names a field the lab has not set up, gives a field a value that does not fit it, or totals more than 10,000 bytes. `error` names the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `EXPLANT_UPDATE_FAILED` | The change could not be saved. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List stages > Every stage one plant or explant has been through, newest first — where it is now and how it got there. Send exactly one of plant_id or explant_id. Source: https://docs.xplantpro.com/docs/api/transfers-and-stages/list-stages `GET https://app.xplantpro.com/api/v1/stages` - Required scope: `read:transfers` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Stage names are your lab's own, so expect the keys from your workspace's stage list rather than a fixed set. The list is ordered by `entered_on`, which can be corrected after the fact: a stage whose date is changed while you page through can move between pages. Stages entered on the same day keep a fixed order. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `plant_id` | string (uuid) | No | The plant whose stage history to read. Send exactly one of `plant_id` or `explant_id`. | | `explant_id` | string (uuid) | No | The explant whose stage history to read. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/stages?plant_id=0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const stages = await client.stages.list({ plant_id: "0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90" }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/stages", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"plant_id": "0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") stages = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `entity_type` | `"plant"` \| `"explant"` | Yes | Whether the stage belongs to a plant or an explant. | | `entity_id` | string (uuid) | Yes | The plant's or explant's id. | | `stage` | string | Yes | The stage, usually as a key from your lab's stage list such as `multiplication`. Records made by older tools can carry the stage's display name instead, such as `Multiplication`. | | `status` | string | Yes | `active` for the stage the plant or explant is in now, `completed` for one it has moved on from. Stages can also be `failed` or `archived`. | | `entered_on` | string \| null | Yes | When it entered this stage, as an ISO 8601 timestamp. | | `completed_at` | string \| null | Yes | When it moved on from this stage. Null while the stage is current. | | `room_id` | string \| null (uuid) | Yes | The room it was in for this stage, when one was recorded. | | `notes` | string \| null | Yes | — | | `created_at` | string \| null | Yes | — | ```json title="Response" { "ok": true, "data": [ { "id": "6e5d4c3b-2a19-4807-96a5-b4c3d2e1f0a9", "entity_type": "explant", "entity_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "stage": "multiplication", "status": "active", "entered_on": "2026-09-24T00:00:00+00:00", "completed_at": null, "room_id": null, "notes": "Shoot clusters forming well on the Alocasia line.", "created_at": "2026-09-24T08:41:17.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | The plant or explant is not in this workspace. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | Neither or both of `plant_id` and `explant_id` were sent. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `STAGE_QUERY_FAILED` | The stage history could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Advance a stage > Moves a plant or explant into a new stage in one call: the stage it is in now is marked completed, the new one is recorded, and it becomes the current stage. Send exactly one of plant_id or explant_id. Source: https://docs.xplantpro.com/docs/api/transfers-and-stages/create-stage `POST https://app.xplantpro.com/api/v1/stages` - Required scope: `write:transfers` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) Every successful move answers with `meta` beside the stage: `meta.previous_stage_id` names the stage that was closed, so your system can mirror the change without reading the history back. Send an `Idempotency-Key` when you might retry: a repeated move would otherwise close the stage the first attempt opened. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `plant_id` | string (uuid) | No | The plant this is for. Send exactly one of `plant_id` or `explant_id`. | | `explant_id` | string (uuid) | No | The explant this is for. Send exactly one of `plant_id` or `explant_id`. | | `stage` | string | Yes | The stage to move into, as a key from your lab's stage list — for example `multiplication` or `root_induction`. It is stored as sent and not checked against the list, so send a key your lab already uses. 1–50 characters. | | `entered_on` | string | No | When it entered the stage: a date such as `2026-09-24`, or a full ISO 8601 timestamp. Defaults to today (UTC). | | `room_id` | string (uuid) | No | The growing room it is in for this stage. Must be a room in the workspace. | | `notes` | string | No | Up to 5000 characters. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/stages \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "explant_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "stage": "multiplication", "entered_on": "2026-09-24", "notes": "Shoot clusters forming well on the Alocasia line." }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const stage = await client.stages.advance({ explant_id: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", stage: "multiplication", entered_on: "2026-09-24", notes: "Shoot clusters forming well on the Alocasia line.", }); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/stages", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={ "explant_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "stage": "multiplication", "entered_on": "2026-09-24", "notes": "Shoot clusters forming well on the Alocasia line.", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") stage = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `entity_type` | `"plant"` \| `"explant"` | Yes | Whether the stage belongs to a plant or an explant. | | `entity_id` | string (uuid) | Yes | The plant's or explant's id. | | `stage` | string | Yes | The stage, usually as a key from your lab's stage list such as `multiplication`. Records made by older tools can carry the stage's display name instead, such as `Multiplication`. | | `status` | string | Yes | `active` for the stage the plant or explant is in now, `completed` for one it has moved on from. Stages can also be `failed` or `archived`. | | `entered_on` | string \| null | Yes | When it entered this stage, as an ISO 8601 timestamp. | | `completed_at` | string \| null | Yes | When it moved on from this stage. Null while the stage is current. | | `room_id` | string \| null (uuid) | Yes | The room it was in for this stage, when one was recorded. | | `notes` | string \| null | Yes | — | | `created_at` | string \| null | Yes | — | ```json title="Response" { "ok": true, "data": { "id": "6e5d4c3b-2a19-4807-96a5-b4c3d2e1f0a9", "entity_type": "explant", "entity_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "stage": "multiplication", "status": "active", "entered_on": "2026-09-24T00:00:00+00:00", "completed_at": null, "room_id": null, "notes": "Shoot clusters forming well on the Alocasia line.", "created_at": "2026-09-24T08:41:17.000Z" } } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `previous_stage_id` | string \| null (uuid) | No | The stage this one replaced, which is now `completed`. Null when there was no current stage. | | `warning` | string | No | Sent instead of `previous_stage_id` when the stage was saved but could not be made the current one. The history is complete; set the current stage in xPlant. | | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | The plant or explant, or the growing room named in `room_id`, is not in this workspace. Nothing was changed. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation, or neither or both of `plant_id` and `explant_id` were sent. `error` names the first problem, for example `stage: stage is required`. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `STAGE_ADVANCE_FAILED` | The stage could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List transfers > The transfer history of one plant or explant, newest first: every move onto fresh media, where it went and which subculture it was. Send exactly one of plant_id or explant_id. Source: https://docs.xplantpro.com/docs/api/transfers-and-stages/list-transfers `GET https://app.xplantpro.com/api/v1/transfers` - Required scope: `read:transfers` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Each transfer carries your lab's own fields in `custom_fields`. The list is ordered by `transfer_date`, which changes when a planned transfer is postponed or edited: a transfer changed while you page through can move between pages. Transfers on the same date keep a fixed order. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `plant_id` | string (uuid) | No | The plant whose transfers to list. Send exactly one of `plant_id` or `explant_id`. | | `explant_id` | string (uuid) | No | The explant whose transfers to list. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/transfers?explant_id=7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const transfers = await client.transfers.list({ explant_id: "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e" }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/transfers", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"explant_id": "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") transfers = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `entity_type` | `"plant"` \| `"explant"` | Yes | Whether the transfer was of a plant or an explant. | | `entity_id` | string (uuid) | Yes | The plant's or explant's id. | | `transfer_date` | string \| null | Yes | When the transfer was done, as an ISO 8601 timestamp. | | `transfer_cycle` | integer \| null | Yes | Which subculture this was for the plant or explant — 1 for the first. | | `from_location` | string \| null | Yes | Where the culture was before the transfer. | | `to_location` | string \| null | Yes | Where the culture went. | | `status` | string | Yes | `active` for a transfer recorded through the API. Transfers scheduled in xPlant read `pending` until someone marks them `completed` or `cancelled`. | | `notes` | string \| null | Yes | — | | `created_at` | string \| null | Yes | — | | `custom_fields` | object | Yes | Your lab's own fields on this transfer, keyed by field key. Always an object: `{}` when none were filled in, and always `{}` on a plant transfer. | ```json title="Response" { "ok": true, "data": [ { "id": "c7d8e9f0-a1b2-4c3d-8e4f-5a6b7c8d9e0f", "entity_type": "explant", "entity_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "transfer_date": "2026-09-24T00:00:00+00:00", "transfer_cycle": 5, "from_location": "Growth room 1, shelf B2", "to_location": "Growth room 1, shelf C1", "status": "active", "notes": "LINE-0412 onto fresh multiplication medium. Two jars browning at the base, set aside.", "created_at": "2026-09-24T09:12:44.000Z", "custom_fields": { "vessel_lot": "LOT-2291", "hood": "Hood 2" } } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | The plant or explant is not in this workspace. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | Neither or both of `plant_id` and `explant_id` were sent. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `TRANSFER_QUERY_FAILED` | The transfers could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Record a transfer > Records a transfer — a subculture onto fresh media — for one plant or explant. Send exactly one of plant_id or explant_id. Everything else is optional: the date defaults to today and the cycle to the next one in the record's history. Source: https://docs.xplantpro.com/docs/api/transfers-and-stages/create-transfer `POST https://app.xplantpro.com/api/v1/transfers` - Required scope: `write:transfers` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) Fill in the fields your lab has added to transfers with `custom_fields`. They are checked against your lab's field list exactly as the transfer form in xPlant checks them, so a key the lab has not defined, or a value of the wrong kind, is refused before anything is saved. Send an `Idempotency-Key` when you might retry: a repeated transfer would also advance the cycle twice. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `plant_id` | string (uuid) | No | The plant this is for. Send exactly one of `plant_id` or `explant_id`. | | `explant_id` | string (uuid) | No | The explant this is for. Send exactly one of `plant_id` or `explant_id`. | | `transfer_date` | string | No | When the transfer was done: a date such as `2026-09-24`, or a full ISO 8601 timestamp. Defaults to today (UTC). | | `from_location` | string | No | Where the culture was before the transfer, in your lab's own words — a room, shelf or hood. Up to 200 characters. | | `to_location` | string | No | Where the culture went. Up to 200 characters. | | `transfer_cycle` | integer | No | Which subculture this is for the plant or explant — 1 for the first. Defaults to one more than the highest cycle already recorded for it. At least 1. | | `notes` | string | No | Up to 5000 characters. | | `custom_fields` | object \| null | No | Values for the fields your lab has added to transfers, keyed by field key — for example `{ "vessel_lot": "LOT-2291" }`. Each value must suit its field: text, a number, `true` or `false`, a date (stored as `YYYY-MM-DD`), or one of a list field's options. `null` leaves a field empty. A key your lab has not defined is refused, and the values may total at most 10,000 bytes. Explant transfers only: a plant transfer takes no custom fields. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/transfers \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "explant_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "transfer_date": "2026-09-24", "from_location": "Growth room 1, shelf B2", "to_location": "Growth room 1, shelf C1", "notes": "LINE-0412 onto fresh multiplication medium. Two jars browning at the base, set aside.", "custom_fields": { "vessel_lot": "LOT-2291", "hood": "Hood 2" } }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const transfer = await client.transfers.create({ explant_id: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", transfer_date: "2026-09-24", from_location: "Growth room 1, shelf B2", to_location: "Growth room 1, shelf C1", notes: "LINE-0412 onto fresh multiplication medium. Two jars browning at the base, set aside.", custom_fields: { vessel_lot: "LOT-2291", hood: "Hood 2" }, }); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/transfers", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={ "explant_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "transfer_date": "2026-09-24", "from_location": "Growth room 1, shelf B2", "to_location": "Growth room 1, shelf C1", "notes": "LINE-0412 onto fresh multiplication medium. Two jars browning at the base, set aside.", "custom_fields": {"vessel_lot": "LOT-2291", "hood": "Hood 2"}, }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") transfer = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `entity_type` | `"plant"` \| `"explant"` | Yes | Whether the transfer was of a plant or an explant. | | `entity_id` | string (uuid) | Yes | The plant's or explant's id. | | `transfer_date` | string \| null | Yes | When the transfer was done, as an ISO 8601 timestamp. | | `transfer_cycle` | integer \| null | Yes | Which subculture this was for the plant or explant — 1 for the first. | | `from_location` | string \| null | Yes | Where the culture was before the transfer. | | `to_location` | string \| null | Yes | Where the culture went. | | `status` | string | Yes | `active` for a transfer recorded through the API. Transfers scheduled in xPlant read `pending` until someone marks them `completed` or `cancelled`. | | `notes` | string \| null | Yes | — | | `created_at` | string \| null | Yes | — | | `custom_fields` | object | Yes | Your lab's own fields on this transfer, keyed by field key. Always an object: `{}` when none were filled in, and always `{}` on a plant transfer. | ```json title="Response" { "ok": true, "data": { "id": "c7d8e9f0-a1b2-4c3d-8e4f-5a6b7c8d9e0f", "entity_type": "explant", "entity_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "transfer_date": "2026-09-24T00:00:00+00:00", "transfer_cycle": 5, "from_location": "Growth room 1, shelf B2", "to_location": "Growth room 1, shelf C1", "status": "active", "notes": "LINE-0412 onto fresh multiplication medium. Two jars browning at the base, set aside.", "created_at": "2026-09-24T09:12:44.000Z", "custom_fields": { "vessel_lot": "LOT-2291", "hood": "Hood 2" } } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | The plant or explant is not in this workspace. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation, or neither or both of `plant_id` and `explant_id` were sent. `error` names the first problem, for example `transfer_cycle: Number must be greater than or equal to 1`. | | `422` | `VALIDATION_ERROR` | `custom_fields` names a field your lab has not defined, gives a field a value of the wrong kind, or was sent for a plant transfer. `error` names the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `TRANSFER_CREATE_FAILED` | The transfer could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List change history > What has happened to the workspace's plants or explants, oldest first: transfers, stage changes, contamination records, observations, printed labels. Source: https://docs.xplantpro.com/docs/api/change-history/list-change-events `GET https://app.xplantpro.com/api/v1/events` - Required scope: `read:events` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Use it to keep your own system in step with xPlant without downloading everything again: 1. The first time, call with `entity=plant` and follow `meta.next_cursor` until it comes back `null`. Do the same with `entity=explant`. 2. Save the newest `created_at` you received for each. 3. Next time, pass that value as `since` and follow the cursor again from the first page. Several events can share one `created_at` — a batch import records many at once. The cursor keeps them in a fixed order, so a walk neither skips nor repeats one; still walk a `since` window to its last page before you move `since` forward, and skip any `id` you already hold. The history is written by work done in xPlant. Transfers and stage moves recorded through this API's own endpoints do not add events here; their responses already carry what was saved. See also: [Pull change history](https://docs.xplantpro.com/docs/guides/change-history.md). ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `entity` | `"plant"` \| `"explant"` | Yes | Which history to read. Required: plant and explant histories are separate, so sync each with its own calls. | | `since` | string | No | Only events recorded after this moment, as an ISO 8601 timestamp such as `2026-09-24T00:00:00Z`. Pass the newest `created_at` you already hold. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/events?entity=plant&since=2026-09-01T00%3A00%3A00Z" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const events = await client.events.list({ entity: "plant", since: "2026-09-01T00:00:00Z" }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/events", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"entity": "plant", "since": "2026-09-01T00:00:00Z"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") events = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The event's id. Events are never edited, so an id you already hold is the same event. | | `entity_type` | `"plant"` \| `"explant"` | Yes | Whether the event is about a plant or an explant. | | `entity_id` | string (uuid) | Yes | The plant's or explant's id. | | `stage_id` | string \| null (uuid) | Yes | The stage the event was recorded against, when there was one. | | `event_type` | string | Yes | What happened — for example `transfer`, `stage_change`, `contamination`, `observation` or `label_print`. New kinds can appear; treat one you do not recognise as informational. | | `event_time` | string | Yes | When it happened in the lab, as an ISO 8601 timestamp. | | `recorded_by` | string \| null (uuid) | Yes | The workspace member who recorded it, when known. | | `payload` | any | Yes | The event's details, as a JSON value whose fields depend on `event_type` — a transfer, for example, carries its cycle, locations and vessel counts. Can be null. | | `created_at` | string | Yes | When xPlant recorded the event. Pass the newest one you hold back as `since`. | ```json title="Response" { "ok": true, "data": [ { "id": "4b3a2918-0716-4f5e-8d4c-3b2a19087f6e", "entity_type": "explant", "entity_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "stage_id": "6e5d4c3b-2a19-4807-96a5-b4c3d2e1f0a9", "event_type": "transfer", "event_time": "2026-09-24T00:00:00+00:00", "recorded_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "payload": { "transfer_id": "c7d8e9f0-a1b2-4c3d-8e4f-5a6b7c8d9e0f", "transfer_cycle": 5, "from_location": "Growth room 1, shelf B2", "to_location": "Growth room 1, shelf C1", "observed_vessel_count": 12, "plantlet_count": 48 }, "created_at": "2026-09-24T09:12:45.118Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | `entity` is missing or is not `plant` or `explant`, or `since` is not a valid timestamp. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `EVENT_QUERY_FAILED` | The history could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List contaminations > The workspace's contamination logs, newest first. Narrow to one plant or explant, to one status, or to what was logged since a point in time. Withdrawn logs are never listed. Source: https://docs.xplantpro.com/docs/api/contaminations/list-contaminations `GET https://app.xplantpro.com/api/v1/contaminations` - Required scope: `read:contaminations` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `plant_id` | string (uuid) | No | Only contaminations linked to this plant. | | `explant_id` | string (uuid) | No | Only contaminations linked to this explant. | | `status` | `"active"` \| `"resolved"` \| `"quarantined"` \| `"archived"` \| `"under investigation"` | No | Only contaminations in this state. | | `since` | string (date-time) | No | Only contaminations logged at or after this ISO 8601 timestamp. Keep the newest `created_at` you have received and send it back to fetch only what is new. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/contaminations?explant_id=7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e&status=active" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const contaminations = await client.contaminations.list({ explant_id: "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", status: "active", }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/contaminations", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={ "explant_id": "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", "status": "active", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") contaminations = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `workspace_id` | string \| null | Yes | The workspace the log belongs to. | | `type` | string \| null | Yes | What was seen: `mold`, `bacteria`, `hyperhydricity`, `phenolic`, `algae`, `yeast`, `endophytic`, `viral`, `fungal`, `physiological`, `contaminated_media`, `damage`, `insect`, `other`. | | `type_other` | string \| null | Yes | The contamination in the logger's own words, when `type` is `other`. | | `issue` | string | Yes | A short summary of what was seen. | | `description` | string \| null | Yes | — | | `notes` | string \| null | Yes | — | | `severity` | string | Yes | How serious it is: `very low`, `low`, `medium`, `high`, `critical`. | | `status` | string | Yes | Where it stands: `active`, `resolved`, `quarantined`, `archived`, `under investigation`. | | `observed_at` | string \| null | Yes | When it was seen. ISO 8601. | | `resolved_at` | string \| null | Yes | When it was marked resolved. ISO 8601. | | `vessels_affected` | integer \| null | Yes | How many vessels it reached. Null when nobody counted, which is not the same as none. | | `plants_affected` | integer \| null | Yes | How many plants it reached. Null when nobody counted, which is not the same as none. | | `affected_vessel_markings` | string \| null | Yes | Which vessels, as written on them. | | `custom_fields` | object | Yes | Your lab's own fields on this contamination, keyed by field key. Always an object: `{}` when none were filled in. | | `plant_ids` | string (uuid)[] | Yes | The plants the log is linked to. | | `explant_ids` | string (uuid)[] | Yes | The explants the log is linked to. | | `logged_by` | string (uuid) | Yes | User id of the workspace member who logged it. | | `created_at` | string \| null | Yes | When it was logged. ISO 8601. | | `updated_at` | string \| null | Yes | When it last changed. ISO 8601. | ```json title="Response" { "ok": true, "data": [ { "id": "8a3c1e57-4b2d-4f60-9c81-2d7e5b9a0f14", "workspace_id": "3f9d2b61-7c4e-4a85-b0d3-6e1f8a2c5b97", "type": "bacteria", "type_other": null, "issue": "Cloudy halo around the base of the Phalaenopsis explant", "description": null, "notes": "Noticed at the weekly check on shelf 3.", "severity": "medium", "status": "active", "observed_at": "2026-09-24T08:30:00.000Z", "resolved_at": null, "vessels_affected": 2, "plants_affected": null, "affected_vessel_markings": "P-07 / P-09", "custom_fields": { "hood": "Hood 2" }, "plant_ids": [], "explant_ids": [ "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96" ], "logged_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "created_at": "2026-09-24T08:41:12.000Z", "updated_at": "2026-09-24T08:41:12.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | `plant_id` or `explant_id` names no plant or explant in the key's workspace; `error` says which. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | A filter is malformed; `error` names it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `CONTAMINATION_QUERY_FAILED` | The logs could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Record a contamination > Records a contamination seen on one plant or explant, as the key's owner. It is the same record the app makes: it appears in the plant's or explant's history, and for an explant the stage and room the culture was in when it was seen are recorded with it, which is what lets the app show that room's conditions over the week before. Source: https://docs.xplantpro.com/docs/api/contaminations/create-contamination `POST https://app.xplantpro.com/api/v1/contaminations` - Required scope: `write:contaminations` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) Send `observed_at` when logging something seen earlier; it defaults to now. Fill in the fields your lab has added to contaminations with `custom_fields`; they are checked against your lab's field list exactly as the app's form checks them. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `plant_id` | string (uuid) | No | The plant the contamination was seen on. Send exactly one of `plant_id` or `explant_id`. | | `explant_id` | string (uuid) | No | The explant the contamination was seen on. Send exactly one of `plant_id` or `explant_id`. | | `type` | `"mold"` \| `"bacteria"` \| `"hyperhydricity"` \| `"phenolic"` \| `"algae"` \| `"yeast"` \| `"endophytic"` \| `"viral"` \| `"fungal"` \| `"physiological"` \| `"contaminated_media"` \| `"damage"` \| `"insect"` \| `"other"` | Yes | What was seen. Use `other` and describe it in `type_other` when nothing in the list fits. | | `type_other` | string | No | What the contamination is, in your own words. Required when `type` is `other`, ignored otherwise. 1–200 characters. | | `issue` | string | Yes | A short summary of what was seen, as it should read in a list. 1–300 characters. | | `description` | string | No | Up to 5000 characters. | | `notes` | string | No | Up to 5000 characters. | | `severity` | `"very low"` \| `"low"` \| `"medium"` \| `"high"` \| `"critical"` | No | Default `"low"`. | | `status` | `"active"` \| `"resolved"` \| `"quarantined"` \| `"archived"` \| `"under investigation"` | No | Where the contamination stands. A new observation is `active`; send another state only when recording one that has already been dealt with. Default `"active"`. | | `suspected_source` | `"airborne"` \| `"cross"` \| `"media"` \| `"observed"` \| `"tool"` \| `"transferred"` \| `"unknown"` | No | Where you think it came from, if you have a view. Leave it out rather than guess. | | `observed_at` | string (date-time) | No | When it was seen, as an ISO 8601 timestamp. Defaults to now. For an explant, the stage and room it was in at that moment are recorded with the observation. | | `vessels_affected` | integer | No | How many vessels it reached. Leave it out if nobody counted: `0` means counted and none were affected. From 0 to 1000000. | | `plants_affected` | integer | No | How many plants it reached. Leave it out if nobody counted: `0` means counted and none were affected. From 0 to 1000000. | | `affected_vessel_markings` | string | No | Which vessels, as written on them — for example `B-12 / B-14`. Up to 500 characters. | | `custom_fields` | object \| null | No | Values for the fields your lab has added to contaminations, keyed by field key — for example `{ "hood": "Hood 2" }`. Each value must suit its field: text, a number, `true` or `false`, a date (stored as `YYYY-MM-DD`), or one of a list field's options. `null` leaves a field empty. A key your lab has not defined is refused. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/contaminations \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: scanner-3-line-0412-contamination" \ -H "Content-Type: application/json" \ -d '{ "explant_id": "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96", "type": "bacteria", "issue": "Cloudy halo around the base of the Phalaenopsis explant", "notes": "Noticed at the weekly check on shelf 3.", "severity": "medium", "observed_at": "2026-09-24T08:30:00Z", "vessels_affected": 2, "affected_vessel_markings": "P-07 / P-09", "custom_fields": { "hood": "Hood 2" } }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const contamination = await client.contaminations.create( { explant_id: "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96", type: "bacteria", issue: "Cloudy halo around the base of the Phalaenopsis explant", notes: "Noticed at the weekly check on shelf 3.", severity: "medium", observed_at: "2026-09-24T08:30:00Z", vessels_affected: 2, affected_vessel_markings: "P-07 / P-09", custom_fields: { hood: "Hood 2" }, }, { idempotencyKey: "scanner-3-line-0412-contamination" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/contaminations", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "scanner-3-line-0412-contamination", }, json={ "explant_id": "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96", "type": "bacteria", "issue": "Cloudy halo around the base of the Phalaenopsis explant", "notes": "Noticed at the weekly check on shelf 3.", "severity": "medium", "observed_at": "2026-09-24T08:30:00Z", "vessels_affected": 2, "affected_vessel_markings": "P-07 / P-09", "custom_fields": {"hood": "Hood 2"}, }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") contamination = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `workspace_id` | string \| null | Yes | The workspace the log belongs to. | | `type` | string \| null | Yes | What was seen: `mold`, `bacteria`, `hyperhydricity`, `phenolic`, `algae`, `yeast`, `endophytic`, `viral`, `fungal`, `physiological`, `contaminated_media`, `damage`, `insect`, `other`. | | `type_other` | string \| null | Yes | The contamination in the logger's own words, when `type` is `other`. | | `issue` | string | Yes | A short summary of what was seen. | | `description` | string \| null | Yes | — | | `notes` | string \| null | Yes | — | | `severity` | string | Yes | How serious it is: `very low`, `low`, `medium`, `high`, `critical`. | | `status` | string | Yes | Where it stands: `active`, `resolved`, `quarantined`, `archived`, `under investigation`. | | `observed_at` | string \| null | Yes | When it was seen. ISO 8601. | | `resolved_at` | string \| null | Yes | When it was marked resolved. ISO 8601. | | `vessels_affected` | integer \| null | Yes | How many vessels it reached. Null when nobody counted, which is not the same as none. | | `plants_affected` | integer \| null | Yes | How many plants it reached. Null when nobody counted, which is not the same as none. | | `affected_vessel_markings` | string \| null | Yes | Which vessels, as written on them. | | `custom_fields` | object | Yes | Your lab's own fields on this contamination, keyed by field key. Always an object: `{}` when none were filled in. | | `plant_ids` | string (uuid)[] | Yes | The plants the log is linked to. | | `explant_ids` | string (uuid)[] | Yes | The explants the log is linked to. | | `logged_by` | string (uuid) | Yes | User id of the workspace member who logged it. | | `created_at` | string \| null | Yes | When it was logged. ISO 8601. | | `updated_at` | string \| null | Yes | When it last changed. ISO 8601. | ```json title="Response" { "ok": true, "data": { "id": "8a3c1e57-4b2d-4f60-9c81-2d7e5b9a0f14", "workspace_id": "3f9d2b61-7c4e-4a85-b0d3-6e1f8a2c5b97", "type": "bacteria", "type_other": null, "issue": "Cloudy halo around the base of the Phalaenopsis explant", "description": null, "notes": "Noticed at the weekly check on shelf 3.", "severity": "medium", "status": "active", "observed_at": "2026-09-24T08:30:00.000Z", "resolved_at": null, "vessels_affected": 2, "plants_affected": null, "affected_vessel_markings": "P-07 / P-09", "custom_fields": { "hood": "Hood 2" }, "plant_ids": [], "explant_ids": [ "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96" ], "logged_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "created_at": "2026-09-24T08:41:12.000Z", "updated_at": "2026-09-24T08:41:12.000Z" } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `403` | `FORBIDDEN` | The key's owner is below the member role in the workspace. A key never does more than its owner can in xPlant; `error` names the role needed. | | `404` | `NOT_FOUND` | `plant_id` or `explant_id` names no plant or explant in the key's workspace; `error` says which. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `422` | `VALIDATION_ERROR` | `custom_fields` names a field your lab has not defined, or gives a field a value of the wrong kind. `error` names the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `CONTAMINATION_CREATE_FAILED` | The log could not be saved, and nothing was kept. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get a contamination > One contamination log, with the plants and explants it is linked to. Source: https://docs.xplantpro.com/docs/api/contaminations/get-contamination `GET https://app.xplantpro.com/api/v1/contaminations/{id}` - Required scope: `read:contaminations` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/contaminations/6a8c0e2b-4d6f-4a8c-9e0b-2d4f6a8c0e2b \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const contamination = await client.contaminations.get("6a8c0e2b-4d6f-4a8c-9e0b-2d4f6a8c0e2b"); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/contaminations/6a8c0e2b-4d6f-4a8c-9e0b-2d4f6a8c0e2b", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") contamination = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `workspace_id` | string \| null | Yes | The workspace the log belongs to. | | `type` | string \| null | Yes | What was seen: `mold`, `bacteria`, `hyperhydricity`, `phenolic`, `algae`, `yeast`, `endophytic`, `viral`, `fungal`, `physiological`, `contaminated_media`, `damage`, `insect`, `other`. | | `type_other` | string \| null | Yes | The contamination in the logger's own words, when `type` is `other`. | | `issue` | string | Yes | A short summary of what was seen. | | `description` | string \| null | Yes | — | | `notes` | string \| null | Yes | — | | `severity` | string | Yes | How serious it is: `very low`, `low`, `medium`, `high`, `critical`. | | `status` | string | Yes | Where it stands: `active`, `resolved`, `quarantined`, `archived`, `under investigation`. | | `observed_at` | string \| null | Yes | When it was seen. ISO 8601. | | `resolved_at` | string \| null | Yes | When it was marked resolved. ISO 8601. | | `vessels_affected` | integer \| null | Yes | How many vessels it reached. Null when nobody counted, which is not the same as none. | | `plants_affected` | integer \| null | Yes | How many plants it reached. Null when nobody counted, which is not the same as none. | | `affected_vessel_markings` | string \| null | Yes | Which vessels, as written on them. | | `custom_fields` | object | Yes | Your lab's own fields on this contamination, keyed by field key. Always an object: `{}` when none were filled in. | | `plant_ids` | string (uuid)[] | Yes | The plants the log is linked to. | | `explant_ids` | string (uuid)[] | Yes | The explants the log is linked to. | | `logged_by` | string (uuid) | Yes | User id of the workspace member who logged it. | | `created_at` | string \| null | Yes | When it was logged. ISO 8601. | | `updated_at` | string \| null | Yes | When it last changed. ISO 8601. | ```json title="Response" { "ok": true, "data": { "id": "8a3c1e57-4b2d-4f60-9c81-2d7e5b9a0f14", "workspace_id": "3f9d2b61-7c4e-4a85-b0d3-6e1f8a2c5b97", "type": "bacteria", "type_other": null, "issue": "Cloudy halo around the base of the Phalaenopsis explant", "description": null, "notes": "Noticed at the weekly check on shelf 3.", "severity": "medium", "status": "active", "observed_at": "2026-09-24T08:30:00.000Z", "resolved_at": null, "vessels_affected": 2, "plants_affected": null, "affected_vessel_markings": "P-07 / P-09", "custom_fields": { "hood": "Hood 2" }, "plant_ids": [], "explant_ids": [ "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96" ], "logged_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "created_at": "2026-09-24T08:41:12.000Z", "updated_at": "2026-09-24T08:41:12.000Z" } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No record with this id exists in the key's workspace. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `CONTAMINATION_QUERY_FAILED` | The log could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List tasks > The workspace's tasks, earliest due first. Filter by board column or assignee; with neither, you get the whole queue a page at a time. Source: https://docs.xplantpro.com/docs/api/tasks/list-tasks `GET https://app.xplantpro.com/api/v1/tasks` - Required scope: `read:tasks` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint A task's due date changes when it is rescheduled, so a task rescheduled while you page through the list can move between pages: it may appear twice, or not at all. Tasks due at the same moment keep a fixed order. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | `"backlog"` \| `"todo"` \| `"in_progress"` \| `"waiting_blocked"` \| `"review"` \| `"done"` | No | Only tasks in this board column. | | `assigned_to` | string (uuid) | No | Only tasks assigned to this workspace member (user id). | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/tasks?status=todo&limit=50" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const tasks = await client.tasks.list({ status: "todo", limit: 50 }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/tasks", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"status": "todo", "limit": 50}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") tasks = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `title` | string | Yes | — | | `status` | string | Yes | Board column: `backlog`, `todo`, `in_progress`, `waiting_blocked`, `review`, `done`. | | `due_date` | string \| null | Yes | ISO 8601 timestamp. | | `assigned_to` | string \| null | Yes | User id of the workspace member the task is assigned to. | | `priority` | string \| null | Yes | — | | `priority_rank` | number \| null | Yes | Queue position. Lower sorts first; null means the priority label decides. | | `priority_source` | string | Yes | Where the current order came from: `default`, `auto`, `manual`. `manual` means a person placed the task, and automated writes leave it where it is unless they send `release: true`. | | `category` | string \| null | Yes | — | | `created_at` | string \| null | Yes | — | | `entity_link` | object \| null | No | The plant or explant the task is about. Returned when you create, fetch or update one task; lists leave it out. | | `entity_link.entity_type` | `"plant"` \| `"explant"` | Yes | — | | `entity_link.entity_id` | string (uuid) | Yes | — | ```json title="Response" { "ok": true, "data": [ { "id": "5b0f6f3e-2c1a-4d8e-9f47-0a6c3e1b7d22", "title": "Transfer the Alocasia line onto fresh medium", "status": "todo", "due_date": "2026-10-02T09:00:00.000Z", "assigned_to": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "priority": "high", "priority_rank": 1.5, "priority_source": "auto", "category": "transfer", "created_at": "2026-09-25T14:12:03.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `TASK_QUERY_FAILED` | The tasks could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Create a task > Adds a task to the workspace. Set priority, priority_rank and assigned_to in the same call so a scheduler can place work in one request. Order set this way counts as automatic, so a later hand-reorder in the app takes precedence over your next sync. Source: https://docs.xplantpro.com/docs/api/tasks/create-task `POST https://app.xplantpro.com/api/v1/tasks` - Required scope: `write:tasks` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) Link the task to the plant or explant it is about with `entity_type` and `entity_id` — send both or neither. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | Yes | 1–300 characters. | | `due_date` | string (date-time) | No | — | | `is_all_day` | boolean | No | — | | `category` | `"media_prep"` \| `"transfer"` \| `"contamination"` \| `"subculture"` \| `"sop_review"` \| `"acclimation"` \| `"cleaning"` \| `"monitoring"` \| `"other"` | No | Default `"media_prep"`. | | `notes` | string | No | Up to 5000 characters. | | `priority` | `"low"` \| `"medium"` \| `"high"` \| `"urgent"` | No | — | | `priority_rank` | number \| null | No | — | | `workflow_status` | `"backlog"` \| `"todo"` \| `"in_progress"` \| `"waiting_blocked"` \| `"review"` \| `"done"` | No | Default `"todo"`. | | `assigned_to` | string (uuid) | No | — | | `genus` | string | No | 1–100 characters. | | `entity_type` | `"plant"` \| `"explant"` | No | — | | `entity_id` | string (uuid) | No | — | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/tasks \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: bench-3-subculture-line-0412" \ -H "Content-Type: application/json" \ -d '{ "title": "Transfer the Alocasia line onto fresh medium", "due_date": "2026-10-02T09:00:00.000Z", "category": "transfer", "priority": "high", "priority_rank": 1.5, "assigned_to": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "genus": "Alocasia" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const task = await client.tasks.create( { title: "Transfer the Alocasia line onto fresh medium", due_date: "2026-10-02T09:00:00.000Z", category: "transfer", priority: "high", priority_rank: 1.5, assigned_to: "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", genus: "Alocasia", }, { idempotencyKey: "bench-3-subculture-line-0412" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/tasks", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "bench-3-subculture-line-0412", }, json={ "title": "Transfer the Alocasia line onto fresh medium", "due_date": "2026-10-02T09:00:00.000Z", "category": "transfer", "priority": "high", "priority_rank": 1.5, "assigned_to": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "genus": "Alocasia", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") task = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `title` | string | Yes | — | | `status` | string | Yes | Board column: `backlog`, `todo`, `in_progress`, `waiting_blocked`, `review`, `done`. | | `due_date` | string \| null | Yes | ISO 8601 timestamp. | | `assigned_to` | string \| null | Yes | User id of the workspace member the task is assigned to. | | `priority` | string \| null | Yes | — | | `priority_rank` | number \| null | Yes | Queue position. Lower sorts first; null means the priority label decides. | | `priority_source` | string | Yes | Where the current order came from: `default`, `auto`, `manual`. `manual` means a person placed the task, and automated writes leave it where it is unless they send `release: true`. | | `category` | string \| null | Yes | — | | `created_at` | string \| null | Yes | — | | `entity_link` | object \| null | No | The plant or explant the task is about. Returned when you create, fetch or update one task; lists leave it out. | | `entity_link.entity_type` | `"plant"` \| `"explant"` | Yes | — | | `entity_link.entity_id` | string (uuid) | Yes | — | ```json title="Response" { "ok": true, "data": { "id": "5b0f6f3e-2c1a-4d8e-9f47-0a6c3e1b7d22", "title": "Transfer the Alocasia line onto fresh medium", "status": "todo", "due_date": "2026-10-02T09:00:00.000Z", "assigned_to": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "priority": "high", "priority_rank": 1.5, "priority_source": "auto", "category": "transfer", "created_at": "2026-09-25T14:12:03.000Z" } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `422` | `VALIDATION_ERROR` | `assigned_to` is not an active member of the workspace, or the linked plant or explant is not in it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `TASK_CREATE_FAILED` | The task could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get a task > One task, including the plant or explant it is linked to. Source: https://docs.xplantpro.com/docs/api/tasks/get-task `GET https://app.xplantpro.com/api/v1/tasks/{id}` - Required scope: `read:tasks` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/tasks/4e9a1c7b-2d3f-4b5a-9c8d-6e7f8a9b0c1d \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const task = await client.tasks.get("4e9a1c7b-2d3f-4b5a-9c8d-6e7f8a9b0c1d"); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/tasks/4e9a1c7b-2d3f-4b5a-9c8d-6e7f8a9b0c1d", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") task = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `title` | string | Yes | — | | `status` | string | Yes | Board column: `backlog`, `todo`, `in_progress`, `waiting_blocked`, `review`, `done`. | | `due_date` | string \| null | Yes | ISO 8601 timestamp. | | `assigned_to` | string \| null | Yes | User id of the workspace member the task is assigned to. | | `priority` | string \| null | Yes | — | | `priority_rank` | number \| null | Yes | Queue position. Lower sorts first; null means the priority label decides. | | `priority_source` | string | Yes | Where the current order came from: `default`, `auto`, `manual`. `manual` means a person placed the task, and automated writes leave it where it is unless they send `release: true`. | | `category` | string \| null | Yes | — | | `created_at` | string \| null | Yes | — | | `entity_link` | object \| null | No | The plant or explant the task is about. Returned when you create, fetch or update one task; lists leave it out. | | `entity_link.entity_type` | `"plant"` \| `"explant"` | Yes | — | | `entity_link.entity_id` | string (uuid) | Yes | — | ```json title="Response" { "ok": true, "data": { "id": "5b0f6f3e-2c1a-4d8e-9f47-0a6c3e1b7d22", "title": "Transfer the Alocasia line onto fresh medium", "status": "todo", "due_date": "2026-10-02T09:00:00.000Z", "assigned_to": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "priority": "high", "priority_rank": 1.5, "priority_source": "auto", "category": "transfer", "created_at": "2026-09-25T14:12:03.000Z", "entity_link": null } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No task with this id exists in the key's workspace, or `id` is not a well-formed id. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `TASK_QUERY_FAILED` | The task could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Update a task > Changes only the fields you send. Move a task along the board with workflow_status; complete it with "done". Source: https://docs.xplantpro.com/docs/api/tasks/update-task `PATCH https://app.xplantpro.com/api/v1/tasks/{id}` - Required scope: `write:tasks` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint A task someone placed by hand keeps its position through automated writes. Send `release: true` to hand it back to automatic ordering. Send `clear_entity_link: true` to unlink it from its plant or explant. See also: [Sync tasks](https://docs.xplantpro.com/docs/guides/sync-tasks.md). ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | No | 1–300 characters. | | `due_date` | string (date-time) | No | — | | `is_all_day` | boolean | No | — | | `category` | `"media_prep"` \| `"transfer"` \| `"contamination"` \| `"subculture"` \| `"sop_review"` \| `"acclimation"` \| `"cleaning"` \| `"monitoring"` \| `"other"` | No | — | | `notes` | string | No | Up to 5000 characters. | | `priority` | `"low"` \| `"medium"` \| `"high"` \| `"urgent"` | No | — | | `priority_rank` | number \| null | No | — | | `release` | boolean | No | — | | `workflow_status` | `"backlog"` \| `"todo"` \| `"in_progress"` \| `"waiting_blocked"` \| `"review"` \| `"done"` | No | — | | `assigned_to` | string (uuid) | No | — | | `genus` | string \| null | No | 1–100 characters. | | `entity_type` | `"plant"` \| `"explant"` | No | — | | `entity_id` | string (uuid) | No | — | | `clear_entity_link` | boolean | No | — | ## Example **curl** ```bash curl -X PATCH https://app.xplantpro.com/api/v1/tasks/4e9a1c7b-2d3f-4b5a-9c8d-6e7f8a9b0c1d \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "workflow_status": "done" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const { task, skipped } = await client.tasks.update( "4e9a1c7b-2d3f-4b5a-9c8d-6e7f8a9b0c1d", { workflow_status: "done" }, ); if (skipped) { // Someone ordered this task by hand; the ordering change was left out. } ``` **Python** ```python import os import requests resp = requests.patch( "https://app.xplantpro.com/api/v1/tasks/4e9a1c7b-2d3f-4b5a-9c8d-6e7f8a9b0c1d", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={"workflow_status": "done"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") result = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `title` | string | Yes | — | | `status` | string | Yes | Board column: `backlog`, `todo`, `in_progress`, `waiting_blocked`, `review`, `done`. | | `due_date` | string \| null | Yes | ISO 8601 timestamp. | | `assigned_to` | string \| null | Yes | User id of the workspace member the task is assigned to. | | `priority` | string \| null | Yes | — | | `priority_rank` | number \| null | Yes | Queue position. Lower sorts first; null means the priority label decides. | | `priority_source` | string | Yes | Where the current order came from: `default`, `auto`, `manual`. `manual` means a person placed the task, and automated writes leave it where it is unless they send `release: true`. | | `category` | string \| null | Yes | — | | `created_at` | string \| null | Yes | — | | `entity_link` | object \| null | No | The plant or explant the task is about. Returned when you create, fetch or update one task; lists leave it out. | | `entity_link.entity_type` | `"plant"` \| `"explant"` | Yes | — | | `entity_link.entity_id` | string (uuid) | Yes | — | ```json title="Response" { "ok": true, "data": { "id": "5b0f6f3e-2c1a-4d8e-9f47-0a6c3e1b7d22", "title": "Transfer the Alocasia line onto fresh medium", "status": "done", "due_date": "2026-10-02T09:00:00.000Z", "assigned_to": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "priority": "high", "priority_rank": 1.5, "priority_source": "auto", "category": "transfer", "created_at": "2026-09-25T14:12:03.000Z", "entity_link": null } } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `priority_write` | object | Yes | Sent when the update asked to change the task's order: whether it was applied, and why not if it was declined. Left out otherwise. | | `priority_write.applied` | boolean | Yes | Whether the requested ordering change was written. | | `priority_write.reason` | `"manual_override"` | No | Present only when the change was declined. | | `priority_write.priority_source` | `"default"` \| `"auto"` \| `"manual"` | Yes | Where the order stands now. | | `priority_write.message` | string | Yes | A plain-language explanation, safe to show in a sync log. | | 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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No task with this id exists in the key's workspace, or `id` is not a well-formed id. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `422` | `VALIDATION_ERROR` | `assigned_to` is not an active member of the workspace, or the linked plant or explant is not in it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `TASK_UPDATE_FAILED` | The change could not be saved. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List demand signals > Demand signals pushed for the workspace, newest first, a page at a time. Add genus to see one genus, and current=true as well to get its current demand as a single reading; that answer is never paged, and meta.next_cursor is always null. Source: https://docs.xplantpro.com/docs/api/tasks/list-demand-signals `GET https://app.xplantpro.com/api/v1/tasks/demand` - Required scope: `read:tasks` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `genus` | string | No | Only signals for this genus. | | `current` | `"true"` | No | With `genus`, return the genus's current demand as a single reading instead of its history. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/tasks/demand?genus=Alocasia¤t=true" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const signals = await client.taskDemand.list({ genus: "Alocasia", current: true }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/tasks/demand", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"genus": "Alocasia", "current": True}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") signals = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | The signal's id — or `current` on the reading `current=true` returns. | | `genus` | string | Yes | — | | `source_type` | string \| null | Yes | — | | `demand_score` | number | Yes | — | | `source` | string | Yes | — | | `observed_at` | string | Yes | — | | `created_at` | string | Yes | — | ```json title="Response" { "ok": true, "data": [ { "id": "c41e8a07-93d2-4b6f-8e15-7a2d0f9b3c58", "genus": "Alocasia", "source_type": "tissue", "demand_score": 42, "source": "storefront", "observed_at": "2026-09-24T00:00:00.000Z", "created_at": "2026-09-24T00:05:11.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `DEMAND_QUERY_FAILED` | The signals could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Record a demand signal > Records how much demand there is for a genus — orders, sales velocity, a forecast — from any source you run. xPlant uses the latest signal per genus when it scores the task queue. Send one whenever your number changes; history is kept. Source: https://docs.xplantpro.com/docs/api/tasks/create-demand-signal `POST https://app.xplantpro.com/api/v1/tasks/demand` - Required scope: `write:demand` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) See also: [Push demand signals](https://docs.xplantpro.com/docs/guides/demand-signals.md). Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `genus` | string | Yes | 1–100 characters. | | `source_type` | string | No | Up to 50 characters. | | `demand_score` | number | Yes | At least 0. | | `source` | string | Yes | 1–100 characters. | | `observed_at` | string (date-time) | No | — | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/tasks/demand \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "genus": "Alocasia", "source_type": "tissue", "demand_score": 42, "source": "storefront", "observed_at": "2026-09-24T00:00:00.000Z" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const signal = await client.taskDemand.record({ genus: "Alocasia", source_type: "tissue", demand_score: 42, source: "storefront", observed_at: "2026-09-24T00:00:00.000Z", }); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/tasks/demand", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={ "genus": "Alocasia", "source_type": "tissue", "demand_score": 42, "source": "storefront", "observed_at": "2026-09-24T00:00:00.000Z", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") signal = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | The signal's id — or `current` on the reading `current=true` returns. | | `genus` | string | Yes | — | | `source_type` | string \| null | Yes | — | | `demand_score` | number | Yes | — | | `source` | string | Yes | — | | `observed_at` | string | Yes | — | | `created_at` | string | Yes | — | ```json title="Response" { "ok": true, "data": { "id": "c41e8a07-93d2-4b6f-8e15-7a2d0f9b3c58", "genus": "Alocasia", "source_type": "tissue", "demand_score": 42, "source": "storefront", "observed_at": "2026-09-24T00:00:00.000Z", "created_at": "2026-09-24T00:05:11.000Z" } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `DEMAND_CREATE_FAILED` | The signal could not be saved. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List comments > The discussion on one plant, explant, contamination log, task, media recipe or SOP, oldest first. Replies carry parent_id. A deleted comment keeps its place, with its text removed, so the replies to it still read in order. Source: https://docs.xplantpro.com/docs/api/comments/list-comments `GET https://app.xplantpro.com/api/v1/comments` - Required scope: `read:comments` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `entity_type` | `"plant"` \| `"explant"` \| `"contamination"` \| `"task"` \| `"media_recipe"` \| `"sop"` | Yes | The kind of record whose comments to list. | | `entity_id` | string (uuid) | Yes | The id of the record whose comments to list. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/comments?entity_type=explant&entity_id=7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const comments = await client.comments.list({ entity_type: "explant", entity_id: "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/comments", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={ "entity_type": "explant", "entity_id": "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") comments = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `entity_type` | `"plant"` \| `"explant"` \| `"contamination"` \| `"task"` \| `"media_recipe"` \| `"sop"` | Yes | The kind of record the comment is on. | | `entity_id` | string (uuid) | Yes | The record the comment is on. | | `parent_id` | string \| null (uuid) | Yes | The comment this one replies to. | | `body` | string | Yes | The comment in Markdown, exactly as written — mentions and record links included as the `@name` and `#type:Label` text the author typed. | | `status` | string | Yes | `active`; `edited` once changed after posting; or `deleted`, which keeps the comment's place in the thread but not its text. | | `is_pinned` | boolean | Yes | Pinned to the top of the record's discussion. | | `author` | object | Yes | — | | `author.id` | string (uuid) | Yes | User id of the author. | | `author.name` | string \| null | Yes | The name shown beside the comment in the app, or null when there is none to show. | | `mentioned_user_ids` | string (uuid)[] | Yes | Workspace members the comment named. Each was notified. | | `references` | object[] | Yes | Records in the workspace the body links to. A record removed since is left out. | | `references[].entity_type` | `"plant"` \| `"explant"` \| `"contamination"` \| `"task"` \| `"media_recipe"` \| `"sop"` | Yes | — | | `references[].entity_id` | string (uuid) | Yes | — | | `references[].label` | string | Yes | The name the body links from. | | `created_at` | string \| null | Yes | ISO 8601. | | `updated_at` | string \| null | Yes | ISO 8601. | | `edited_at` | string \| null | Yes | When the text was last changed. ISO 8601. | ```json title="Response" { "ok": true, "data": [ { "id": "9c4e2a71-3d5b-4f86-b1e0-7a2d6c8f3e45", "entity_type": "explant", "entity_id": "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96", "parent_id": null, "body": "@sam the Alocasia line on shelf 2 is ready to go onto fresh medium. See #task:Subculture-Alocasia-line.", "status": "active", "is_pinned": false, "author": { "id": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "name": "Sam Okafor" }, "mentioned_user_ids": [ "4a8f1c63-2e7d-4b95-8c10-5d3e9f7a2b68" ], "references": [ { "entity_type": "task", "entity_id": "5b0f6f3e-2c1a-4d8e-9f47-0a6c3e1b7d22", "label": "Subculture Alocasia line" } ], "created_at": "2026-09-25T09:15:40.000Z", "updated_at": "2026-09-25T09:15:40.000Z", "edited_at": null } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | `entity_id` names no record of that `entity_type` in the key's workspace. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | `entity_type` or `entity_id` is missing or malformed. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `COMMENT_QUERY_FAILED` | The comments could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Add a comment > Adds a comment to a plant, explant, contamination log, task, media recipe or SOP, as the key's owner. It is the same comment the app makes: members it names are notified, the author of the comment it replies to is told, the people following a task hear about it, and it appears in the record's activity. Source: https://docs.xplantpro.com/docs/api/comments/create-comment `POST https://app.xplantpro.com/api/v1/comments` - Required scope: `write:comments` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) The key's owner needs the member role or above in the workspace, as in the app. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `entity_type` | `"plant"` \| `"explant"` \| `"contamination"` \| `"task"` \| `"media_recipe"` \| `"sop"` | Yes | The kind of record to comment on. | | `entity_id` | string (uuid) | Yes | The id of the record to comment on. | | `body` | string | Yes | The comment, in Markdown. Stored exactly as sent. To mention someone, write `@` and their username in the text and add their user id to `mentioned_user_ids`. To link a record, write `#plant:` (or `#explant:`, `#contamination:`, `#task:`, `#media:`, `#sop:`) followed by its name, and list it in `references`. 1–5000 characters. | | `parent_id` | string (uuid) | No | Reply to this comment. It must be on the same record. | | `mentioned_user_ids` | string (uuid)[] | No | Workspace members this comment names. Each is notified, exactly as when someone is mentioned in the app. Every id must be an active member of the workspace. Up to 25 items. | | `references` | object[] | No | Records in this workspace that the body links to. Up to 25 items. | | `references[].entity_type` | `"plant"` \| `"explant"` \| `"contamination"` \| `"task"` \| `"media_recipe"` \| `"sop"` | Yes | The kind of record the comment links to. | | `references[].entity_id` | string (uuid) | Yes | The linked record's id. | | `references[].label` | string | Yes | The record's name as it follows `#type:` in the body, where spaces are written as hyphens; either spelling is accepted here. The link is drawn over the matching text. 1–200 characters. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/comments \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: bench-3-line-0412-note-1" \ -H "Content-Type: application/json" \ -d '{ "entity_type": "explant", "entity_id": "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96", "body": "@sam the Alocasia line on shelf 2 is ready to go onto fresh medium. See #task:Subculture-Alocasia-line.", "mentioned_user_ids": [ "4a8f1c63-2e7d-4b95-8c10-5d3e9f7a2b68" ], "references": [ { "entity_type": "task", "entity_id": "5b0f6f3e-2c1a-4d8e-9f47-0a6c3e1b7d22", "label": "Subculture Alocasia line" } ] }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const comment = await client.comments.create( { entity_type: "explant", entity_id: "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96", body: "@sam the Alocasia line on shelf 2 is ready to go onto fresh medium. See #task:Subculture-Alocasia-line.", mentioned_user_ids: ["4a8f1c63-2e7d-4b95-8c10-5d3e9f7a2b68"], references: [ { entity_type: "task", entity_id: "5b0f6f3e-2c1a-4d8e-9f47-0a6c3e1b7d22", label: "Subculture Alocasia line", }, ], }, { idempotencyKey: "bench-3-line-0412-note-1" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/comments", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "bench-3-line-0412-note-1", }, json={ "entity_type": "explant", "entity_id": "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96", "body": "@sam the Alocasia line on shelf 2 is ready to go onto fresh medium. See #task:Subculture-Alocasia-line.", "mentioned_user_ids": ["4a8f1c63-2e7d-4b95-8c10-5d3e9f7a2b68"], "references": [ { "entity_type": "task", "entity_id": "5b0f6f3e-2c1a-4d8e-9f47-0a6c3e1b7d22", "label": "Subculture Alocasia line", }, ], }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") comment = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `entity_type` | `"plant"` \| `"explant"` \| `"contamination"` \| `"task"` \| `"media_recipe"` \| `"sop"` | Yes | The kind of record the comment is on. | | `entity_id` | string (uuid) | Yes | The record the comment is on. | | `parent_id` | string \| null (uuid) | Yes | The comment this one replies to. | | `body` | string | Yes | The comment in Markdown, exactly as written — mentions and record links included as the `@name` and `#type:Label` text the author typed. | | `status` | string | Yes | `active`; `edited` once changed after posting; or `deleted`, which keeps the comment's place in the thread but not its text. | | `is_pinned` | boolean | Yes | Pinned to the top of the record's discussion. | | `author` | object | Yes | — | | `author.id` | string (uuid) | Yes | User id of the author. | | `author.name` | string \| null | Yes | The name shown beside the comment in the app, or null when there is none to show. | | `mentioned_user_ids` | string (uuid)[] | Yes | Workspace members the comment named. Each was notified. | | `references` | object[] | Yes | Records in the workspace the body links to. A record removed since is left out. | | `references[].entity_type` | `"plant"` \| `"explant"` \| `"contamination"` \| `"task"` \| `"media_recipe"` \| `"sop"` | Yes | — | | `references[].entity_id` | string (uuid) | Yes | — | | `references[].label` | string | Yes | The name the body links from. | | `created_at` | string \| null | Yes | ISO 8601. | | `updated_at` | string \| null | Yes | ISO 8601. | | `edited_at` | string \| null | Yes | When the text was last changed. ISO 8601. | ```json title="Response" { "ok": true, "data": { "id": "9c4e2a71-3d5b-4f86-b1e0-7a2d6c8f3e45", "entity_type": "explant", "entity_id": "6d2f9a14-8b3e-4c71-a5d0-1f7e3b8c2a96", "parent_id": null, "body": "@sam the Alocasia line on shelf 2 is ready to go onto fresh medium. See #task:Subculture-Alocasia-line.", "status": "active", "is_pinned": false, "author": { "id": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "name": "Sam Okafor" }, "mentioned_user_ids": [ "4a8f1c63-2e7d-4b95-8c10-5d3e9f7a2b68" ], "references": [ { "entity_type": "task", "entity_id": "5b0f6f3e-2c1a-4d8e-9f47-0a6c3e1b7d22", "label": "Subculture Alocasia line" } ], "created_at": "2026-09-25T09:15:40.000Z", "updated_at": "2026-09-25T09:15:40.000Z", "edited_at": null } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `403` | `FORBIDDEN` | The key's owner is below the member role in the workspace. A key never does more than its owner can in xPlant; `error` names the role needed. | | `404` | `NOT_FOUND` | `entity_id` names no record of that `entity_type` in the workspace, or `parent_id` names no comment on it; `error` says which. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `409` | `CONFLICT` | The task's discussion is locked. Only the task's creator or a lab manager can comment. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `422` | `VALIDATION_ERROR` | A mentioned user is not an active member of the workspace, or a linked record is not in it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `COMMENT_CREATE_FAILED` | The comment could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List media files > The photos and files attached to one plant, explant, contamination log or SOP, newest first. Each carries a view_url: a link to the file itself that works for 15 minutes. List again for fresh links, and never store one. Source: https://docs.xplantpro.com/docs/api/media/list-assets `GET https://app.xplantpro.com/api/v1/assets` - Required scope: `read:assets` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `target` | `"plant"` \| `"explant"` \| `"contamination"` \| `"sop"` | Yes | The kind of record whose files to list: `plant`, `explant`, `contamination`, `sop`. | | `target_id` | string (uuid) | Yes | The id of that record. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/assets?target=explant&target_id=7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const assets = await client.assets.list({ target: "explant", target_id: "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/assets", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={ "target": "explant", "target_id": "7c1d9e2a-3b4f-4a5c-8d6e-1f2a3b4c5d6e", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") assets = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `target` | `"plant"` \| `"explant"` \| `"contamination"` \| `"sop"` | Yes | The kind of record the file is attached to. | | `target_id` | string (uuid) | Yes | The id of that record. | | `kind` | string | Yes | What the file is: `photo`, `video`, `annotation`, `document`, `diagram`, `scan`, `audio`, `other`. Images attached through the API are `photo`. | | `file_name` | string \| null | Yes | The file's name as xPlant shows it. | | `content_type` | string \| null | Yes | The file's media type, for example `image/jpeg`, `image/png`. | | `caption` | string \| null | Yes | The note stored with the file. | | `captured_at` | string \| null | Yes | When the photo was taken, where that was recorded. ISO 8601. | | `uploaded_by` | string \| null | Yes | User id of the workspace member who added the file. | | `created_at` | string \| null | Yes | When the file was added. ISO 8601. | | `view_url` | string \| null | Yes | A link to the file itself, valid for 15 minutes from this response. Fetch the asset again for a fresh link, and never store one. `null` when the file cannot be linked. | | `view_url_expires_at` | string \| null | Yes | When `view_url` stops working. ISO 8601. | ```json title="Response" { "ok": true, "data": [ { "id": "8c2f1a6e-4b3d-4f7a-9e21-5d6c7b8a9f10", "target": "explant", "target_id": "3f9a2c1e-7b4d-4e8f-a6c5-1d2e3f4a5b6c", "kind": "photo", "file_name": "vessel-12-week-3.png", "content_type": "image/png", "caption": "Callus forming at the cut edge", "captured_at": null, "uploaded_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "created_at": "2026-09-25T14:20:11.000Z", "view_url": "https://files.example.com/vessel-12-week-3.png?signature=4f9c2e", "view_url_expires_at": "2026-09-25T14:35:11.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | `target_id` does not name a record of that kind in the key's workspace. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | `target` or `target_id` is missing or not valid. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `ASSET_QUERY_FAILED` | The files could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Attach a media file > Attaches an image to a plant, explant, contamination log or SOP in the workspace. It appears on that record in xPlant exactly like a photo added in the app. Send the image as image_url or as image_base64 — exactly one. Source: https://docs.xplantpro.com/docs/api/media/create-asset `POST https://app.xplantpro.com/api/v1/assets` - Required scope: `write:assets` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) **Limits.** JPEG, PNG, WebP or GIF, up to 10 MB. The type is read from the image itself, not from its name. A request body can be at most about 4.5 MB, and base64 makes a file about a third larger, so send `image_base64` for images up to about 3 MB and `image_url` for anything larger. A request body over that size is refused with `413` before it reaches the API. **`image_url`.** xPlant downloads the image once, over `https`, from the public internet. Addresses on private or local networks are refused, redirects are not followed, and the download must finish within 10 seconds. The response's `view_url` works for 15 minutes. A retry with the same `Idempotency-Key` returns the first response, with a fresh `view_url`. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `target` | `"plant"` \| `"explant"` \| `"contamination"` \| `"sop"` | Yes | The kind of record to attach the image to: `plant`, `explant`, `contamination`, `sop`. | | `target_id` | string (uuid) | Yes | The id of that record. It must be in the key's workspace. | | `image_url` | string (uri) | No | An `https` address xPlant downloads the image from, up to 10 MB. It must be reachable from the public internet: private and local network addresses are refused, and redirects are not followed, so send the image's final address. The download must finish within 10 seconds. Use this for anything larger than about 3 MB. Up to 2048 characters. | | `image_base64` | string | No | The image itself, base64-encoded, without a `data:` prefix. A request body can be at most about 4.5 MB and base64 makes a file about a third larger, so this suits images up to about 3 MB. Send anything larger, up to the 10 MB limit, as `image_url`. | | `filename` | string | No | The name to show for the file, for example `leaf-sample-3.jpg`. Defaults to the last part of `image_url`, or `photo` for an inline image. 1–200 characters. | | `caption` | string | No | A short note stored with the image and shown beside it in xPlant. 1–500 characters. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/assets \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: camera-2-line-0412-photo-1" \ -H "Content-Type: application/json" \ -d '{ "target": "explant", "target_id": "3f9a2c1e-7b4d-4e8f-a6c5-1d2e3f4a5b6c", "image_base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "filename": "vessel-12-week-3.png", "caption": "Callus forming at the cut edge" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const asset = await client.assets.create( { target: "explant", target_id: "3f9a2c1e-7b4d-4e8f-a6c5-1d2e3f4a5b6c", image_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", filename: "vessel-12-week-3.png", caption: "Callus forming at the cut edge", }, { idempotencyKey: "camera-2-line-0412-photo-1" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/assets", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "camera-2-line-0412-photo-1", }, json={ "target": "explant", "target_id": "3f9a2c1e-7b4d-4e8f-a6c5-1d2e3f4a5b6c", "image_base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "filename": "vessel-12-week-3.png", "caption": "Callus forming at the cut edge", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") asset = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `target` | `"plant"` \| `"explant"` \| `"contamination"` \| `"sop"` | Yes | The kind of record the file is attached to. | | `target_id` | string (uuid) | Yes | The id of that record. | | `kind` | string | Yes | What the file is: `photo`, `video`, `annotation`, `document`, `diagram`, `scan`, `audio`, `other`. Images attached through the API are `photo`. | | `file_name` | string \| null | Yes | The file's name as xPlant shows it. | | `content_type` | string \| null | Yes | The file's media type, for example `image/jpeg`, `image/png`. | | `caption` | string \| null | Yes | The note stored with the file. | | `captured_at` | string \| null | Yes | When the photo was taken, where that was recorded. ISO 8601. | | `uploaded_by` | string \| null | Yes | User id of the workspace member who added the file. | | `created_at` | string \| null | Yes | When the file was added. ISO 8601. | | `view_url` | string \| null | Yes | A link to the file itself, valid for 15 minutes from this response. Fetch the asset again for a fresh link, and never store one. `null` when the file cannot be linked. | | `view_url_expires_at` | string \| null | Yes | When `view_url` stops working. ISO 8601. | ```json title="Response" { "ok": true, "data": { "id": "8c2f1a6e-4b3d-4f7a-9e21-5d6c7b8a9f10", "target": "explant", "target_id": "3f9a2c1e-7b4d-4e8f-a6c5-1d2e3f4a5b6c", "kind": "photo", "file_name": "vessel-12-week-3.png", "content_type": "image/png", "caption": "Callus forming at the cut edge", "captured_at": null, "uploaded_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "created_at": "2026-09-25T14:20:11.000Z", "view_url": "https://files.example.com/vessel-12-week-3.png?signature=4f9c2e", "view_url_expires_at": "2026-09-25T14:35:11.000Z" } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `413` | `PAYLOAD_TOO_LARGE` | The image is larger than 10 MB. | | `415` | `UNSUPPORTED_MEDIA_TYPE` | The file is not a JPEG, PNG, WebP or GIF image. The type is read from the file itself. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `422` | `VALIDATION_ERROR` | `target_id` does not name a record of that kind in the key's workspace. | | `422` | `VALIDATION_ERROR` | `image_url` is not an `https` address on the public internet. | | `422` | `IMAGE_URL_FETCH_FAILED` | `image_url` could not be downloaded: it redirected, answered with an error status, took longer than 10 seconds, or its host could not be found. `error` says which. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `ASSET_UPLOAD_FAILED` | The image could not be stored. Retry with the same `Idempotency-Key`. | | `500` | `ASSET_INSERT_FAILED` | The image could not be saved to the record. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get a media file > One photo or file, with a view_url that works for 15 minutes from this response. Fetch it again whenever you need the file; never store the link. Source: https://docs.xplantpro.com/docs/api/media/get-asset `GET https://app.xplantpro.com/api/v1/assets/{id}` - Required scope: `read:assets` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/assets/9e1a3c5b-7d9f-4b1d-a3c5-e7f9b1d3f5a7 \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const asset = await client.assets.get("9e1a3c5b-7d9f-4b1d-a3c5-e7f9b1d3f5a7"); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/assets/9e1a3c5b-7d9f-4b1d-a3c5-e7f9b1d3f5a7", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") asset = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `target` | `"plant"` \| `"explant"` \| `"contamination"` \| `"sop"` | Yes | The kind of record the file is attached to. | | `target_id` | string (uuid) | Yes | The id of that record. | | `kind` | string | Yes | What the file is: `photo`, `video`, `annotation`, `document`, `diagram`, `scan`, `audio`, `other`. Images attached through the API are `photo`. | | `file_name` | string \| null | Yes | The file's name as xPlant shows it. | | `content_type` | string \| null | Yes | The file's media type, for example `image/jpeg`, `image/png`. | | `caption` | string \| null | Yes | The note stored with the file. | | `captured_at` | string \| null | Yes | When the photo was taken, where that was recorded. ISO 8601. | | `uploaded_by` | string \| null | Yes | User id of the workspace member who added the file. | | `created_at` | string \| null | Yes | When the file was added. ISO 8601. | | `view_url` | string \| null | Yes | A link to the file itself, valid for 15 minutes from this response. Fetch the asset again for a fresh link, and never store one. `null` when the file cannot be linked. | | `view_url_expires_at` | string \| null | Yes | When `view_url` stops working. ISO 8601. | ```json title="Response" { "ok": true, "data": { "id": "8c2f1a6e-4b3d-4f7a-9e21-5d6c7b8a9f10", "target": "explant", "target_id": "3f9a2c1e-7b4d-4e8f-a6c5-1d2e3f4a5b6c", "kind": "photo", "file_name": "vessel-12-week-3.png", "content_type": "image/png", "caption": "Callus forming at the cut edge", "captured_at": null, "uploaded_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "created_at": "2026-09-25T14:20:11.000Z", "view_url": "https://files.example.com/vessel-12-week-3.png?signature=4f9c2e", "view_url_expires_at": "2026-09-25T14:35:11.000Z" } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No file with this id is attached to a record in the key's workspace. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `ASSET_QUERY_FAILED` | The file could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List media recipes > The workspace's culture media recipes that the key's owner can see — their own, and those shared with the workspace or published — newest first, each with its components. Another member's private recipe is not listed. Filter by status. Source: https://docs.xplantpro.com/docs/api/media/list-media-recipes `GET https://app.xplantpro.com/api/v1/media-recipes` - Required scope: `read:media_recipes` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | `"active"` \| `"archived"` \| `"deprecated"` \| `"draft"` \| `"published"` | No | Only recipes with this status. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/media-recipes?status=published" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const recipes = await client.mediaRecipes.list({ status: "published" }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/media-recipes", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"status": "published"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") recipes = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `title` | string | Yes | — | | `status` | string \| null | Yes | One of `active`, `archived`, `deprecated`, `draft`, `published`. | | `origin` | string \| null | Yes | Where the recipe came from, for example `user` or `imported`. | | `visibility` | string | Yes | `private`: only its creator can see it. `team`: everyone in the workspace can. | | `is_public` | boolean | Yes | Published to xPlant's public recipe library. | | `notes` | string \| null | Yes | — | | `ph_target` | number \| null | Yes | Target pH. | | `sterilization_notes` | string \| null | Yes | — | | `storage_notes` | string \| null | Yes | — | | `usage_notes` | string \| null | Yes | — | | `components` | object[] | Yes | The ingredients, in order. | | `components[].id` | string | Yes | The component's id. Send it back in an update to keep the component. | | `components[].name` | string | Yes | The ingredient. | | `components[].qty` | string | Yes | The amount as the recipe writes it. | | `components[].unit` | string \| null | Yes | The unit of `qty`. | | `components[].concentration` | string \| null | Yes | A concentration, when the recipe states one apart from the amount. | | `version` | integer | Yes | The recipe's version. It goes up by one each time the formulation changes, and every version is kept. | | `created_by` | string | Yes | User id of the workspace member who created the recipe. Only they can change it. | | `created_at` | string \| null | Yes | ISO 8601. | ```json title="Response" { "ok": true, "data": [ { "id": "a3d5f7b9-1c2e-4f6a-8b0d-2e4f6a8c0e1f", "title": "MS + 1 mg/L BAP", "status": "active", "origin": "user", "visibility": "team", "is_public": false, "notes": "Shoot multiplication medium for aroid cultures.", "ph_target": 5.8, "sterilization_notes": "Autoclave for 20 minutes at 121 °C.", "storage_notes": "Keep poured vessels at 4 °C and use within two weeks.", "usage_notes": null, "components": [ { "id": "c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b", "name": "MS basal salts", "qty": "4.4", "unit": "g/L", "concentration": null }, { "id": "d2b3c4d5-e6f7-4a81-9b0c-1d2e3f4a5b6c", "name": "Sucrose", "qty": "30", "unit": "g/L", "concentration": null }, { "id": "e3c4d5e6-f7a8-4b92-8c1d-2e3f4a5b6c7d", "name": "6-BAP", "qty": "1", "unit": "mg/L", "concentration": null }, { "id": "f4d5e6f7-a8b9-4ca3-9d2e-3f4a5b6c7d8e", "name": "Agar", "qty": "7", "unit": "g/L", "concentration": null } ], "version": 1, "created_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "created_at": "2026-09-25T14:12:03.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | `status` is not one of the recipe statuses. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `MEDIA_RECIPE_QUERY_FAILED` | The recipes could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Create a media recipe > Creates a recipe in the workspace with the key's owner as its creator. It starts at version 1 and is private to its creator unless visibility is team. Source: https://docs.xplantpro.com/docs/api/media/create-media-recipe `POST https://app.xplantpro.com/api/v1/media-recipes` - Required scope: `write:media_recipes` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) A component sent without an `id` is given one; keep the ids from the response to update the recipe later. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | Yes | The recipe's name, for example `MS + 1 mg/L BAP`. 1–200 characters. | | `components` | object[] | Yes | The ingredients, in order. At least one. Up to 100 items. | | `components[].id` | string | No | Your id for this component. Kept as given; when left out, one is assigned and returned, so an update can send it back. 1–64 characters. | | `components[].name` | string | Yes | The ingredient, for example `Sucrose` or `6-BAP`. 1–200 characters. | | `components[].qty` | string | Yes | The amount as the recipe writes it, for example `30` or `0.5`. 1–50 characters. | | `components[].unit` | string | No | The unit of `qty`, for example `g/L` or `mg/L`. 1–20 characters. | | `components[].concentration` | string | No | A concentration, when the recipe states one apart from the amount. 1–50 characters. | | `notes` | string | No | Free-text notes, up to 500 characters. Up to 500 characters. | | `status` | `"active"` \| `"archived"` \| `"deprecated"` \| `"draft"` \| `"published"` | No | One of `active`, `archived`, `deprecated`, `draft`, `published`. Default `"active"`. | | `visibility` | `"private"` \| `"team"` | No | Who in the workspace can see the recipe. `private`: only the key's owner, who is recorded as its creator. `team`: everyone in the workspace. Default `"private"`. | | `is_public` | boolean | No | Publish the recipe to xPlant's public recipe library, where anyone can read it. A `draft` recipe stays out of the library either way. Default `false`. | | `origin` | `"user"` \| `"imported"` | No | `imported` for a recipe brought in from another system; `user` otherwise. Default `"user"`. | | `ph_target` | number \| null | No | Target pH, 0 to 14. Stored to two decimal places. From 0 to 14. | | `sterilization_notes` | string \| null | No | How to sterilize the medium, up to 500 characters. Up to 500 characters. | | `storage_notes` | string \| null | No | How to store the prepared medium, up to 500 characters. Up to 500 characters. | | `usage_notes` | string \| null | No | How to use the medium, up to 500 characters. Up to 500 characters. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/media-recipes \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: recipes-sync-half-ms-v3" \ -H "Content-Type: application/json" \ -d '{ "title": "MS + 1 mg/L BAP", "components": [ { "name": "MS basal salts", "qty": "4.4", "unit": "g/L" }, { "name": "Sucrose", "qty": "30", "unit": "g/L" }, { "name": "6-BAP", "qty": "1", "unit": "mg/L" }, { "name": "Agar", "qty": "7", "unit": "g/L" } ], "notes": "Shoot multiplication medium for aroid cultures.", "visibility": "team", "ph_target": 5.8, "sterilization_notes": "Autoclave for 20 minutes at 121 °C.", "storage_notes": "Keep poured vessels at 4 °C and use within two weeks." }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const recipe = await client.mediaRecipes.create( { title: "MS + 1 mg/L BAP", components: [ { name: "MS basal salts", qty: "4.4", unit: "g/L" }, { name: "Sucrose", qty: "30", unit: "g/L" }, { name: "6-BAP", qty: "1", unit: "mg/L" }, { name: "Agar", qty: "7", unit: "g/L" }, ], notes: "Shoot multiplication medium for aroid cultures.", visibility: "team", ph_target: 5.8, sterilization_notes: "Autoclave for 20 minutes at 121 °C.", storage_notes: "Keep poured vessels at 4 °C and use within two weeks.", }, { idempotencyKey: "recipes-sync-half-ms-v3" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/media-recipes", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "recipes-sync-half-ms-v3", }, json={ "title": "MS + 1 mg/L BAP", "components": [ {"name": "MS basal salts", "qty": "4.4", "unit": "g/L"}, {"name": "Sucrose", "qty": "30", "unit": "g/L"}, {"name": "6-BAP", "qty": "1", "unit": "mg/L"}, {"name": "Agar", "qty": "7", "unit": "g/L"}, ], "notes": "Shoot multiplication medium for aroid cultures.", "visibility": "team", "ph_target": 5.8, "sterilization_notes": "Autoclave for 20 minutes at 121 °C.", "storage_notes": "Keep poured vessels at 4 °C and use within two weeks.", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") recipe = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `title` | string | Yes | — | | `status` | string \| null | Yes | One of `active`, `archived`, `deprecated`, `draft`, `published`. | | `origin` | string \| null | Yes | Where the recipe came from, for example `user` or `imported`. | | `visibility` | string | Yes | `private`: only its creator can see it. `team`: everyone in the workspace can. | | `is_public` | boolean | Yes | Published to xPlant's public recipe library. | | `notes` | string \| null | Yes | — | | `ph_target` | number \| null | Yes | Target pH. | | `sterilization_notes` | string \| null | Yes | — | | `storage_notes` | string \| null | Yes | — | | `usage_notes` | string \| null | Yes | — | | `components` | object[] | Yes | The ingredients, in order. | | `components[].id` | string | Yes | The component's id. Send it back in an update to keep the component. | | `components[].name` | string | Yes | The ingredient. | | `components[].qty` | string | Yes | The amount as the recipe writes it. | | `components[].unit` | string \| null | Yes | The unit of `qty`. | | `components[].concentration` | string \| null | Yes | A concentration, when the recipe states one apart from the amount. | | `version` | integer | Yes | The recipe's version. It goes up by one each time the formulation changes, and every version is kept. | | `created_by` | string | Yes | User id of the workspace member who created the recipe. Only they can change it. | | `created_at` | string \| null | Yes | ISO 8601. | ```json title="Response" { "ok": true, "data": { "id": "a3d5f7b9-1c2e-4f6a-8b0d-2e4f6a8c0e1f", "title": "MS + 1 mg/L BAP", "status": "active", "origin": "user", "visibility": "team", "is_public": false, "notes": "Shoot multiplication medium for aroid cultures.", "ph_target": 5.8, "sterilization_notes": "Autoclave for 20 minutes at 121 °C.", "storage_notes": "Keep poured vessels at 4 °C and use within two weeks.", "usage_notes": null, "components": [ { "id": "c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b", "name": "MS basal salts", "qty": "4.4", "unit": "g/L", "concentration": null }, { "id": "d2b3c4d5-e6f7-4a81-9b0c-1d2e3f4a5b6c", "name": "Sucrose", "qty": "30", "unit": "g/L", "concentration": null }, { "id": "e3c4d5e6-f7a8-4b92-8c1d-2e3f4a5b6c7d", "name": "6-BAP", "qty": "1", "unit": "mg/L", "concentration": null }, { "id": "f4d5e6f7-a8b9-4ca3-9d2e-3f4a5b6c7d8e", "name": "Agar", "qty": "7", "unit": "g/L", "concentration": null } ], "version": 1, "created_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "created_at": "2026-09-25T14:12:03.000Z" } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `MEDIA_RECIPE_CREATE_FAILED` | The recipe could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get a media recipe > One recipe with its components and current version. Source: https://docs.xplantpro.com/docs/api/media/get-media-recipe `GET https://app.xplantpro.com/api/v1/media-recipes/{id}` - Required scope: `read:media_recipes` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/media-recipes/4b6d8f0a-2c4e-4d6f-8a0c-2e4b6d8f0a2c \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const recipe = await client.mediaRecipes.get("4b6d8f0a-2c4e-4d6f-8a0c-2e4b6d8f0a2c"); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/media-recipes/4b6d8f0a-2c4e-4d6f-8a0c-2e4b6d8f0a2c", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") recipe = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `title` | string | Yes | — | | `status` | string \| null | Yes | One of `active`, `archived`, `deprecated`, `draft`, `published`. | | `origin` | string \| null | Yes | Where the recipe came from, for example `user` or `imported`. | | `visibility` | string | Yes | `private`: only its creator can see it. `team`: everyone in the workspace can. | | `is_public` | boolean | Yes | Published to xPlant's public recipe library. | | `notes` | string \| null | Yes | — | | `ph_target` | number \| null | Yes | Target pH. | | `sterilization_notes` | string \| null | Yes | — | | `storage_notes` | string \| null | Yes | — | | `usage_notes` | string \| null | Yes | — | | `components` | object[] | Yes | The ingredients, in order. | | `components[].id` | string | Yes | The component's id. Send it back in an update to keep the component. | | `components[].name` | string | Yes | The ingredient. | | `components[].qty` | string | Yes | The amount as the recipe writes it. | | `components[].unit` | string \| null | Yes | The unit of `qty`. | | `components[].concentration` | string \| null | Yes | A concentration, when the recipe states one apart from the amount. | | `version` | integer | Yes | The recipe's version. It goes up by one each time the formulation changes, and every version is kept. | | `created_by` | string | Yes | User id of the workspace member who created the recipe. Only they can change it. | | `created_at` | string \| null | Yes | ISO 8601. | ```json title="Response" { "ok": true, "data": { "id": "a3d5f7b9-1c2e-4f6a-8b0d-2e4f6a8c0e1f", "title": "MS + 1 mg/L BAP", "status": "active", "origin": "user", "visibility": "team", "is_public": false, "notes": "Shoot multiplication medium for aroid cultures.", "ph_target": 5.8, "sterilization_notes": "Autoclave for 20 minutes at 121 °C.", "storage_notes": "Keep poured vessels at 4 °C and use within two weeks.", "usage_notes": null, "components": [ { "id": "c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b", "name": "MS basal salts", "qty": "4.4", "unit": "g/L", "concentration": null }, { "id": "d2b3c4d5-e6f7-4a81-9b0c-1d2e3f4a5b6c", "name": "Sucrose", "qty": "30", "unit": "g/L", "concentration": null }, { "id": "e3c4d5e6-f7a8-4b92-8c1d-2e3f4a5b6c7d", "name": "6-BAP", "qty": "1", "unit": "mg/L", "concentration": null }, { "id": "f4d5e6f7-a8b9-4ca3-9d2e-3f4a5b6c7d8e", "name": "Agar", "qty": "7", "unit": "g/L", "concentration": null } ], "version": 1, "created_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "created_at": "2026-09-25T14:12:03.000Z" } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No recipe with this id is visible to the key's owner in the workspace: it does not exist there, or it is another member's private recipe. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `MEDIA_RECIPE_QUERY_FAILED` | The recipe could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Update a media recipe > Changes only the fields you send. Only the recipe's creator can change it. Source: https://docs.xplantpro.com/docs/api/media/update-media-recipe `PATCH https://app.xplantpro.com/api/v1/media-recipes/{id}` - Required scope: `write:media_recipes` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint A change to the formulation — the title, notes, components, pH or preparation notes — records a new version and raises `version`. Earlier versions are kept, and records already linked to the recipe keep the version they were linked to. Changing `status`, `visibility` or `is_public` does not create a version. `components` replaces the whole list, so send every component to keep, with its `id`. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | No | The recipe's name. 1–200 characters. | | `components` | object[] | No | The complete ingredient list. It replaces the current one, so send every component to keep, with its `id`. Up to 100 items. | | `components[].id` | string | No | Your id for this component. Kept as given; when left out, one is assigned and returned, so an update can send it back. 1–64 characters. | | `components[].name` | string | Yes | The ingredient, for example `Sucrose` or `6-BAP`. 1–200 characters. | | `components[].qty` | string | Yes | The amount as the recipe writes it, for example `30` or `0.5`. 1–50 characters. | | `components[].unit` | string | No | The unit of `qty`, for example `g/L` or `mg/L`. 1–20 characters. | | `components[].concentration` | string | No | A concentration, when the recipe states one apart from the amount. 1–50 characters. | | `notes` | string \| null | No | Free-text notes, up to 500 characters. `null` clears them. Up to 500 characters. | | `status` | `"active"` \| `"archived"` \| `"deprecated"` \| `"draft"` \| `"published"` | No | One of `active`, `archived`, `deprecated`, `draft`, `published`. | | `visibility` | `"private"` \| `"team"` | No | Who in the workspace can see the recipe. `private`: only the key's owner, who is recorded as its creator. `team`: everyone in the workspace. | | `is_public` | boolean | No | Publish the recipe to xPlant's public recipe library, where anyone can read it. A `draft` recipe stays out of the library either way. | | `ph_target` | number \| null | No | Target pH, 0 to 14. Stored to two decimal places. `null` clears it. From 0 to 14. | | `sterilization_notes` | string \| null | No | How to sterilize the medium, up to 500 characters. Up to 500 characters. | | `storage_notes` | string \| null | No | How to store the prepared medium, up to 500 characters. Up to 500 characters. | | `usage_notes` | string \| null | No | How to use the medium, up to 500 characters. Up to 500 characters. | ## Example **curl** ```bash curl -X PATCH https://app.xplantpro.com/api/v1/media-recipes/4b6d8f0a-2c4e-4d6f-8a0c-2e4b6d8f0a2c \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "MS + 0.5 mg/L BAP", "components": [ { "id": "c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b", "name": "MS basal salts", "qty": "4.4", "unit": "g/L" }, { "id": "d2b3c4d5-e6f7-4a81-9b0c-1d2e3f4a5b6c", "name": "Sucrose", "qty": "30", "unit": "g/L" }, { "id": "e3c4d5e6-f7a8-4b92-8c1d-2e3f4a5b6c7d", "name": "6-BAP", "qty": "0.5", "unit": "mg/L" }, { "id": "f4d5e6f7-a8b9-4ca3-9d2e-3f4a5b6c7d8e", "name": "Agar", "qty": "7", "unit": "g/L" } ] }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const recipe = await client.mediaRecipes.update( "4b6d8f0a-2c4e-4d6f-8a0c-2e4b6d8f0a2c", { title: "MS + 0.5 mg/L BAP", components: [ { id: "c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b", name: "MS basal salts", qty: "4.4", unit: "g/L", }, { id: "d2b3c4d5-e6f7-4a81-9b0c-1d2e3f4a5b6c", name: "Sucrose", qty: "30", unit: "g/L", }, { id: "e3c4d5e6-f7a8-4b92-8c1d-2e3f4a5b6c7d", name: "6-BAP", qty: "0.5", unit: "mg/L", }, { id: "f4d5e6f7-a8b9-4ca3-9d2e-3f4a5b6c7d8e", name: "Agar", qty: "7", unit: "g/L", }, ], }, ); ``` **Python** ```python import os import requests resp = requests.patch( "https://app.xplantpro.com/api/v1/media-recipes/4b6d8f0a-2c4e-4d6f-8a0c-2e4b6d8f0a2c", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={ "title": "MS + 0.5 mg/L BAP", "components": [ { "id": "c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b", "name": "MS basal salts", "qty": "4.4", "unit": "g/L", }, { "id": "d2b3c4d5-e6f7-4a81-9b0c-1d2e3f4a5b6c", "name": "Sucrose", "qty": "30", "unit": "g/L", }, { "id": "e3c4d5e6-f7a8-4b92-8c1d-2e3f4a5b6c7d", "name": "6-BAP", "qty": "0.5", "unit": "mg/L", }, { "id": "f4d5e6f7-a8b9-4ca3-9d2e-3f4a5b6c7d8e", "name": "Agar", "qty": "7", "unit": "g/L", }, ], }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") recipe = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `title` | string | Yes | — | | `status` | string \| null | Yes | One of `active`, `archived`, `deprecated`, `draft`, `published`. | | `origin` | string \| null | Yes | Where the recipe came from, for example `user` or `imported`. | | `visibility` | string | Yes | `private`: only its creator can see it. `team`: everyone in the workspace can. | | `is_public` | boolean | Yes | Published to xPlant's public recipe library. | | `notes` | string \| null | Yes | — | | `ph_target` | number \| null | Yes | Target pH. | | `sterilization_notes` | string \| null | Yes | — | | `storage_notes` | string \| null | Yes | — | | `usage_notes` | string \| null | Yes | — | | `components` | object[] | Yes | The ingredients, in order. | | `components[].id` | string | Yes | The component's id. Send it back in an update to keep the component. | | `components[].name` | string | Yes | The ingredient. | | `components[].qty` | string | Yes | The amount as the recipe writes it. | | `components[].unit` | string \| null | Yes | The unit of `qty`. | | `components[].concentration` | string \| null | Yes | A concentration, when the recipe states one apart from the amount. | | `version` | integer | Yes | The recipe's version. It goes up by one each time the formulation changes, and every version is kept. | | `created_by` | string | Yes | User id of the workspace member who created the recipe. Only they can change it. | | `created_at` | string \| null | Yes | ISO 8601. | ```json title="Response" { "ok": true, "data": { "id": "a3d5f7b9-1c2e-4f6a-8b0d-2e4f6a8c0e1f", "title": "MS + 0.5 mg/L BAP", "status": "active", "origin": "user", "visibility": "team", "is_public": false, "notes": "Shoot multiplication medium for aroid cultures.", "ph_target": 5.8, "sterilization_notes": "Autoclave for 20 minutes at 121 °C.", "storage_notes": "Keep poured vessels at 4 °C and use within two weeks.", "usage_notes": null, "components": [ { "id": "c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b", "name": "MS basal salts", "qty": "4.4", "unit": "g/L", "concentration": null }, { "id": "d2b3c4d5-e6f7-4a81-9b0c-1d2e3f4a5b6c", "name": "Sucrose", "qty": "30", "unit": "g/L", "concentration": null }, { "id": "e3c4d5e6-f7a8-4b92-8c1d-2e3f4a5b6c7d", "name": "6-BAP", "qty": "0.5", "unit": "mg/L", "concentration": null }, { "id": "f4d5e6f7-a8b9-4ca3-9d2e-3f4a5b6c7d8e", "name": "Agar", "qty": "7", "unit": "g/L", "concentration": null } ], "version": 2, "created_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "created_at": "2026-09-25T14:12:03.000Z" } } ``` | 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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `403` | `MEDIA_RECIPE_NOT_OWNER` | The key's owner can see the recipe but did not create it. Only a recipe's creator can change it. | | `404` | `NOT_FOUND` | No recipe with this id is visible to the key's owner in the workspace: it does not exist there, or it is another member's private recipe. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `MEDIA_RECIPE_UPDATE_FAILED` | The change could not be saved. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Start an SOP run > Starts a run of an SOP: the record that a procedure was carried out, step by step, against the version the lab had in force. Source: https://docs.xplantpro.com/docs/api/sops/create-sop-run `POST https://app.xplantpro.com/api/v1/sop-runs` - Required scope: `write:sop_runs` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) A bench station typically: 1. Reads the SOP with Get an SOP and shows the steps of the version in force. 2. Starts a run here, naming the batch or line it is for in `batch_code`. 3. Posts evidence against each step as the work happens — a confirmation, a scanned vessel label, a reading from a balance or pH meter — with Record a step event and Record a step measurement. The run follows the version in force when it starts, and you cannot choose another. Send an `Idempotency-Key` so a retried start does not open a second run. See also: [Run an SOP from a bench station](https://docs.xplantpro.com/docs/guides/sop-runs.md). Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `sop_id` | string (uuid) | Yes | The SOP to run. It must have a version in force. | | `batch_code` | string | No | Your lab's own identifier for what is being run, such as a batch or line code. Up to 120 characters. | | `plant_id` | string (uuid) | No | The plant the run is for, when there is one. It must be in this workspace. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/sop-runs \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: station-3-wk38-start" \ -H "Content-Type: application/json" \ -d '{ "sop_id": "2e4f6a8c-0b1d-4e3f-9a5b-7c9d1e3f5a7b", "batch_code": "LINE-0412" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const run = await client.sopRuns.start( { sop_id: "2e4f6a8c-0b1d-4e3f-9a5b-7c9d1e3f5a7b", batch_code: "LINE-0412", }, { idempotencyKey: "station-3-wk38-start" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/sop-runs", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "station-3-wk38-start", }, json={ "sop_id": "2e4f6a8c-0b1d-4e3f-9a5b-7c9d1e3f5a7b", "batch_code": "LINE-0412", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") run = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `sopId` | string \| null (uuid) | Yes | The SOP being run. | | `version` | integer \| null | Yes | The version of the SOP this run follows — the one in force when it started. It never changes afterwards. | | `versionId` | string \| null (uuid) | Yes | That version's id. | | `status` | string | Yes | `in_progress` from the moment a run starts. A run is completed in xPlant, once every step is ticked off; runs can also be `pending`, `on_hold`, `failed`, `cancelled` or `archived`. | | `batchCode` | string \| null | Yes | Your lab's own identifier for what is being run. | | `startedAt` | string \| null | Yes | — | | `completedAt` | string \| null | Yes | — | | `progress` | number | Yes | How far through its steps the run is, from 0 to 100, as ticked off in xPlant. | | `steps` | any[] | Yes | The step checklist as ticked off in xPlant, one entry per step, such as `{ "id": "step-3", "completed": true }`. Empty until someone works the run in xPlant. Evidence posted through the API is in `events` and does not tick steps off here. | ```json title="Response" { "ok": true, "data": { "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "sopId": "2e4f6a8c-0b1d-4e3f-9a5b-7c9d1e3f5a7b", "version": 2, "versionId": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", "status": "in_progress", "batchCode": "LINE-0412", "startedAt": "2026-09-25T08:02:13.000Z", "completedAt": null, "progress": 0, "steps": [] } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | The SOP, or the plant named in `plant_id`, is not in this workspace. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `409` | `SOP_RUN_NOT_EFFECTIVE` | The SOP has no version in force, so there is nothing approved to run. Put a version into force in xPlant, then start the run. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation, for example `sop_id` is missing or is not an id. `error` gives the first problem but does not name the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `SOP_RUN_CREATE_FAILED` | The run could not be started. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get an SOP run > One run: the SOP and version it follows, where it stands, and every piece of evidence posted against its steps, oldest first — the trail to read forwards when you need to know what was done, in what order. Source: https://docs.xplantpro.com/docs/api/sops/get-sop-run `GET https://app.xplantpro.com/api/v1/sop-runs/{id}` - Required scope: `read:sop_runs` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The run's id. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/sop-runs/2a4c6e8b-1d3f-4b5a-a7c9-e1f3a5b7c9d1 \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const run = await client.sopRuns.get("2a4c6e8b-1d3f-4b5a-a7c9-e1f3a5b7c9d1"); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/sop-runs/2a4c6e8b-1d3f-4b5a-a7c9-e1f3a5b7c9d1", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") run = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `sopId` | string \| null (uuid) | Yes | The SOP being run. | | `version` | integer \| null | Yes | The version of the SOP this run follows — the one in force when it started. It never changes afterwards. | | `versionId` | string \| null (uuid) | Yes | That version's id. | | `status` | string | Yes | `in_progress` from the moment a run starts. A run is completed in xPlant, once every step is ticked off; runs can also be `pending`, `on_hold`, `failed`, `cancelled` or `archived`. | | `batchCode` | string \| null | Yes | Your lab's own identifier for what is being run. | | `startedAt` | string \| null | Yes | — | | `completedAt` | string \| null | Yes | — | | `progress` | number | Yes | How far through its steps the run is, from 0 to 100, as ticked off in xPlant. | | `steps` | any[] | Yes | The step checklist as ticked off in xPlant, one entry per step, such as `{ "id": "step-3", "completed": true }`. Empty until someone works the run in xPlant. Evidence posted through the API is in `events` and does not tick steps off here. | | `events` | object[] | Yes | Every piece of evidence posted against the run's steps, oldest first. | | `events[].id` | string (uuid) | Yes | — | | `events[].runId` | string (uuid) | Yes | The run the evidence belongs to. | | `events[].stepKey` | string | Yes | The step it was recorded against — the `stepId` it was posted to. | | `events[].eventType` | string | Yes | `confirmed`, `scanned`, `skipped`, `note` or `device_state` for a step event; `measured` for a measurement. | | `events[].recordedAt` | string | Yes | When it happened: the `recorded_at` that was sent, or when xPlant received it. | | `events[].payload` | object | Yes | The detail sent with the event. For a measurement: `metric`, `value`, `unit`, and `notes` when there were any. | ```json title="Response" { "ok": true, "data": { "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "sopId": "2e4f6a8c-0b1d-4e3f-9a5b-7c9d1e3f5a7b", "version": 2, "versionId": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", "status": "in_progress", "batchCode": "LINE-0412", "startedAt": "2026-09-25T08:02:13.000Z", "completedAt": null, "progress": 0, "steps": [], "events": [ { "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "runId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "stepKey": "step-2", "eventType": "measured", "recordedAt": "2026-09-25T08:21:40+00:00", "payload": { "metric": "ph", "value": 5.7, "unit": "pH", "notes": "Initiation medium before pouring." } }, { "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "runId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "stepKey": "step-3", "eventType": "scanned", "recordedAt": "2026-09-25T08:30:00+00:00", "payload": { "code": "LINE-0412-J07" } } ] } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No run with this id exists in the key's workspace, or the run id is not a well-formed id. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `SOP_RUN_QUERY_FAILED` | The run could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Record step evidence > Records what happened at one step of a run: someone confirmed it, scanned a label or vessel, skipped it, or left a note, or an instrument reported its state. Source: https://docs.xplantpro.com/docs/api/sops/create-sop-step-event `POST https://app.xplantpro.com/api/v1/sop-runs/{id}/steps/{stepId}/events` - Required scope: `write:sop_steps` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) Evidence is append-only. There is no edit or delete — to correct something, post another event that says so. Send an `Idempotency-Key` so a scanner retrying on a patchy connection records one event rather than two. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The run's id. | | `stepId` | string | Yes | The step's `id` in the version the run follows, as Get an SOP lists it in `version.steps`. It is recorded as sent and not checked against the procedure. | ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `event_type` | `"confirmed"` \| `"scanned"` \| `"skipped"` \| `"note"` \| `"device_state"` | Yes | What happened at the step: `confirmed` (it was done), `scanned` (a label or vessel was scanned), `skipped`, `note` (a remark for whoever reads the run later) or `device_state` (what an instrument reported, such as a hood or autoclave cycle). | | `payload` | object | No | Any detail worth keeping with the event, as a JSON object — the code that was scanned, the note's text, the device's reading. Stored as sent. Default `{}`. | | `recorded_at` | string (date-time) | No | When it happened, in UTC with a trailing `Z`, such as `2026-09-25T08:30:00Z`. A timestamp with an offset is refused. Defaults to when xPlant receives it. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/sop-runs/2a4c6e8b-1d3f-4b5a-a7c9-e1f3a5b7c9d1/steps/step-3/events \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: station-3-wk38-step3-scan" \ -H "Content-Type: application/json" \ -d '{ "event_type": "scanned", "payload": { "code": "LINE-0412-J07" }, "recorded_at": "2026-09-25T08:30:00Z" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const event = await client.sopRuns.recordStepEvent( "2a4c6e8b-1d3f-4b5a-a7c9-e1f3a5b7c9d1", "step-3", { event_type: "scanned", payload: { code: "LINE-0412-J07" }, recorded_at: "2026-09-25T08:30:00Z", }, { idempotencyKey: "station-3-wk38-step3-scan" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/sop-runs/2a4c6e8b-1d3f-4b5a-a7c9-e1f3a5b7c9d1/steps/step-3/events", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "station-3-wk38-step3-scan", }, json={ "event_type": "scanned", "payload": {"code": "LINE-0412-J07"}, "recorded_at": "2026-09-25T08:30:00Z", }, 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 | — | | `runId` | string (uuid) | Yes | The run the evidence belongs to. | | `stepKey` | string | Yes | The step it was recorded against — the `stepId` it was posted to. | | `eventType` | string | Yes | `confirmed`, `scanned`, `skipped`, `note` or `device_state` for a step event; `measured` for a measurement. | | `recordedAt` | string | Yes | When it happened: the `recorded_at` that was sent, or when xPlant received it. | | `payload` | object | Yes | The detail sent with the event. For a measurement: `metric`, `value`, `unit`, and `notes` when there were any. | ```json title="Response" { "ok": true, "data": { "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", "runId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "stepKey": "step-3", "eventType": "scanned", "recordedAt": "2026-09-25T08:30:00+00:00", "payload": { "code": "LINE-0412-J07" } } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No run with this id exists in the key's workspace, or the run id is not a well-formed id. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `409` | `SOP_RUN_CLOSED` | The run is complete. A completed run's record is final and takes no more evidence. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation — an `event_type` outside the list, or a `recorded_at` that is not a UTC timestamp. `error` gives the first problem but does not name the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `SOP_STEP_EVENT_CREATE_FAILED` | The evidence could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Record a step measurement > Records a numeric reading against one step of a run — the pH of a medium, the mass of an ingredient, a temperature — with its unit, as the bench station running the SOP reads it from a balance, pH meter or probe. Source: https://docs.xplantpro.com/docs/api/sops/create-sop-step-measurement `POST https://app.xplantpro.com/api/v1/sop-runs/{id}/steps/{stepId}/measurements` - Required scope: `write:sop_steps` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) The reading joins the same trail as the step events, with `eventType` `measured`, so a run reads in one order. The unit is required: a number without one is not a record anyone can rely on later. Send an `Idempotency-Key` so a retried post does not record the reading twice. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The run's id. | | `stepId` | string | Yes | The step's `id` in the version the run follows, as Get an SOP lists it in `version.steps`. It is recorded as sent and not checked against the procedure. | ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `metric` | string | Yes | What was measured, in your own words — `temperature`, `ph`, `mass`. 1–60 characters. | | `value` | number | Yes | The reading. | | `unit` | string | Yes | The unit the reading is in, such as `C`, `pH` or `g`. Required. 1–20 characters. | | `recorded_at` | string (date-time) | No | When it happened, in UTC with a trailing `Z`, such as `2026-09-25T08:30:00Z`. A timestamp with an offset is refused. Defaults to when xPlant receives it. | | `notes` | string | No | Anything the reading needs beside it. Up to 500 characters. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/sop-runs/2a4c6e8b-1d3f-4b5a-a7c9-e1f3a5b7c9d1/steps/step-3/measurements \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: station-3-wk38-step3-ph" \ -H "Content-Type: application/json" \ -d '{ "metric": "ph", "value": 5.7, "unit": "pH", "recorded_at": "2026-09-25T08:21:40Z", "notes": "Initiation medium before pouring." }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const measurement = await client.sopRuns.recordMeasurement( "2a4c6e8b-1d3f-4b5a-a7c9-e1f3a5b7c9d1", "step-3", { metric: "ph", value: 5.7, unit: "pH", recorded_at: "2026-09-25T08:21:40Z", notes: "Initiation medium before pouring.", }, { idempotencyKey: "station-3-wk38-step3-ph" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/sop-runs/2a4c6e8b-1d3f-4b5a-a7c9-e1f3a5b7c9d1/steps/step-3/measurements", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "station-3-wk38-step3-ph", }, json={ "metric": "ph", "value": 5.7, "unit": "pH", "recorded_at": "2026-09-25T08:21:40Z", "notes": "Initiation medium before pouring.", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") measurement = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `runId` | string (uuid) | Yes | The run the evidence belongs to. | | `stepKey` | string | Yes | The step it was recorded against — the `stepId` it was posted to. | | `eventType` | string | Yes | `confirmed`, `scanned`, `skipped`, `note` or `device_state` for a step event; `measured` for a measurement. | | `recordedAt` | string | Yes | When it happened: the `recorded_at` that was sent, or when xPlant received it. | | `payload` | object | Yes | The detail sent with the event. For a measurement: `metric`, `value`, `unit`, and `notes` when there were any. | ```json title="Response" { "ok": true, "data": { "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "runId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "stepKey": "step-2", "eventType": "measured", "recordedAt": "2026-09-25T08:21:40+00:00", "payload": { "metric": "ph", "value": 5.7, "unit": "pH", "notes": "Initiation medium before pouring." } } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No run with this id exists in the key's workspace, or the run id is not a well-formed id. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `409` | `SOP_RUN_CLOSED` | The run is complete. A completed run's record is final and takes no more evidence. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation — a missing `unit`, a `value` that is not a number, or a `recorded_at` that is not a UTC timestamp. `error` gives the first problem but does not name the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `SOP_STEP_EVENT_CREATE_FAILED` | The evidence could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List SOPs > The workspace's standard operating procedures, most recently updated first — drafts and archived ones included, with status saying which. Summaries only: fetch one SOP to read the steps of the version in force. Source: https://docs.xplantpro.com/docs/api/sops/list-sops `GET https://app.xplantpro.com/api/v1/sops` - Required scope: `read:sops` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Editing an SOP moves it to the front of the list, so an SOP edited while you page through can move between pages: it may appear twice, or not at all. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/sops?limit=50" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const sops = await client.sops.list({ limit: 50 }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/sops", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"limit": 50}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") sops = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `title` | string | Yes | — | | `description` | string \| null | Yes | — | | `category` | string \| null | Yes | The kind of procedure, such as `media-prep`, `sterilization` or `tissue-culture`. | | `status` | string \| null | Yes | `draft`, `active` or `archived`. | | `level` | string \| null | Yes | Who it is written for: `beginner`, `intermediate`, `advanced` or `expert`. | | `tags` | string[] | Yes | — | | `equipment` | string[] | Yes | Equipment the procedure calls for. | | `estimatedTimeMinutes` | number \| null | Yes | How long one run is expected to take, in minutes. | | `durationHours` | number \| null | Yes | The procedure's duration in hours, as the lab recorded it. | | `currentVersion` | integer | Yes | The newest version number. It can be a draft; the version the lab works from is the one Get an SOP returns. | | `updatedAt` | string \| null | Yes | — | | `createdAt` | string \| null | Yes | — | ```json title="Response" { "ok": true, "data": [ { "id": "2e4f6a8c-0b1d-4e3f-9a5b-7c9d1e3f5a7b", "title": "Phalaenopsis flower-stalk node initiation", "description": "Surface-sterilise nodes from a healthy flower stalk and place them onto initiation medium.", "category": "tissue-culture", "status": "active", "level": "intermediate", "tags": [ "initiation", "orchid" ], "equipment": [ "laminar flow hood", "autoclave" ], "estimatedTimeMinutes": 45, "durationHours": null, "currentVersion": 3, "updatedAt": "2026-09-18T11:04:52.000Z", "createdAt": "2026-06-02T08:30:00.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `SOP_QUERY_FAILED` | The SOPs could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get an SOP > One SOP and the version the lab works from right now, with its steps — what a bench station shows the person at the hood. Source: https://docs.xplantpro.com/docs/api/sops/get-sop `GET https://app.xplantpro.com/api/v1/sops/{id}` - Required scope: `read:sops` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Only the version in force is returned, never a draft or a version waiting to take effect. When the SOP has no version in force, `version` is null: the procedure exists, but there is nothing approved to follow yet, and it cannot be run. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/sops/9d2c4b6a-8e1f-4a3b-b5c7-d9e1f3a5b7c9 \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const sop = await client.sops.get("9d2c4b6a-8e1f-4a3b-b5c7-d9e1f3a5b7c9"); if (!sop.version) { // No version is in force yet, so there are no steps to run. } ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/sops/9d2c4b6a-8e1f-4a3b-b5c7-d9e1f3a5b7c9", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") sop = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `title` | string | Yes | — | | `description` | string \| null | Yes | — | | `category` | string \| null | Yes | The kind of procedure, such as `media-prep`, `sterilization` or `tissue-culture`. | | `status` | string \| null | Yes | `draft`, `active` or `archived`. | | `level` | string \| null | Yes | Who it is written for: `beginner`, `intermediate`, `advanced` or `expert`. | | `tags` | string[] | Yes | — | | `equipment` | string[] | Yes | Equipment the procedure calls for. | | `estimatedTimeMinutes` | number \| null | Yes | How long one run is expected to take, in minutes. | | `durationHours` | number \| null | Yes | The procedure's duration in hours, as the lab recorded it. | | `currentVersion` | integer | Yes | The newest version number. It can be a draft; the version the lab works from is the one Get an SOP returns. | | `updatedAt` | string \| null | Yes | — | | `createdAt` | string \| null | Yes | — | | `version` | object \| null | Yes | The version in force — the one the lab works from — with its steps. Null when no version has been put into force, and then there are no steps to follow: a draft is never returned. | ```json title="Response" { "ok": true, "data": { "id": "2e4f6a8c-0b1d-4e3f-9a5b-7c9d1e3f5a7b", "title": "Phalaenopsis flower-stalk node initiation", "description": "Surface-sterilise nodes from a healthy flower stalk and place them onto initiation medium.", "category": "tissue-culture", "status": "active", "level": "intermediate", "tags": [ "initiation", "orchid" ], "equipment": [ "laminar flow hood", "autoclave" ], "estimatedTimeMinutes": 45, "durationHours": null, "currentVersion": 3, "updatedAt": "2026-09-18T11:04:52.000Z", "createdAt": "2026-06-02T08:30:00.000Z", "version": { "version": 2, "lifecycleStatus": "effective", "effectiveAt": "2026-08-12T00:00:00+00:00", "approvedAt": "2026-08-11T16:20:00+00:00", "steps": [ { "id": "step-1", "instruction": "Wipe the hood down and let it run for 15 minutes before starting.", "estimatedMinutes": 15 }, { "id": "step-2", "instruction": "Cut single nodes from the flower stalk, leaving a short section either side.", "notes": "Discard any node with a split or browned bract." }, { "id": "step-3", "instruction": "Place each node onto initiation medium and label the vessel." } ], "title": "Phalaenopsis flower-stalk node initiation", "description": "Surface-sterilise nodes from a healthy flower stalk and place them onto initiation medium." } } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No SOP with this id exists in the key's workspace, or `id` is not a well-formed id. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `SOP_QUERY_FAILED` | The SOP could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Record a label scan > Records that a code was scanned — where, and when. Resolving a label only reads; this writes the visit down. Source: https://docs.xplantpro.com/docs/api/labels/create-label-scan `POST https://app.xplantpro.com/api/v1/label-scans` - Required scope: `write:label_scans` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) Send `plant_id` or `explant_id` when you already know what the code belongs to, for example from resolving it first; the scan is then marked `resolved`. A scan that matched nothing is still worth recording. Scans are append-only: a correction is another scan. Send an `Idempotency-Key` so a scanner retrying on a patchy connection records one visit rather than two. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `barcode` | string | Yes | The code exactly as the scanner read it. 1–512 characters. | | `plant_id` | string (uuid) | No | The plant the code belongs to, when you already know it — for example from resolving the label first. It must be in this workspace. | | `explant_id` | string (uuid) | No | The explant the code belongs to, when you already know it. It must be in this workspace. | | `context` | string | No | Where the scan happened, in your own words — a bench, a shelf, a growth room door. Up to 120 characters. | | `scanned_at` | string (date-time) | No | When the scan happened, in UTC with a trailing `Z`, such as `2026-09-25T08:30:00Z`. A timestamp with an offset is refused. Defaults to when xPlant receives it. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/label-scans \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: scanner-3-0f2a7c91" \ -H "Content-Type: application/json" \ -d '{ "barcode": "LINE-0412", "explant_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "context": "Growth room 1, shelf C1", "scanned_at": "2026-09-25T08:30:00Z" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const scan = await client.labels.recordScan( { barcode: "LINE-0412", explant_id: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", context: "Growth room 1, shelf C1", scanned_at: "2026-09-25T08:30:00Z", }, { idempotencyKey: "scanner-3-0f2a7c91" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/label-scans", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "scanner-3-0f2a7c91", }, json={ "barcode": "LINE-0412", "explant_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "context": "Growth room 1, shelf C1", "scanned_at": "2026-09-25T08:30:00Z", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") scan = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `barcode` | string | Yes | The code as the scanner read it. | | `resolved` | boolean | Yes | True when the scan was recorded against a plant or explant — that is, when `plant_id` or `explant_id` was sent. | | `plantId` | string \| null (uuid) | Yes | — | | `explantId` | string \| null (uuid) | Yes | — | | `context` | string \| null | Yes | Where the scan happened, as it was described. | | `scannedAt` | string | Yes | When the scan happened: the `scanned_at` that was sent, or when xPlant received it. | ```json title="Response" { "ok": true, "data": { "id": "d4c3b2a1-f6e5-4d7c-8b9a-0f1e2d3c4b5a", "barcode": "LINE-0412", "resolved": true, "plantId": null, "explantId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "context": "Growth room 1, shelf C1", "scannedAt": "2026-09-25T08:30:00+00:00" } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | The plant or explant named in the scan is not in this workspace. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation, for example a missing `barcode` or a `scanned_at` that is not a UTC timestamp. `error` gives the first problem but does not name the field. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `LABEL_SCAN_CREATE_FAILED` | The scan could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Resolve a label > Turns a scanned or typed code into the record it identifies, for scanners and bench hardware. xPlant checks, in order: plant labels, explant labels, container labels, then your own plant and explant identifiers — so a code your lab writes on the jar itself, such as LINE-0412, resolves too. Source: https://docs.xplantpro.com/docs/api/labels/resolve-label `GET https://app.xplantpro.com/api/v1/labels/resolve` - Required scope: `read:labels` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint A container code answers with the container and, in `contents`, the items stored at its location. Looking a code up leaves no trace. To record that someone was at the shelf, also call Record a label scan. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `barcode` | string | Yes | The code exactly as scanned or typed. A plant, explant or container label must match exactly; your own identifiers match ignoring case. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/labels/resolve?barcode=LINE-0412" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const match = await client.labels.resolve("LINE-0412"); console.log(match.record_type, match.display_name, match.url); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/labels/resolve", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"barcode": "LINE-0412"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") match = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `barcode` | string | Yes | The code that was looked up, trimmed. | | `record_type` | `"plant"` \| `"explant"` \| `"container"` | Yes | What the code identifies. A `container` is a labelled location — a rack, shelf or box — and resolves to the cultures stored there. | | `record_id` | string (uuid) | Yes | The plant's, explant's or container's id. | | `display_name` | string | Yes | A name to show the person scanning: the plant's common name or species, the explant's label or batch number, or the container's label. | | `url` | string | Yes | Where the record opens in xPlant, as a path on the xPlant web address — for example `/dashboard/explants/`. | | `contents` | object[] | No | For a container only: the items stored at that location. | | `contents[].item_id` | string (uuid) | Yes | The stored item's id. | | `contents[].record_type` | `"plant"` \| `"explant"` \| null | Yes | What the item is, when it is linked to a plant or explant. | | `contents[].record_id` | string \| null (uuid) | Yes | The plant's or explant's id, when the item is linked to one. | | `contents[].display_name` | string | Yes | A name to show for the item. | | `contents[].url` | string \| null | Yes | Where the linked record opens in xPlant, as a path on the xPlant web address. Null when there is no linked record. | ```json title="Response" { "ok": true, "data": { "barcode": "LINE-0412", "record_type": "explant", "record_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "display_name": "Alocasia LINE-0412", "url": "/dashboard/explants/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `400` | `VALIDATION_ERROR` | `barcode` is missing or blank. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | Nothing in this workspace matches the code. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `LINK_QUERY_FAILED` | The code could not be looked up. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # 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 Events are not deduplicated: 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 ending in `Z`. Defaults to the time the request arrives. | ## 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" }' ``` **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", }); ``` **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", }, 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" } } ``` | 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 later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List devices > Every device registered to the workspace, newest first, a page at a time. Pages hold up to 200 devices by default, so most workspaces receive every device in the first answer; follow meta.next_cursor until it comes back null to be sure you have them all. Source: https://docs.xplantpro.com/docs/api/devices/list-devices `GET https://app.xplantpro.com/api/v1/devices` - Required scope: `read:devices` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `200`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/devices \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const devices = await client.devices.list(); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/devices", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") devices = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `name` | string | Yes | — | | `type` | string | Yes | One of `sensor`, `controller`, `gateway`. | | `hardware` | string \| null | Yes | — | | `status` | string | Yes | `active` while the device is in service. `paused` or `retired` once someone in the lab takes it out of service, which refuses its readings and revokes its device tokens. | | `firmware_version` | string \| null | Yes | — | | `room_id` | string \| null (uuid) | Yes | The growing room the device sits in, if one was set. | | `last_seen_at` | string \| null | Yes | When the device was last heard from, as an ISO 8601 timestamp. A heartbeat updates it. Null if it has never been heard from. | | `metadata` | object | Yes | The JSON object stored with the device when it was registered. | | `created_at` | string | Yes | — | | `updated_at` | string | Yes | — | ```json title="Response" { "ok": true, "data": [ { "id": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13", "name": "Shelf 3 climate probe", "type": "sensor", "hardware": "Raspberry Pi 4", "status": "active", "firmware_version": "1.4.2", "room_id": "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26", "last_seen_at": "2026-09-25T14:05:00.000Z", "metadata": { "shelf": 3 }, "created_at": "2026-09-01T09:30:00.000Z", "updated_at": "2026-09-25T14:05:00.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `DEVICE_QUERY_FAILED` | The devices could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Register a device > Adds a sensor, controller or gateway to the workspace so it can send heartbeats, readings and events. Then mint a device token for it and put that token on the device, rather than a workspace key. Source: https://docs.xplantpro.com/docs/api/devices/create-device `POST https://app.xplantpro.com/api/v1/devices` - Required scope: `write:devices` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) If the workspace has already connected every device its plan includes, or its plan includes none, the request is refused with `DEVICE_LIMIT_REACHED`. A retired device does not count. Send an `Idempotency-Key` so a retried request cannot register the same device twice. Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Yes | What the lab calls the device, as it appears in the device list. 1–100 characters. | | `type` | `"sensor"` \| `"controller"` \| `"gateway"` | No | What kind of device this is. Default `"sensor"`. | | `hardware` | string | No | The board or model, for example `Raspberry Pi 4`. Up to 100 characters. | | `firmware_version` | string | No | The firmware or software version the device runs. Up to 50 characters. | | `room_id` | string (uuid) | No | The growing room the device sits in. Must be a room in the workspace. | | `metadata` | object | No | Any JSON object you want kept with the device. Default `{}`. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/devices \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Shelf 3 climate probe", "type": "sensor", "hardware": "Raspberry Pi 4", "firmware_version": "1.4.2", "room_id": "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26", "metadata": { "shelf": 3 } }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const device = await client.devices.register({ name: "Shelf 3 climate probe", type: "sensor", hardware: "Raspberry Pi 4", firmware_version: "1.4.2", room_id: "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26", metadata: { shelf: 3 }, }); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/devices", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={ "name": "Shelf 3 climate probe", "type": "sensor", "hardware": "Raspberry Pi 4", "firmware_version": "1.4.2", "room_id": "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26", "metadata": {"shelf": 3}, }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") device = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `name` | string | Yes | — | | `type` | string | Yes | One of `sensor`, `controller`, `gateway`. | | `hardware` | string \| null | Yes | — | | `status` | string | Yes | `active` while the device is in service. `paused` or `retired` once someone in the lab takes it out of service, which refuses its readings and revokes its device tokens. | | `firmware_version` | string \| null | Yes | — | | `room_id` | string \| null (uuid) | Yes | The growing room the device sits in, if one was set. | | `last_seen_at` | string \| null | Yes | When the device was last heard from, as an ISO 8601 timestamp. A heartbeat updates it. Null if it has never been heard from. | | `metadata` | object | Yes | The JSON object stored with the device when it was registered. | | `created_at` | string | Yes | — | | `updated_at` | string | Yes | — | ```json title="Response" { "ok": true, "data": { "id": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13", "name": "Shelf 3 climate probe", "type": "sensor", "hardware": "Raspberry Pi 4", "status": "active", "firmware_version": "1.4.2", "room_id": "2c6f9a41-7d3e-4b8a-a1c5-9e0d4f7b3a26", "last_seen_at": null, "metadata": { "shelf": 3 }, "created_at": "2026-09-01T09:30:00.000Z", "updated_at": "2026-09-01T09:30:00.000Z" } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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. | | `402` | `DEVICE_LIMIT_REACHED` | The workspace has connected every device its plan includes — retire a device it no longer uses, or contact support to raise the limit — or its plan includes no connected devices at all. `error` says which. Nothing was registered. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | `room_id` does not name a growing room in the workspace. Nothing was registered. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `name: String must contain at least 1 character(s)`. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `DEVICE_CREATE_FAILED` | The device could not be saved. Retry with the same `Idempotency-Key`. | | `503` | `DEVICE_LIMIT_UNAVAILABLE` | The workspace's device allowance could not be checked, so nothing was registered. Retry shortly. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Send a heartbeat > Tells xPlant the device is alive and stamps its last-seen time. xPlant shows a device as offline once 15 minutes pass without hearing from it, so send a heartbeat every minute or two. The request has no body. Source: https://docs.xplantpro.com/docs/api/devices/send-heartbeat `POST https://app.xplantpro.com/api/v1/devices/{deviceId}/heartbeat` - Required scope: `write:devices` - Credentials: workspace API key (`xpk_`) or device token (`xpd_`) - Idempotency-Key: ignored on this endpoint A repeated heartbeat does no harm, so a retry needs no `Idempotency-Key`. A device token may send heartbeats only for its own device. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `deviceId` | string (uuid) | Yes | The device's id. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/devices/5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e/heartbeat \ -H "Authorization: Bearer $XPLANT_DEVICE_TOKEN" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const device = new XPlantClient({ deviceToken: process.env.XPLANT_DEVICE_TOKEN }); const heartbeat = await device.devices.heartbeat("5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e"); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/devices/5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e/heartbeat", headers={"Authorization": f"Bearer {os.environ['XPLANT_DEVICE_TOKEN']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") heartbeat = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `received_at` | string | Yes | When the heartbeat was recorded, as an ISO 8601 timestamp. | ```json title="Response" { "ok": true, "data": { "received_at": "2026-09-25T14:05:00.000Z" } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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` | No device with this id is registered in the key's workspace. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `DEVICE_UPDATE_FAILED` | The heartbeat could not be recorded. Retry, or send the next one on schedule. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List device tokens > Every token minted for the device, active and revoked, newest first. Each carries its prefix, name, status and last use — what you need to tell tokens apart and decide which to revoke. The secret itself is never returned after the token is minted. Source: https://docs.xplantpro.com/docs/api/devices/list-device-tokens `GET https://app.xplantpro.com/api/v1/devices/{deviceId}/tokens` - Required scope: `read:devices` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Pages hold up to 200 tokens by default; follow `meta.next_cursor` until it comes back `null` to be sure you have them all. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `deviceId` | string (uuid) | Yes | The device's id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `200`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/devices/5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e/tokens \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const tokens = await client.devices.listTokens("5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e"); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/devices/5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e/tokens", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") tokens = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `deviceId` | string (uuid) | Yes | The device the token belongs to. | | `name` | string \| null | Yes | The label it was given when it was minted. | | `prefix` | string | Yes | The first characters of the token — enough to recognise it, never enough to use it. | | `status` | string | Yes | `active`, or `revoked` once it can no longer be used. | | `lastUsedAt` | string \| null | Yes | When the token last authenticated a request, as an ISO 8601 timestamp. Null if never. | | `revokedAt` | string \| null | Yes | When the token was revoked, as an ISO 8601 timestamp. Null while it is active. | | `createdAt` | string | Yes | When the token was minted. | ```json title="Response" { "ok": true, "data": [ { "id": "4f8a2c6e-9d1b-4e7a-b3c5-6a0d8f2e4b19", "deviceId": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13", "name": "Grow room Pi", "prefix": "xpd_live_0123456789a", "status": "active", "lastUsedAt": "2026-09-25T14:05:00.000Z", "revokedAt": null, "createdAt": "2026-09-01T09:40:00.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No device with this id is registered in the key's workspace. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `DEVICE_TOKEN_QUERY_FAILED` | The tokens could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Create a device token > Creates a credential for one device. Put it on the device in place of a workspace key: it can send that device's heartbeats, readings and events, and nothing else. Source: https://docs.xplantpro.com/docs/api/devices/create-device-token `POST https://app.xplantpro.com/api/v1/devices/{deviceId}/tokens` - Required scope: `write:devices` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint **The secret is in `token`, and this is the only response that contains it.** Store it on the device straight away. If it is lost, mint another and revoke the old one. Mint tokens with a workspace key — a device token cannot create tokens. A retried request mints a second token rather than returning the first, so revoke whichever one you do not keep. Every field is optional; send `{}` or no body at all. See also: [Device tokens](https://docs.xplantpro.com/docs/device-tokens.md). ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `deviceId` | string (uuid) | Yes | The device's id. | ## Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | No | A label to tell the device's tokens apart, for example `Grow room Pi`. 1–120 characters. | | `environment` | `"production"` \| `"development"` | No | `production` mints an `xpd_live_` token and `development` an `xpd_dev_` one. Both act on the same workspace. Default `"production"`. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/devices/5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e/tokens \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Grow room Pi" }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const { token } = await client.devices.createToken( "5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e", { name: "Grow room Pi" }, ); // Store it on the device now: it is never shown again. ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/devices/5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e/tokens", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, json={"name": "Grow room Pi"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") created = body["data"] ``` ## Response `201` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `deviceId` | string (uuid) | Yes | The device the token belongs to. | | `name` | string \| null | Yes | The label it was given when it was minted. | | `prefix` | string | Yes | The first characters of the token — enough to recognise it, never enough to use it. | | `token` | string | Yes | The secret. Returned in this response only — store it on the device now, because it cannot be shown again. | | `createdAt` | string | Yes | When the token was minted. | ```json title="Response" { "ok": true, "data": { "id": "4f8a2c6e-9d1b-4e7a-b3c5-6a0d8f2e4b19", "deviceId": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13", "name": "Grow room Pi", "prefix": "xpd_live_0123456789a", "token": "xpd_live_0123456789abcdef0123456789abcdef0123456789abcdef", "createdAt": "2026-09-01T09:40:00.000Z" } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No device with this id is registered in the key's workspace. | | `422` | `VALIDATION_ERROR` | A field failed validation, such as a `name` longer than 120 characters. `error` names it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `DEVICE_TOKEN_CREATE_FAILED` | The token could not be created, and none was minted. Retry. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Revoke a device token > Stops one device token working: the next request made with it is refused as unauthorized. The token stays in the device's token list, marked revoked with the time it was revoked, so the list remains a complete history of the device's credentials. Source: https://docs.xplantpro.com/docs/api/devices/revoke-device-token `DELETE https://app.xplantpro.com/api/v1/devices/{deviceId}/tokens/{tokenId}` - Required scope: `write:devices` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Revoking a token that is already revoked is not an error — you get the token back as it stands, with its original revocation time. Taking a device out of service revokes all of its tokens at once. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `deviceId` | string (uuid) | Yes | The device's id. | | `tokenId` | string (uuid) | Yes | The token's id, as the token list returns it. | ## Example **curl** ```bash curl -X DELETE https://app.xplantpro.com/api/v1/devices/5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e/tokens/3c5e7a9b-1d3f-4b5d-8f7a-9c1e3b5d7f9a \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const revoked = await client.devices.revokeToken( "5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e", "3c5e7a9b-1d3f-4b5d-8f7a-9c1e3b5d7f9a", ); ``` **Python** ```python import os import requests resp = requests.delete( "https://app.xplantpro.com/api/v1/devices/5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e/tokens/3c5e7a9b-1d3f-4b5d-8f7a-9c1e3b5d7f9a", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") revoked = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `deviceId` | string (uuid) | Yes | The device the token belongs to. | | `name` | string \| null | Yes | The label it was given when it was minted. | | `prefix` | string | Yes | The first characters of the token — enough to recognise it, never enough to use it. | | `status` | string | Yes | `active`, or `revoked` once it can no longer be used. | | `lastUsedAt` | string \| null | Yes | When the token last authenticated a request, as an ISO 8601 timestamp. Null if never. | | `revokedAt` | string \| null | Yes | When the token was revoked, as an ISO 8601 timestamp. Null while it is active. | | `createdAt` | string | Yes | When the token was minted. | ```json title="Response" { "ok": true, "data": { "id": "4f8a2c6e-9d1b-4e7a-b3c5-6a0d8f2e4b19", "deviceId": "8e3b1f52-6c0d-4a7e-9b21-5f4d8c2a7e13", "name": "Grow room Pi", "prefix": "xpd_live_0123456789a", "status": "revoked", "lastUsedAt": "2026-09-25T14:05:00.000Z", "revokedAt": "2026-09-25T15:00:00.000Z", "createdAt": "2026-09-01T09:40:00.000Z" } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No token with this id belongs to this device in the key's workspace. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `DEVICE_TOKEN_REVOKE_FAILED` | The token could not be revoked and may still work. Retry. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List sensor readings > The workspace's environmental readings, newest first, a page at a time. Narrow them to one device, one growing room or one kind of measurement, and use since and until to fetch only the readings taken within a window. Source: https://docs.xplantpro.com/docs/api/sensor-readings/list-sensor-readings `GET https://app.xplantpro.com/api/v1/sensor-readings` - Required scope: `read:sensor_readings` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Pages are larger here than on other lists: 100 readings by default and up to 1,000. To read further back than one page reaches, follow `meta.next_cursor` with the same filters until it comes back `null`. Readings taken at the same moment keep a fixed order, so none is skipped or repeated. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `device_id` | string (uuid) | No | Only readings from this device. | | `room_id` | string (uuid) | No | Only readings for this growing room. | | `type` | `"temperature"` \| `"humidity"` \| `"ph"` \| `"co2"` \| `"light"` \| `"other"` | No | Only this kind of reading. | | `since` | string (date-time) | No | Only readings taken at or after this time, as an ISO 8601 timestamp. | | `until` | string (date-time) | No | Only readings taken at or before this time, as an ISO 8601 timestamp. Must not be earlier than `since`. | | `limit` | integer | No | Page size. Values above 1,000 are capped at 1,000. Default `100`. From 1 to 1000. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/sensor-readings?device_id=5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e&type=temperature&since=2026-09-24T00%3A00%3A00Z&until=2026-09-25T00%3A00%3A00Z&limit=1000" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const readings = await client.sensorReadings.list({ device_id: "5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e", type: "temperature", since: "2026-09-24T00:00:00Z", until: "2026-09-25T00:00:00Z", limit: 1000, }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/sensor-readings", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={ "device_id": "5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c3e", "type": "temperature", "since": "2026-09-24T00:00:00Z", "until": "2026-09-25T00:00:00Z", "limit": 1000, }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") readings = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `device_id` | string (uuid) | Yes | — | | `team_id` | string (uuid) | Yes | The id of the workspace the reading belongs to. | | `room_id` | string \| null (uuid) | Yes | — | | `type` | string | Yes | What was measured: `temperature`, `humidity`, `ph`, `co2`, `light`, `other`. | | `value` | number | Yes | — | | `unit` | string | Yes | — | | `recorded_at` | string | Yes | When the reading was taken, as an ISO 8601 timestamp. | | `notes` | string \| null | Yes | — | | `created_at` | string | Yes | — | ```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" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | A query parameter is malformed: `device_id` or `room_id` is not a UUID, `since` or `until` is not a timestamp, `since` is later than `until`, or `limit` is not a whole number. `error` names the parameter, for example `device_id: must be a UUID`. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `READINGS_QUERY_FAILED` | The readings could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # 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, and is left out of the response — so a skipped single reading returns `ok: true` with no `data`. 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 ending in `Z`. 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. 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 ending in `Z`. 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. 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" } ] } ``` | 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). --- # List equipment > The workspace's equipment library, newest first, with each item's calibration and maintenance due dates. Filter by category and status; with neither you get every item, archived ones included. Source: https://docs.xplantpro.com/docs/api/equipment/list-equipment `GET https://app.xplantpro.com/api/v1/equipment` - Required scope: `read:equipment` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `category` | `"balance_scale"` \| `"autoclave_pressure_cooker"` \| `"laminar_flow_hood"` \| `"still_air_box"` \| `"incubator"` \| `"light_rack"` \| `"fridge_freezer"` \| `"ph_ec_meter"` \| `"microscope"` \| `"label_printer"` \| `"other"` | No | Only equipment of this type: `balance_scale`, `autoclave_pressure_cooker`, `laminar_flow_hood`, `still_air_box`, `incubator`, `light_rack`, `fridge_freezer`, `ph_ec_meter`, `microscope`, `label_printer`, `other`. | | `status` | `"active"` \| `"archived"` | No | `active`, or `archived` for equipment the lab has retired. Leave it out to get both. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/equipment?category=autoclave_pressure_cooker&status=active" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const equipment = await client.equipment.list({ category: "autoclave_pressure_cooker", status: "active" }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/equipment", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={ "category": "autoclave_pressure_cooker", "status": "active", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") equipment = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `name` | string | Yes | — | | `category` | string | Yes | The kind of equipment: `balance_scale`, `autoclave_pressure_cooker`, `laminar_flow_hood`, `still_air_box`, `incubator`, `light_rack`, `fridge_freezer`, `ph_ec_meter`, `microscope`, `label_printer`, `other`. | | `status` | string | Yes | `active`, or `archived` once the lab has retired it. | | `manufacturer` | string \| null | Yes | — | | `model` | string \| null | Yes | — | | `serial_number` | string \| null | Yes | — | | `location` | string \| null | Yes | Where it is kept, as the lab wrote it. | | `purchase_date` | string \| null | Yes | ISO 8601 date. | | `vendor_url` | string \| null | Yes | A link to the product or supplier page. | | `notes` | string \| null | Yes | — | | `last_calibrated_at` | string \| null | Yes | When a calibration schedule was last completed, as an ISO 8601 timestamp. | | `next_calibration_due_at` | string \| null | Yes | The date the next calibration is due (ISO 8601 date), or null when no calibration schedule is active. | | `last_maintenance_at` | string \| null | Yes | When a maintenance schedule was last completed, as an ISO 8601 timestamp. | | `next_maintenance_due_at` | string \| null | Yes | The date the next preventive maintenance is due (ISO 8601 date), or null when no maintenance schedule is active. | | `created_at` | string \| null | Yes | — | | `updated_at` | string \| null | Yes | — | ```json title="Response" { "ok": true, "data": [ { "id": "8c2f4e61-5a3b-4d7e-9b10-3e6a2c9d4f18", "name": "Autoclave 2", "category": "autoclave_pressure_cooker", "status": "active", "manufacturer": "Example Instruments", "model": "AC-40", "serial_number": "AC40-00172", "location": "Media room", "purchase_date": "2025-03-14", "vendor_url": null, "notes": null, "last_calibrated_at": "2026-06-02T08:30:00.000Z", "next_calibration_due_at": "2026-12-02", "last_maintenance_at": null, "next_maintenance_due_at": null, "created_at": "2025-03-20T10:04:11.000Z", "updated_at": "2026-06-02T08:31:40.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `LAB_EQUIPMENT_QUERY_FAILED` | The equipment could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get a piece of equipment > One piece of equipment, with its calibration and maintenance due dates. Source: https://docs.xplantpro.com/docs/api/equipment/get-equipment `GET https://app.xplantpro.com/api/v1/equipment/{id}` - Required scope: `read:equipment` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Example **curl** ```bash curl https://app.xplantpro.com/api/v1/equipment/8b0d2f4a-6c8e-4a0b-8d2f-4a6c8e0b2d4f \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const item = await client.equipment.get("8b0d2f4a-6c8e-4a0b-8d2f-4a6c8e0b2d4f"); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/equipment/8b0d2f4a-6c8e-4a0b-8d2f-4a6c8e0b2d4f", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") item = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` holds the result. | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `name` | string | Yes | — | | `category` | string | Yes | The kind of equipment: `balance_scale`, `autoclave_pressure_cooker`, `laminar_flow_hood`, `still_air_box`, `incubator`, `light_rack`, `fridge_freezer`, `ph_ec_meter`, `microscope`, `label_printer`, `other`. | | `status` | string | Yes | `active`, or `archived` once the lab has retired it. | | `manufacturer` | string \| null | Yes | — | | `model` | string \| null | Yes | — | | `serial_number` | string \| null | Yes | — | | `location` | string \| null | Yes | Where it is kept, as the lab wrote it. | | `purchase_date` | string \| null | Yes | ISO 8601 date. | | `vendor_url` | string \| null | Yes | A link to the product or supplier page. | | `notes` | string \| null | Yes | — | | `last_calibrated_at` | string \| null | Yes | When a calibration schedule was last completed, as an ISO 8601 timestamp. | | `next_calibration_due_at` | string \| null | Yes | The date the next calibration is due (ISO 8601 date), or null when no calibration schedule is active. | | `last_maintenance_at` | string \| null | Yes | When a maintenance schedule was last completed, as an ISO 8601 timestamp. | | `next_maintenance_due_at` | string \| null | Yes | The date the next preventive maintenance is due (ISO 8601 date), or null when no maintenance schedule is active. | | `created_at` | string \| null | Yes | — | | `updated_at` | string \| null | Yes | — | ```json title="Response" { "ok": true, "data": { "id": "8c2f4e61-5a3b-4d7e-9b10-3e6a2c9d4f18", "name": "Autoclave 2", "category": "autoclave_pressure_cooker", "status": "active", "manufacturer": "Example Instruments", "model": "AC-40", "serial_number": "AC40-00172", "location": "Media room", "purchase_date": "2025-03-14", "vendor_url": null, "notes": null, "last_calibrated_at": "2026-06-02T08:30:00.000Z", "next_calibration_due_at": "2026-12-02", "last_maintenance_at": null, "next_maintenance_due_at": null, "created_at": "2025-03-20T10:04:11.000Z", "updated_at": "2026-06-02T08:31:40.000Z" } } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No record with this id exists in the key's workspace. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `LAB_EQUIPMENT_QUERY_FAILED` | The equipment could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List equipment events > A piece of equipment's history, newest first: what it was used on, and its calibration and preventive maintenance records. Each event has the fields of its kind; the others are null. Source: https://docs.xplantpro.com/docs/api/equipment/list-equipment-events `GET https://app.xplantpro.com/api/v1/equipment/{id}/events` - Required scope: `read:equipment` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Without `kind` you get every kind in one list, which pages by `cursor` only. Send `kind` to read one kind, which pages by `cursor` or `offset`. This history is part of equipment calibration, which not every plan includes. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `kind` | `"used"` \| `"calibration"` \| `"preventive_maintenance"` | No | Only this kind of event: `used`, `calibration` or `preventive_maintenance`. Without it you get every kind, newest first. | | `from` | string (date-time) | No | Only events at or after this time, as an ISO 8601 timestamp. | | `to` | string (date-time) | No | Only events at or before this time, as an ISO 8601 timestamp. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/equipment/8b0d2f4a-6c8e-4a0b-8d2f-4a6c8e0b2d4f/events?kind=calibration" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const events = await client.equipment.listEvents("8b0d2f4a-6c8e-4a0b-8d2f-4a6c8e0b2d4f", { kind: "calibration" }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/equipment/8b0d2f4a-6c8e-4a0b-8d2f-4a6c8e0b2d4f/events", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"kind": "calibration"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") events = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `equipment_id` | string (uuid) | Yes | — | | `kind` | string | Yes | `used` for a use; `calibration`, `preventive_maintenance` for a maintenance record. | | `occurred_at` | string \| null | Yes | When it happened — the use, or the work — as an ISO 8601 timestamp in UTC. | | `outcome` | string \| null | Yes | Maintenance only: `pass`, `pass_after_adjustment`, `out_of_tolerance`, `fail`, `not_performed`. | | `subject_type` | string \| null | Yes | Uses only — what it was used on: `sop_log`, `media_batch`, `plant_transfer`, `explant_transfer`, `contamination_log`. | | `subject_id` | string \| null (uuid) | Yes | Uses only: the id of that record. | | `subject_label` | string \| null | Yes | Uses only: a readable name for that record. | | `performed_by_name` | string \| null | Yes | Maintenance only: who did the work, a person or a service company. | | `provider` | string \| null | Yes | Maintenance only: the service provider, if any. | | `as_found_condition` | string \| null | Yes | Maintenance only: the state the instrument was found in, before any adjustment. | | `as_left_condition` | string \| null | Yes | Maintenance only: the state it was left in. | | `result_summary` | string \| null | Yes | — | | `certificate_number` | string \| null | Yes | — | | `certificate_url` | string \| null | Yes | — | | `next_due_at` | string \| null | Yes | Maintenance only: the next due date the work set, as an ISO 8601 date. | | `notes` | string \| null | Yes | — | ```json title="Response" { "ok": true, "data": [ { "id": "3f7a9c20-8e41-4b5d-a6c2-1d9e0b7f5a34", "equipment_id": "8c2f4e61-5a3b-4d7e-9b10-3e6a2c9d4f18", "kind": "used", "occurred_at": "2026-09-24T07:15:00.000Z", "outcome": null, "subject_type": "media_batch", "subject_id": "b1e6d8a4-2c7f-4e93-8a05-6f4c3d2b1a90", "subject_label": "MS half strength, batch 14", "performed_by_name": null, "provider": null, "as_found_condition": null, "as_left_condition": null, "result_summary": null, "certificate_number": null, "certificate_url": null, "next_due_at": null, "notes": "121 °C for 20 minutes" }, { "id": "c5d2e8f1-7a3b-4c69-9e14-0b8a6d4f2e57", "equipment_id": "8c2f4e61-5a3b-4d7e-9b10-3e6a2c9d4f18", "kind": "calibration", "occurred_at": "2026-06-02T08:30:00.000Z", "outcome": "pass", "subject_type": null, "subject_id": null, "subject_label": null, "performed_by_name": "Example Calibration Services", "provider": "Example Calibration Services", "as_found_condition": "Within tolerance", "as_left_condition": "Within tolerance", "result_summary": "Temperature and pressure checked at three points.", "certificate_number": "ECS-2026-0412", "certificate_url": null, "next_due_at": "2026-12-02", "notes": null } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `402` | `FEATURE_NOT_INCLUDED` | Equipment calibration and maintenance records are not included in your 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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | No record with this id exists in the key's workspace. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | A query parameter is malformed, `from` is later than `to`, or `offset` was sent without `kind`. `error` names the parameter. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `LAB_EQUIPMENT_QUERY_FAILED` | The equipment could not be read. Retry later. | | `500` | `FEATURE_CHECK_FAILED` | Your plan could not be checked. Retry later. | | `500` | `EQUIPMENT_EVENT_QUERY_FAILED` | The history could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Record an equipment event > Records that a piece of equipment was used, calibrated or serviced — from the instrument itself, or from any system that knows. Events are history: there is no update or delete, and a correction is another event. Source: https://docs.xplantpro.com/docs/api/equipment/create-equipment-event `POST https://app.xplantpro.com/api/v1/equipment/{id}/events` - Required scope: `write:equipment_events` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: honoured (24 hours) `used` records what the equipment was used on; `subject_id` must be a record in the same workspace. `calibration` and `preventive_maintenance` record maintenance with an `outcome`, and are part of equipment calibration, which not every plan includes. Recording an event does not move the equipment's calibration or maintenance due dates; those follow the lab's schedules in xPlant. See also: [Equipment events](https://docs.xplantpro.com/docs/guides/equipment-events.md). Send an `Idempotency-Key` header to make retries safe: a repeat with the same key within 24 hours returns the first response instead of writing twice. See [Idempotency](https://docs.xplantpro.com/docs/idempotency.md). ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | The record's id. | ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | No | Any unique string you choose per logical write. A retry carrying the same key within 24 hours returns the first result instead of writing again. Scoped to your API key and this operation. 8–255 characters. Must match `^[A-Za-z0-9._:~-]+$`. | ## Request body The body is one of: ### A single object | Field | Type | Required | Description | | --- | --- | --- | --- | | `kind` | `"used"` | Yes | The equipment was used. | | `subject_type` | `"sop_log"` \| `"media_batch"` \| `"plant_transfer"` \| `"explant_transfer"` \| `"contamination_log"` | Yes | What it was used on: `sop_log`, `media_batch`, `plant_transfer`, `explant_transfer`, `contamination_log`. | | `subject_id` | string (uuid) | Yes | The id of that record. It must belong to the key's workspace. | | `subject_label` | string | No | A readable name for the record, kept with the event. Up to 200 characters. | | `used_at` | string (date-time) | No | When it was used, as an ISO 8601 timestamp in UTC (ending in `Z`). Defaults to now. | | `notes` | string | No | Up to 2000 characters. | ### A single object | Field | Type | Required | Description | | --- | --- | --- | --- | | `kind` | `"calibration"` \| `"preventive_maintenance"` | Yes | `calibration`, or `preventive_maintenance` for a service. | | `outcome` | `"pass"` \| `"pass_after_adjustment"` \| `"out_of_tolerance"` \| `"fail"` \| `"not_performed"` | No | How it went: `pass`, `pass_after_adjustment`, `out_of_tolerance`, `fail`, `not_performed`. `out_of_tolerance` means the instrument was found outside its specification before any adjustment. Default `"pass"`. | | `performed_at` | string (date-time) | No | When the work was done, as an ISO 8601 timestamp in UTC (ending in `Z`). Defaults to now. | | `performed_by_name` | string | No | Who did the work — a person or a service company. Up to 200 characters. | | `result_summary` | string | No | Up to 2000 characters. | | `notes` | string | No | Up to 2000 characters. | ## Example **curl** ```bash curl -X POST https://app.xplantpro.com/api/v1/equipment/8b0d2f4a-6c8e-4a0b-8d2f-4a6c8e0b2d4f/events \ -H "Authorization: Bearer $XPLANT_API_KEY" \ -H "Idempotency-Key: autoclave-2-cycle-4411" \ -H "Content-Type: application/json" \ -d '{ "kind": "calibration", "outcome": "pass", "performed_at": "2026-06-02T08:30:00Z", "performed_by_name": "Example Calibration Services", "result_summary": "Temperature and pressure checked at three points." }' ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const event = await client.equipment.recordEvent( "8b0d2f4a-6c8e-4a0b-8d2f-4a6c8e0b2d4f", { kind: "calibration", outcome: "pass", performed_at: "2026-06-02T08:30:00Z", performed_by_name: "Example Calibration Services", result_summary: "Temperature and pressure checked at three points.", }, { idempotencyKey: "autoclave-2-cycle-4411" }, ); ``` **Python** ```python import os import requests resp = requests.post( "https://app.xplantpro.com/api/v1/equipment/8b0d2f4a-6c8e-4a0b-8d2f-4a6c8e0b2d4f/events", headers={ "Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}", "Idempotency-Key": "autoclave-2-cycle-4411", }, json={ "kind": "calibration", "outcome": "pass", "performed_at": "2026-06-02T08:30:00Z", "performed_by_name": "Example Calibration Services", "result_summary": "Temperature and pressure checked at three points.", }, 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 | The recorded event's id, as it appears in the equipment's history. | | `equipmentId` | string (uuid) | Yes | — | | `kind` | string | Yes | The kind that was recorded. | | `recordedAt` | string | Yes | When the use or the work happened, as the event records it. ISO 8601 timestamp. | ```json title="Response" { "ok": true, "data": { "id": "c5d2e8f1-7a3b-4c69-9e14-0b8a6d4f2e57", "equipmentId": "8c2f4e61-5a3b-4d7e-9b10-3e6a2c9d4f18", "kind": "calibration", "recordedAt": "2026-06-02T08:30:00+00:00" } } ``` | Response header | Meaning | | --- | --- | | `Idempotent-Replay` | `true` when this response is a replay of an earlier request with the same `Idempotency-Key`. | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `402` | `FEATURE_NOT_INCLUDED` | A calibration or maintenance record was sent, and equipment calibration is not included in your 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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | The equipment, or a use's `subject_id`, is not in the key's workspace. | | `409` | `IDEMPOTENCY_IN_FLIGHT` | A request with this `Idempotency-Key` is still being processed; retry after `Retry-After` seconds. | | `422` | `VALIDATION_ERROR` | The `Idempotency-Key` header is malformed. | | `422` | `VALIDATION_ERROR` | A field failed validation. `error` names the first one, for example `title: title is required`. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `FEATURE_CHECK_FAILED` | Your plan could not be checked. Retry later. | | `500` | `EQUIPMENT_EVENT_CREATE_FAILED` | The event could not be saved. Retry with the same `Idempotency-Key`. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List order lines > Your store's sales, line by line, most recent first: which product sold, the culture line it is matched to, how many, at what unit price, and when. Filter with from and to for a period and product_link_id for one product. Source: https://docs.xplantpro.com/docs/api/commerce/list-order-lines `GET https://app.xplantpro.com/api/v1/commerce/order-lines` - Required scope: `read:commerce` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Order lines carry no buyer details — no names, email or postal addresses — and leave out your store's order numbers. For totals, use the sell-through summary rather than adding these lines up: amounts are exact decimal text, and lines in different currencies must never be added together. Needs culture line pricing, which includes sell-through, in the workspace's plan; without it the answer is `FEATURE_NOT_INCLUDED`. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string (date-time) | No | Only sales at or after this time, as an ISO 8601 timestamp. | | `to` | string (date-time) | No | Only sales at or before this time, as an ISO 8601 timestamp. | | `product_link_id` | string (uuid) | No | Only lines for this store product, by the `product_link_id` an order line carries. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/commerce/order-lines?from=2026-09-01T00%3A00%3A00Z&to=2026-09-25T00%3A00%3A00Z" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const lines = await client.commerce.listOrderLines({ from: "2026-09-01T00:00:00Z", to: "2026-09-25T00:00:00Z" }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/commerce/order-lines", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={ "from": "2026-09-01T00:00:00Z", "to": "2026-09-25T00:00:00Z", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") lines = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `product_link_id` | string \| null (uuid) | Yes | The store product this line sold, as xPlant tracks it; null when it is not tracked yet. Pass it as `product_link_id` to list that product's lines. | | `plant_id` | string \| null (uuid) | Yes | The culture line the product is matched to (a plant id). Null until someone in the lab matches the product. | | `store_product_id` | string \| null | Yes | Your store's own id for the product. | | `store_variant_id` | string \| null | Yes | Your store's own id for the variant, when the product has variants. | | `quantity` | integer | Yes | — | | `unit_price` | object \| null | Yes | The price per unit, with its currency. Null when your store reported none. | | `occurred_at` | string \| null | Yes | When the sale happened in your store, as an ISO 8601 timestamp in UTC. | ```json title="Response" { "ok": true, "data": [ { "id": "9b3d5f7a-1c2e-4a4b-8d6f-0e2a4c6e8b1d", "product_link_id": "f2a4c6e8-0b1d-4e3f-a5b7-c9d1e3f5a7b9", "plant_id": "6d1b3f8a-4c2e-4a97-b5d0-8e7f2a1c9b43", "store_product_id": "prod-5521", "store_variant_id": "var-5521-4in", "quantity": 3, "unit_price": { "amount": "18.50", "currency": "USD" }, "occurred_at": "2026-09-21T15:02:44.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `402` | `FEATURE_NOT_INCLUDED` | Culture line pricing, which includes sell-through, is not included in your 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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | `product_link_id` names no store product in the key's workspace. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | A query parameter is malformed, or `from` is later than `to`. `error` names it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `FEATURE_CHECK_FAILED` | Your plan could not be checked. Retry later. | | `500` | `SELL_THROUGH_EVENT_QUERY_FAILED` | The sales could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # Get sell-through > Units sold and revenue per culture line over a period, highest units first. Filter with from and to for the period and plant_id for one culture line. Source: https://docs.xplantpro.com/docs/api/commerce/get-sell-through `GET https://app.xplantpro.com/api/v1/commerce/sell-through` - Required scope: `read:commerce` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Each group is one culture line in one currency. There is no combined total across currencies: groups in different currencies are separate figures and must not be added together. Revenue is exact decimal text, totalled from the recorded sales without floating-point rounding. Pages by `offset`. Needs culture line pricing, which includes sell-through, in the workspace's plan; without it the answer is `FEATURE_NOT_INCLUDED`. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string (date-time) | No | Only sales at or after this time, as an ISO 8601 timestamp. | | `to` | string (date-time) | No | Only sales at or before this time, as an ISO 8601 timestamp. | | `plant_id` | string (uuid) | No | Only this culture line's sales. A culture line is identified by its plant id. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/commerce/sell-through?from=2026-09-01T00%3A00%3A00Z&to=2026-09-25T00%3A00%3A00Z" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const sellThrough = await client.commerce.getSellThrough({ from: "2026-09-01T00:00:00Z", to: "2026-09-25T00:00:00Z" }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/commerce/sell-through", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={ "from": "2026-09-01T00:00:00Z", "to": "2026-09-25T00:00:00Z", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") sellThrough = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. Lists have no total: a page shorter than your `limit` is the last one. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `plant_id` | string \| null (uuid) | Yes | The culture line (a plant id). Null groups the sales of store products not yet matched to one. | | `currency` | string \| null | Yes | The currency of every amount in this group. Null for lines that carried none. | | `units` | integer | Yes | Total quantity sold. | | `revenue` | object | Yes | — | | `revenue.amount` | string | Yes | An exact decimal, as text — for example `"1250.00"`. Never a floating-point number; parse it with a decimal type. | | `revenue.currency` | string \| null | Yes | ISO 4217 currency code, for example `USD`. Null only when the record carries no currency. Never add amounts in different currencies. | | `order_line_count` | integer | Yes | — | | `priced_line_count` | integer | Yes | Order lines that carried a unit price. Lines without one count toward `units` but not `revenue`. | | `first_occurred_at` | string \| null | Yes | The earliest sale in the group. | | `last_occurred_at` | string \| null | Yes | The latest sale in the group. | ```json title="Response" { "ok": true, "data": [ { "plant_id": "6d1b3f8a-4c2e-4a97-b5d0-8e7f2a1c9b43", "currency": "USD", "units": 42, "revenue": { "amount": "777.00", "currency": "USD" }, "order_line_count": 17, "priced_line_count": 17, "first_occurred_at": "2026-07-02T10:11:00.000Z", "last_occurred_at": "2026-09-21T15:02:44.000Z" }, { "plant_id": "6d1b3f8a-4c2e-4a97-b5d0-8e7f2a1c9b43", "currency": "EUR", "units": 6, "revenue": { "amount": "96.00", "currency": "EUR" }, "order_line_count": 2, "priced_line_count": 2, "first_occurred_at": "2026-08-14T08:30:00.000Z", "last_occurred_at": "2026-09-03T12:45:10.000Z" } ] } ``` | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `402` | `FEATURE_NOT_INCLUDED` | Culture line pricing, which includes sell-through, is not included in your 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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | `plant_id` names no culture line in the key's workspace. | | `422` | `VALIDATION_ERROR` | A query parameter is malformed, or `from` is later than `to`. `error` names it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `FEATURE_CHECK_FAILED` | Your plan could not be checked. Retry later. | | `500` | `SELL_THROUGH_EVENT_QUERY_FAILED` | The sales could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List culture line prices > Each priced culture line's current list and wholesale price and its pricing tier, newest first by when the line was first priced. Filter to one culture line with plant_id, or to one tier with pricing_tier. Source: https://docs.xplantpro.com/docs/api/commerce/list-culture-line-prices `GET https://app.xplantpro.com/api/v1/pricing/culture-lines` - Required scope: `read:pricing` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Every amount is exact decimal text with its currency. Never add amounts in different currencies. Needs culture line pricing in the workspace's plan; without it the answer is `FEATURE_NOT_INCLUDED`. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `plant_id` | string (uuid) | No | Only this culture line's price. A culture line is identified by its plant id. | | `pricing_tier` | string | No | Only culture lines in this pricing tier, spelled exactly as your lab names it. 1–40 characters. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/pricing/culture-lines?plant_id=0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const prices = await client.pricing.listCultureLines({ plant_id: "0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90" }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/pricing/culture-lines", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={"plant_id": "0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90"}, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") prices = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `plant_id` | string (uuid) | Yes | The culture line this price is for (a plant id). | | `list_price` | object | Yes | — | | `list_price.amount` | string | Yes | An exact decimal, as text — for example `"1250.00"`. Never a floating-point number; parse it with a decimal type. | | `list_price.currency` | string \| null | Yes | ISO 4217 currency code, for example `USD`. Null only when the record carries no currency. Never add amounts in different currencies. | | `wholesale_price` | object \| null | Yes | — | | `previous_list_price` | object \| null | Yes | The list price before the latest change, in the currency it was set in. Null until the price has changed once. | | `pricing_tier` | string \| null | Yes | The line's pricing tier, as your lab names its tiers. | | `tier_score` | string \| null | Yes | The score the tier was derived from, as exact decimal text. Not money. | | `price_source` | string | Yes | Who set the list price: `manual`, `store_adopted`, `store_follow`. `manual` is a person typing it, `store_adopted` a person accepting the price from your store, and `store_follow` an automatic update that follows your store's price. | | `price_source_at` | string \| null | Yes | When the list price was last set that way, as an ISO 8601 timestamp. | | `notes` | string \| null | Yes | — | | `created_at` | string \| null | Yes | When the culture line was first priced. | | `updated_at` | string \| null | Yes | — | ```json title="Response" { "ok": true, "data": [ { "id": "a4c8e2f6-1b3d-4f5a-9c7e-2d4b6f8a0c12", "plant_id": "6d1b3f8a-4c2e-4a97-b5d0-8e7f2a1c9b43", "list_price": { "amount": "18.50", "currency": "USD" }, "wholesale_price": { "amount": "11.00", "currency": "USD" }, "previous_list_price": { "amount": "16.00", "currency": "USD" }, "pricing_tier": "B", "tier_score": "72.500", "price_source": "manual", "price_source_at": "2026-09-10T09:12:00.000Z", "notes": null, "created_at": "2026-02-03T11:40:22.000Z", "updated_at": "2026-09-10T09:12:00.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `402` | `FEATURE_NOT_INCLUDED` | Culture line pricing is not included in your 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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | `plant_id` names no culture line in the key's workspace. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | A query parameter is malformed. `error` names it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `FEATURE_CHECK_FAILED` | Your plan could not be checked. Retry later. | | `500` | `CULTURE_LINE_PRICE_QUERY_FAILED` | The prices could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # List price changes > Every change to a culture line's price, newest first: what it moved from, what it moved to, when, who or what moved it. Filter with plant_id for one culture line and from and to for a period. Source: https://docs.xplantpro.com/docs/api/commerce/list-price-events `GET https://app.xplantpro.com/api/v1/pricing/events` - Required scope: `read:pricing` - Credentials: workspace API key (`xpk_`) - Idempotency-Key: ignored on this endpoint Each amount carries its own currency, and a previous price may be in a different currency from the new one. Needs culture line pricing in the workspace's plan; without it the answer is `FEATURE_NOT_INCLUDED`. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `plant_id` | string (uuid) | No | Only changes to this culture line's price (a plant id). | | `from` | string (date-time) | No | Only changes made at or after this time, as an ISO 8601 timestamp. | | `to` | string (date-time) | No | Only changes made at or before this time, as an ISO 8601 timestamp. | | `limit` | integer | No | Page size. Values above 200 are capped at 200. Default `50`. From 1 to 200. | | `offset` | integer | No | Number of records to skip. Prefer `cursor` where a list offers it: an offset shifts when records are added ahead of it. Default `0`. At least 0. | | `cursor` | string | No | Continue from the previous page: pass its `meta.next_cursor` unchanged, with the same filters. Treat it as opaque. Not combinable with `offset`. Up to 2048 characters. | ## Example **curl** ```bash curl "https://app.xplantpro.com/api/v1/pricing/events?plant_id=0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90&from=2026-09-01T00%3A00%3A00Z" \ -H "Authorization: Bearer $XPLANT_API_KEY" ``` **JavaScript** ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); const events = await client.pricing.listEvents({ plant_id: "0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90", from: "2026-09-01T00:00:00Z", }); ``` **Python** ```python import os import requests resp = requests.get( "https://app.xplantpro.com/api/v1/pricing/events", headers={"Authorization": f"Bearer {os.environ['XPLANT_API_KEY']}"}, params={ "plant_id": "0b8e2f4c-6a1d-4c3e-9f7a-2d5b8c1e4a90", "from": "2026-09-01T00:00:00Z", }, timeout=10, ) body = resp.json() if not body["ok"]: raise RuntimeError(f"{resp.status_code} {body['code']}: {body['error']}") events = body["data"] ``` ## Response `200` with `{ "ok": true, "data": … }`. `data` is an array. To get the next page, pass `meta.next_cursor` back as `cursor`; it is `null` on the last page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | Yes | — | | `plant_id` | string (uuid) | Yes | The culture line whose price changed (a plant id). | | `list_price` | object | Yes | — | | `list_price.amount` | string | Yes | An exact decimal, as text — for example `"1250.00"`. Never a floating-point number; parse it with a decimal type. | | `list_price.currency` | string \| null | Yes | ISO 4217 currency code, for example `USD`. Null only when the record carries no currency. Never add amounts in different currencies. | | `previous_list_price` | object \| null | Yes | The price it moved from, in its own currency. Null for a line's first price. | | `price_source` | string | Yes | Who set the list price: `manual`, `store_adopted`, `store_follow`. `manual` is a person typing it, `store_adopted` a person accepting the price from your store, and `store_follow` an automatic update that follows your store's price. | | `changed_by` | string \| null (uuid) | Yes | The workspace member who changed it. Null for an automatic change. | | `changed_at` | string \| null | Yes | When the price moved, as an ISO 8601 timestamp in UTC. | ```json title="Response" { "ok": true, "data": [ { "id": "e7b1d3f5-9a2c-4e6b-8d0f-3a5c7e9b1d24", "plant_id": "6d1b3f8a-4c2e-4a97-b5d0-8e7f2a1c9b43", "list_price": { "amount": "18.50", "currency": "USD" }, "previous_list_price": { "amount": "16.00", "currency": "USD" }, "price_source": "manual", "changed_by": "0d7e4b9a-6f21-4c3e-8a5b-2e9f1c7d4a60", "changed_at": "2026-09-10T09:12:00.000Z" } ] } ``` The envelope can also carry `meta`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `next_cursor` | string \| null | Yes | Pass as `cursor` to fetch the next page. `null` means this is the last page. | | Response header | Meaning | | --- | --- | | `X-Request-Id` | Identifies this request. Include it when you contact support. | ## Errors | Status | Code | When | | --- | --- | --- | | `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's plan does not include the API. The full API is included with xPlant+ Teams and Enterprise; on Hobby and Pro Lab, keys can connect devices only. | | `402` | `FEATURE_NOT_INCLUDED` | Culture line pricing is not included in your 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_NOT_ACCEPTED` | A device token was sent; this operation needs a workspace API key. | | `404` | `NOT_FOUND` | `plant_id` names no culture line in the key's workspace. | | `422` | `VALIDATION_ERROR` | Both `cursor` and `offset` were sent; use one. | | `422` | `INVALID_CURSOR` | The cursor is malformed, or came from a different list or different filters. Start again without it. | | `422` | `VALIDATION_ERROR` | A query parameter is malformed, or `from` is later than `to`. `error` names it. | | `429` | `RATE_LIMIT_EXCEEDED` | Too many requests for this key, device token or workspace. Wait `Retry-After` seconds. | | `500` | `FEATURE_CHECK_FAILED` | Your plan could not be checked. Retry later. | | `500` | `CULTURE_LINE_PRICE_QUERY_FAILED` | The prices could not be read. Retry later. | Branch on `code`, never on the `error` text. See [Errors](https://docs.xplantpro.com/docs/errors.md). --- # JavaScript SDK > @shmaplex/xplant-sdk: a typed server-side client for Node.js 18+ and other fetch runtimes, with retries, timeouts, idempotency and paging built in. Source: https://docs.xplantpro.com/docs/sdk ```bash npm install @shmaplex/xplant-sdk ``` The source, issues and full reference live at **[github.com/shmaplex/xplant_sdk](https://github.com/shmaplex/xplant_sdk#readme)**. Every endpoint page in the [API reference](https://docs.xplantpro.com/docs/api.md) shows the SDK call next to curl and Python. These docs match **@shmaplex/xplant-sdk 0.6.0**. The SDK runs on **Node.js 18+** and any runtime with standard `fetch`: Deno, Bun, edge and serverless functions. **Call the API from your server, not a web page.** The API doesn't send CORS headers, and keys don't belong in front-end code. ## Create a client ```js import { XPlantClient } from "@shmaplex/xplant-sdk"; // On a server or in a script: a workspace key. const client = new XPlantClient({ apiKey: process.env.XPLANT_API_KEY }); // On a device: a device token. const device = new XPlantClient({ deviceToken: process.env.XPLANT_DEVICE_TOKEN }); ``` | Option | | | --- | --- | | `apiKey` | A workspace key (`xpk_…`). | | `deviceToken` | A device token (`xpd_…`). Give exactly one of `apiKey` or `deviceToken`. The client refuses a `deviceToken` that doesn't start with `xpd_`, so a workspace key can't end up on a device by mistake. | | `baseUrl` | Defaults to `https://app.xplantpro.com`. You shouldn't need to change it. | | `retry` | `true`, or `{ maxRetries, baseDelayMs, maxDelayMs }`. Off unless set. | | `timeout` | Milliseconds per attempt. Defaults to 60 seconds; `0` means no timeout. Can also be set per call. | | `fetch` | Your own `fetch`, for testing or unusual runtimes. | ## Lists and paging Awaiting a list method gives you one page, as an array. Iterating it gives you every row, fetched a page at a time as you go: ```js // One page. const plants = await client.plants.list({ limit: 200 }); // Every row. for await (const plant of client.plants.list()) { console.log(plant.id); } // Page by page, with a cursor you can save and resume from. for await (const { data, nextCursor, hasMore } of client.explants.list().pages()) { await save(data, nextCursor); } const next = await client.explants.list({ cursor: savedCursor }); ``` Every list pages by cursor, including `devices.list()`, `devices.listTokens(deviceId)` and `sensorReadings.list()`. Pass request options such as `{ signal }` after the list parameters: `devices.list(params, options)`, `devices.listTokens(deviceId, params, options)`. `devices.get(id)` searches every page. See [Pagination](https://docs.xplantpro.com/docs/pagination.md). ## Errors ```js import { XPlantError, XPlantConnectionError, XPlantTimeoutError } from "@shmaplex/xplant-sdk"; try { await client.tasks.create({ title: "Check jar 47" }); } catch (err) { if (err instanceof XPlantError) { // The API answered with an error. console.log(err.status, err.code, err.message, err.retryAfter, err.requestId); } else if (err instanceof XPlantTimeoutError) { // No answer within the timeout. console.log(`Timed out after ${err.timeout} ms`); } else if (err instanceof XPlantConnectionError) { // Never got an answer; the underlying fetch error is on err.cause. } } ``` Branch on `err.code`. `err.message` includes the API's `error` text, which may be reworded. `XPlantTimeoutError` is a kind of `XPlantConnectionError`, so one check covers both. ## Request IDs Every response carries an `X-Request-Id`. The SDK exposes it as `requestId`: on errors (`err.requestId`), and on each page from `.pages()` (`page.requestId`). Log it, and quote it when you contact support. ```js try { await client.tasks.get(taskId); } catch (err) { if (err instanceof XPlantError) console.error(`${err.code} (request ${err.requestId})`); } ``` ## Retries With `retry` on, the client: - waits out `Retry-After` on `429 RATE_LIMIT_EXCEEDED` and `409 IDEMPOTENCY_IN_FLIGHT`, up to `maxDelayMs`; - backs off exponentially on `502`, `503`, `504` and connection errors, for reads and for writes that are safe to replay; - on endpoints that honour [idempotency](https://docs.xplantpro.com/docs/idempotency.md), generates an `Idempotency-Key` if you didn't pass one, and reuses it across its own attempts. ## Idempotency Pass `{ idempotencyKey }` as the last argument to any write: ```js await client.sopRuns.start({ sop_id: sopId }, { idempotencyKey: "station-3-wk38-start" }); ``` ## Buffering sensor readings on a device For a device posting readings continuously, the buffer batches, retries and de-duplicates for you: ```js const device = new XPlantClient({ deviceToken: process.env.XPLANT_DEVICE_TOKEN }); const buffer = device.sensorReadings.buffer({ flushIntervalMs: 30_000, maxBatch: 500, onError: (err) => console.error(err), }); buffer.add({ device_id: process.env.XPLANT_DEVICE_ID, type: "temperature", value: 23.4, unit: "C" }); // On shutdown, send what's left: await buffer.close(); ``` `sensorReadings.create()` resolves to the stored reading, or to `null` when the reading duplicated one already stored (same device, `external_id` and `recorded_at`). The buffer stamps `recorded_at` and `external_id` on each reading, keeps readings through outages (up to 10,000), and drops and reports readings that are invalid. The queue is held in memory only, so readings still queued when the process dies are lost. See [Sensors and devices](https://docs.xplantpro.com/docs/guides/sensors-and-devices.md). ## Resources | Resource | Methods | | --- | --- | | `client.me` | `get` | | `client.workspaces` | `list` | | `client.plants` | `list`, `get`, `findByExternalId`, `create`, `update` | | `client.explants` | `list`, `get`, `findByExternalId`, `create`, `update` | | `client.transfers` | `list`, `create` | | `client.stages` | `list`, `advance` | | `client.events` | `list` | | `client.tasks` | `list`, `get`, `create`, `update` | | `client.taskDemand` | `list`, `record` | | `client.sops` | `list`, `get` | | `client.sopRuns` | `start`, `get`, `recordStepEvent`, `recordMeasurement` | | `client.labels` | `resolve`, `recordScan` | | `client.devices` | `list`, `get`, `register`, `heartbeat`, `listTokens`, `createToken`, `revokeToken`, `recordEvent` | | `client.sensorReadings` | `list`, `create`, `createBatch`, `buffer` | | `client.equipment` | `list`, `get`, `listEvents`, `recordEvent` | | `client.contaminations` | `list`, `get`, `create` | | `client.comments` | `list`, `create` | | `client.assets` | `list`, `get`, `create` | | `client.mediaRecipes` | `list`, `get`, `create`, `update` | | `client.pricing` | `listCultureLines`, `listEvents` | | `client.commerce` | `listOrderLines`, `getSellThrough` | A device client can only call `sensorReadings.create`, `sensorReadings.createBatch`, `sensorReadings.buffer`, `devices.heartbeat` and `devices.recordEvent`, and only for its own device. ## Other languages There's no official SDK for other languages yet. The API is plain HTTPS and JSON, and every reference page has a Python example using `requests`. For a generated client, use the [OpenAPI spec](/openapi.json). --- # Hardware and examples > Open-source firmware, gateways and example scripts for connecting lab devices to xPlant. Source: https://docs.xplantpro.com/docs/hardware The [xplant_os repository](https://github.com/shmaplex/xplant_os) holds ready-to-adapt code for common lab hardware. Each device carries a [device token](https://docs.xplantpro.com/docs/device-tokens.md), never a workspace key. ## Devices | Package | What it does | | --- | --- | | [Raspberry Pi gateway](https://github.com/shmaplex/xplant_os/tree/main/devices/raspberry-pi/pi-gateway) | Python bridge from serial or MQTT sensors to xPlant, with a systemd service. | | [ESP32 sensor](https://github.com/shmaplex/xplant_os/tree/main/devices/arduino/esp32-sensor) | ESP32 with a DHT22 or BME280, posting temperature and humidity. | | [ESPHome](https://github.com/shmaplex/xplant_os/tree/main/devices/esphome) | YAML for ESPHome-flashed sensors. | | [Tasmota](https://github.com/shmaplex/xplant_os/tree/main/devices/tasmota) | Rules for Tasmota devices. | | [ESP32 scan station](https://github.com/shmaplex/xplant_os/tree/main/devices/arduino/esp32-scan-station) | Concept: a standalone QR and barcode scanner. | | [Bench kiosk](https://github.com/shmaplex/xplant_os/tree/main/devices/raspberry-pi/pi-bench-kiosk) | Concept: a touchscreen station for the bench. | ## Examples | Example | What it shows | | --- | --- | | [basic-sensor](https://github.com/shmaplex/xplant_os/tree/main/examples/basic-sensor) | Post a reading with curl, Node.js or Python. | | [transfer-counter](https://github.com/shmaplex/xplant_os/tree/main/examples/transfer-counter) | Record a transfer from a script or a button. | | [contamination-check](https://github.com/shmaplex/xplant_os/tree/main/examples/contamination-check) | Scan a vessel label and record a contamination against it. | | [local-dashboard](https://github.com/shmaplex/xplant_os/tree/main/examples/local-dashboard) | Ideas for a local sensor dashboard. | Built something for your lab's hardware? Contributions are welcome; see [CONTRIBUTING.md](https://github.com/shmaplex/xplant_os/blob/main/CONTRIBUTING.md).