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