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