# 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" },
);
if (run.trainingWarning) {
  // { qualification: "untrained" | "expired" | "revoked" | "expiring", expires_on }
  showWarning(run.trainingWarning); // your station's UI
}
```

**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"]
```

If your lab enforces SOP training, starting a run checks the key owner's training for this SOP. With enforcement set to **block**, an untrained owner gets `403 TRAINING_REQUIRED` and no run starts. Set to **warn**, the run starts (`201`) and the response carries `meta.training_warning` with the `qualification` and its `expires_on` date; show it to the technician.

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 has **ended** (completed, failed, cancelled or archived), further writes answer `409 SOP_RUN_CLOSED`: its record is what happened. A step that isn't part of the run's version answers `404 NOT_FOUND`.

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.
