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

A transfer is recorded as `completed`: work that has been done, which xPlant counts when it works out when a culture was last transferred and which cultures are due. Send `status: "pending"` to record one that is planned instead.

The transfer is added to the plant's or explant's history, as one recorded in xPlant is: `GET /api/v1/events` returns it as a `transfer` event.

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. |
| `status` | `"completed"` \| `"pending"` | No | `completed`, the default, records a transfer that has been done. `pending` records one planned for `transfer_date`; it is not counted as done until someone marks it completed in xPlant. |
| `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 | `completed` for a transfer that has been done, `pending` for one planned but not done yet, `cancelled` for one that was called off. A transfer recorded through the API is `completed` unless it was sent as `pending`. Transfers recorded through the API before `status` could be sent may read `active`, which means `completed`. |
| `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": "completed",
    "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).
