# Strand AI Platform API (v1)

REST surface for uploading WSIs, submitting Lattice inference jobs, and streaming results. All endpoints require an API key or Strand AI OAuth access token in the `Authorization: Bearer` header.

_This document is the agent-friendly Markdown view of the OpenAPI spec. The same spec is served as JSON at `/api/v1/openapi.json` and rendered interactively at `/docs/api`._

## Authentication

All endpoints require an API key or Strand AI OAuth access token in the `Authorization: Bearer` header. Both credential types are scoped to one organization.

## Endpoints

### `GET /uploads`

**List uploads for the calling org**

Returns non-archived uploads newest-first. Cursor-paginated; pass the prior response's `nextCursor` to fetch the next page.

Full path: `/api/v1/uploads`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `limit` | `query` | no | `integer` |  |
| `cursor` | `query` | no | `string` |  |

#### Responses

#### `200` — Upload list

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `uploads` | `Upload[]` | yes |  |
| `nextCursor` | `string | null` | no | Opaque cursor for the next page, or null on last page |

#### `400` — Invalid limit or cursor

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/uploads
```

### `POST /uploads`

**Initiate a resumable upload**

Creates an organization-bound GCS resumable session. Slides must be de-identified before upload. Automated slide de-identification is off by default and runs only when enabled for the organization. It does not determine whether a slide satisfies HIPAA or any other legal de-identification standard, and it does not inspect or remove identifiers in tissue pixels or filenames. PUT the declared bytes to `uploadUrl`; when GCS finalizes the object, the platform automatically starts ingest preprocessing. There is no client finalize call. Poll `GET /uploads/{id}` until the sample leaves `uploading`.

Full path: `/api/v1/uploads`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Request body

Content-Type: `application/json` (**required**)

| Field | Type | Required | Description |
|---|---|---|---|
| `filename` | `string` | yes |  |
| `fileSize` | `integer` | yes |  |
| `contentType` | `string` | yes |  |
| `contentSha256` | `string` | no | Optional client-computed SHA-256. On a match within the calling organization, the response returns the existing upload and skips byte transfer. |
| `autoSegment` | `boolean` | no | Opt out of automatic cell segmentation for this upload. Defaults to the organization's `autoSegmentDefault` (true unless changed). When false, the slide is ingested and rendered but no segmentation job runs; marker runs still work but without cell-level readouts until segmented. |
| `mpp` | `any` | no | Optional user-reported microns per pixel, for callers that already know their slide's scale. Slides are isotropic: pass a number, or an { x, y } object whose axes are equal (anisotropic input is rejected). Persisted on the sample at creation and takes precedence over the slide's own calibrated value, so `POST /predict` works as soon as preprocessing finishes — no follow-up `PATCH /samples/{id}` needed. Ignored when `contentSha256` deduplicates to an existing sample (that sample's scale stands). |

#### Responses

#### `200` — Resumable upload URL

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `uploadId` | `string <uuid>` | yes |  |
| `uploadUrl` | `string` | no | Resumable upload URL. Present for a new upload; omitted when `existing` is true. |
| `gcsPath` | `string` | yes |  |
| `existing` | `boolean` | yes | True when `contentSha256` matched an existing active upload in the same organization. |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl -X POST \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  -H "Content-Type: application/json" \
  https://app.strandai.com/api/v1/uploads \
  -d '{ ... }'
```

### `GET /uploads/{id}`

**Fetch a single upload**

Full path: `/api/v1/uploads/{id}`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — Upload

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string <uuid>` | yes |  |
| `filename` | `string` | yes |  |
| `fileSize` | `string` | yes | bytes, stringified bigint |
| `status` | `string` | yes | uploading \| upload_failed \| deid_running \| deid_failed \| preprocessing \| ready \| preprocess_failed |
| `gcsPath` | `string` | yes |  |
| `createdAt` | `string <date-time> | null` | no |  |
| `widthPx` | `integer | null` | no |  |
| `heightPx` | `integer | null` | no |  |
| `autoSegment` | `boolean` | no | Resolved auto-segmentation decision for this upload (per-upload override, else org default, else true). |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Upload not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/uploads/{id}
```

### `GET /markers`

**List available markers**

The entitlement-scoped catalog of markers this account may request. Credit-free. The list equals exactly what predict will accept: a self-signup account sees the public panel; a full-panel account sees the whole vocab. Use it to discover valid marker names upfront instead of trial-and-error against a 400.

Full path: `/api/v1/markers`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Responses

#### `200` — The account's available markers

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `fullPanel` | `boolean` | yes | Whether this account is entitled to the full marker panel. `false` = the public panel only. |
| `count` | `integer` | yes | Number of markers in `markers`. |
| `markers` | `object[]` | yes |  |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/markers
```

### `GET /sample-fields`

**List custom sample fields**

The calling organization's custom-field definitions. Credit-free. This is the discovery surface for the `field` filter on GET /samples: a caller cannot guess a definition's id, so it reads the catalog once and filters by the ids returned here. `key` is stable and unique within the organization; `label` is display text and is not unique.

Full path: `/api/v1/sample-fields`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Responses

#### `200` — The organization's custom-field definitions

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | `integer` | yes | Number of definitions in `fields`. |
| `fields` | `object[]` | yes |  |

#### `401` — Missing or invalid credential

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/sample-fields
```

### `POST /predict`

**Price or submit a prediction**

Set `dryRun: true` to validate and price the prediction without writes or credit reservation (200). Omit it or set it to false to reserve credits atomically and enqueue preprocessing (202).

Full path: `/api/v1/predict`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Request body

Content-Type: `application/json` (**required**)

| Field | Type | Required | Description |
|---|---|---|---|
| `uploadId` | `string <uuid>` | yes |  |
| `markers` | `string[]` | yes |  |
| `model` | `enum ("v0.4", "v0.5", "v0.7")` | no | Optional explicit Lattice version. Only `v0.7` is dispatchable and is the current default; omit `model` to let the platform pick it. `v0.4`/`v0.5` are READ-ONLY as of 2026-06-23: they remain in this enum because they still appear on historical job records, but requesting one returns 400 `model_sunset` (with `currentDefault`). Legacy model strings return 400 `unknown_model`. |
| `dryRun` | `boolean` | no | When true, validates and returns the exact credit estimate without reserving credits, creating a job, or dispatching work. |

#### Responses

#### `200` — Validated dry-run estimate with no side effects

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `dryRun` | `enum (true)` | yes |  |
| `patchCount` | `integer` | yes |  |
| `markerCount` | `integer` | yes |  |
| `estimatedCredits` | `integer` | yes |  |
| `orgBalance` | `integer` | yes |  |
| `orgPending` | `integer` | yes |  |

#### `202` — Job accepted and credits reserved

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `jobId` | `string <uuid>` | yes |  |
| `reservedCredits` | `integer` | yes |  |
| `status` | `enum ("queued")` | yes |  |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `402` — Insufficient credits

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — One or more requested markers are outside this account's panel (`marker_not_available`).

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `409` — Upload not ready

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl -X POST \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  -H "Content-Type: application/json" \
  https://app.strandai.com/api/v1/predict \
  -d '{ ... }'
```

### `GET /jobs/{id}`

**Get job status**

Full path: `/api/v1/jobs/{id}`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — Job

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string <uuid>` | yes |  |
| `status` | `string` | yes | Lifecycle: `queued` → `dispatching` → `preprocessing` → `running` → `postprocessing` → terminal. Terminal values: `completed`, `partial_failed`, `failed`, and `cancelled`. |
| `progress` | `number | null` | yes |  |
| `reservedCredits` | `integer | null` | yes |  |
| `markers` | `string[]` | yes |  |
| `createdAt` | `string <date-time> | null` | yes |  |
| `startedAt` | `string <date-time> | null` | yes |  |
| `completedAt` | `string <date-time> | null` | yes |  |
| `errorMessage` | `string | null` | yes |  |
| `resultsAvailable` | `boolean` | yes |  |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/jobs/{id}
```

### `POST /jobs/{id}/cancel`

**Cancel an in-flight job**

Cancels an active inference job and refunds its credit reservation. Active segmentation is owned by its fenced lifecycle and returns 409; terminal jobs return 400.

Full path: `/api/v1/jobs/{id}/cancel`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — Cancelled

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string <uuid>` | yes |  |
| `status` | `enum ("cancelled")` | yes |  |

#### `400` — Job already terminal

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `409` — Active segmentation is owned by its fenced lifecycle

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl -X POST \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/jobs/{id}/cancel
```

### `GET /jobs/{id}/stream`

**Stream job status (SSE)**

Server-Sent Events stream emitting JSON snapshots of `{status, progress, resultGcsPath}` on each pg_notify. Keep-alive heartbeats every 15s. Closes on terminal status.

Full path: `/api/v1/jobs/{id}/stream`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — text/event-stream

Body (`text/event-stream`):

| Field | Type | Required | Description |
|---|---|---|---|
| `event` | `string` | no |  |
| `data` | `string` | yes |  |
| `id` | `string` | no |  |
| `retry` | `integer` | no |  |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — No job matches the given id

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/jobs/{id}/stream
```

### `GET /jobs/{id}/results`

**Get signed download URL for results**

Returns a signed GCS URL for the OME-Zarr root metadata (`zarr.json`) plus the `resultBasePath` of the zarr store. Useful for clients that already have GCS credentials. SDK clients should generally prefer `GET /jobs/{id}/results/files/{path}` (API-key authenticated) for walking the tree.

Full path: `/api/v1/jobs/{id}/results`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — Signed URL

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `resultUrl` | `string` | yes |  |
| `resultBasePath` | `string` | no |  |
| `expiresAt` | `string <date-time>` | yes |  |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — No job matches the given id

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `409` — Job has not completed

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/jobs/{id}/results
```

### `GET /jobs/{id}/exports`

**Get format-driven result export status**

Poll an asynchronous export and refresh expiring signed URLs. OAuth requests conditionally require samples:read when segmentation artifacts are included; segmentation jobs always include their own artifacts.

Full path: `/api/v1/jobs/{id}/exports`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |
| `format` | `query` | yes | `enum ("ome-zarr", "ome-zarr-zip", "ome-tiff")` |  |
| `includeHe` | `query` | no | `boolean` | Include processed/registered H&E. |
| `includeSegmentation` | `query` | no | `boolean` | Include segmentation artifacts. Segmentation jobs always enable this selection. OAuth requests conditionally require samples:read when segmentation artifacts are included. |
| `includeOriginalUpload` | `query` | no | `boolean` | Request the separate multi-GB source file stored by Strand, not processed/registered H&E. If ingest de-identification ran, this is the stored de-identified copy. OAuth requests conditionally require samples:read when this option is true. |

#### Responses

#### `200` — Result ready

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `schemaVersion` | `enum ("1.0")` | yes |  |
| `status` | `enum ("pending", "running", "ready", "failed")` | yes |  |
| `format` | `enum ("ome-zarr", "ome-zarr-zip", "ome-tiff")` | yes |  |
| `selection` | `object` | yes |  |
| `expiresAt` | `string <date-time> | null` | yes |  |
| `retryable` | `boolean` | yes |  |
| `error` | `string | null` | yes |  |
| `artifacts` | `object` | yes |  |

#### `202` — Export pending or running

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `schemaVersion` | `enum ("1.0")` | yes |  |
| `status` | `enum ("pending", "running", "ready", "failed")` | yes |  |
| `format` | `enum ("ome-zarr", "ome-zarr-zip", "ome-tiff")` | yes |  |
| `selection` | `object` | yes |  |
| `expiresAt` | `string <date-time> | null` | yes |  |
| `retryable` | `boolean` | yes |  |
| `error` | `string | null` | yes |  |
| `artifacts` | `object` | yes |  |

#### `400` — Invalid selection value

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `401` — Authentication required

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — OAuth token lacks a required scope

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Job or export not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `409` — Job has no completed results

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `429` — Original-upload link issuance rate limit exceeded

Headers:
- `Retry-After`: `integer` — Seconds until another issuance token is available

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/jobs/{id}/exports
```

### `POST /jobs/{id}/exports`

**Request a format-driven result export**

One operation selects native OME-Zarr (no conversion), cached OME-Zarr ZIP, or cached OME-TIFF. Every format defaults to predicted mIF only; includeHe=true explicitly adds processed/registered H&E, with separately cached OME-TIFF variants. includeOriginalUpload=true separately requests the multi-GB source file stored by Strand (the stored de-identified copy when ingest de-identification ran), never the processed/registered H&E pyramid. Original retrieval is fail-closed when the runtime original-retrieval flag is disabled or the permanent source is unavailable. includeSegmentation attaches the latest uint32 mask and true per-cell geometry, morphology, and marker-expression manifest; segmentation jobs always include their own artifacts. OAuth requests conditionally require samples:read when segmentation artifacts are included. Predicted marker arrays remain pixel grids, not cell expressions. Requests are idempotent, credit-free, and failed asynchronous exports are retryable by repeating POST.

Full path: `/api/v1/jobs/{id}/exports`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Request body

Content-Type: `application/json` (**required**)

| Field | Type | Required | Description |
|---|---|---|---|
| `format` | `enum ("ome-zarr", "ome-zarr-zip", "ome-tiff")` | yes |  |
| `includeHe` | `boolean` | no | Include processed/registered H&E. |
| `includeSegmentation` | `boolean` | no | Include segmentation artifacts. Segmentation jobs always enable this selection. OAuth requests conditionally require samples:read when segmentation artifacts are included. |
| `includeOriginalUpload` | `boolean` | no | Request the separate multi-GB source file stored by Strand, not processed/registered H&E. If ingest de-identification ran, this is the stored de-identified copy. OAuth requests conditionally require samples:read when this option is true. |

#### Responses

#### `200` — Native or cached result ready

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `schemaVersion` | `enum ("1.0")` | yes |  |
| `status` | `enum ("pending", "running", "ready", "failed")` | yes |  |
| `format` | `enum ("ome-zarr", "ome-zarr-zip", "ome-tiff")` | yes |  |
| `selection` | `object` | yes |  |
| `expiresAt` | `string <date-time> | null` | yes |  |
| `retryable` | `boolean` | yes |  |
| `error` | `string | null` | yes |  |
| `artifacts` | `object` | yes |  |

#### `202` — Export pending or running

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `schemaVersion` | `enum ("1.0")` | yes |  |
| `status` | `enum ("pending", "running", "ready", "failed")` | yes |  |
| `format` | `enum ("ome-zarr", "ome-zarr-zip", "ome-tiff")` | yes |  |
| `selection` | `object` | yes |  |
| `expiresAt` | `string <date-time> | null` | yes |  |
| `retryable` | `boolean` | yes |  |
| `error` | `string | null` | yes |  |
| `artifacts` | `object` | yes |  |

#### `400` — Malformed request body or invalid selection value

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `401` — Authentication required

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — OAuth token lacks a required scope

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Job not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `409` — Job has no completed results

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `429` — Original-upload link issuance rate limit exceeded

Headers:
- `Retry-After`: `integer` — Seconds until another issuance token is available

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl -X POST \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  -H "Content-Type: application/json" \
  https://app.strandai.com/api/v1/jobs/{id}/exports \
  -d '{ ... }'
```

### `GET /samples/{id}`

**Get an owned or public sample**

Resolve an org-owned sample id first, returning its curated detail and inference-job history. Otherwise resolve the same path value as a currently-public share UUID and return only public-safe detail.

Full path: `/api/v1/samples/{id}`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — Owned or public sample detail

Body (`application/json`):

Type: `any`

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Sample not found in this org

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/samples/{id}
```

### `PATCH /samples/{id}`

**Update a sample**

Atomically update any combination of an owned sample's display name, unified field values, and isotropic microns-per-pixel value. Public share ids are not patchable. OAuth callers need both `samples:read` and `samples:write` because the response includes full owned detail and job history. When countable `mpp` is supplied, an idempotent recount is enqueued even if the stored value is unchanged; after an enqueue-failure 500, retry the identical patch to heal the recount.

Full path: `/api/v1/samples/{id}`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Request body

Content-Type: `application/json` (**required**)

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | `string | null` | no | Trimmed display name. Null or an empty string reverts to filename. NUL characters are rejected. |
| `fields` | `any` | no | Complete values for the named fields, not deltas. Null clears a field. `fields.tags` is the complete desired tag set; `fields.public` is read-only and returns 403. |
| `mpp` | `any` | no | User-reported microns per pixel. Object axes must be equal; anisotropic input is rejected. |

#### Responses

#### `200` — Updated owned sample detail

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `ownership` | `enum ("mine")` | yes |  |
| `id` | `string <uuid>` | yes |  |
| `appUrl` | `string <uri>` | yes | Canonical browser URL for this owned sample. |
| `name` | `string | null` | yes | Human-friendly display name, or null if unset (falls back to filename). |
| `filename` | `string` | yes | Original uploaded filename. |
| `status` | `string` | yes | Lifecycle status: `uploading`, `upload_failed`, `deid_running`, `deid_failed`, `preprocessing`, `ready`, or `preprocess_failed`. |
| `fileSize` | `string` | yes | Uploaded file size in bytes (string-encoded 64-bit integer). |
| `widthPx` | `integer | null` | yes | Level-0 width in pixels, or null before the dimensions probe completes. |
| `heightPx` | `integer | null` | yes | Level-0 height in pixels, or null before the dimensions probe completes. |
| `mpp` | `number | null` | yes | Effective isotropic microns per pixel (user override → calibrated slide value), or null when the sample has no usable scale yet. |
| `fields` | `SampleFields` | yes |  |
| `createdAt` | `string <date-time> | null` | yes |  |
| `expiresAt` | `string <date-time> | null` | yes | When the sample moves to Trash. Null when it never expires. |
| `expiresAtSource` | `enum ("org_default", "custom", null)` | yes | `org_default` = derived from the org policy at upload time. `custom` = set by an explicit expiration call (a date or a never-expire pin). Null when the sample has no expiration and no org policy applies. |
| `expiresInDays` | `integer | null` | yes | Whole days until expiry, clamped at 0 for a sample at/past its date but not yet swept. Null when the sample never expires. |
| `willExpire` | `boolean` | yes | True when the sample has an expiration date set. |
| `trashedAt` | `string <date-time> | null` | yes | When the sample entered Trash, or null if it is still active. Trashed samples are permanently deleted 7 days after this time. |
| `jobs` | `SampleJob[]` | yes |  |
| `jobCount` | `integer` | yes | Total inference-job count for the sample before the 50-job cap. |
| `segmentation` | `SegmentationState` | yes |  |

#### `400` — Body is not JSON, empty, or contains unknown fields

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `401` — Missing or invalid credential

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — OAuth token lacks samples:read or samples:write, or the patch attempts to write a read-only field such as Public

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Owned sample not found in this org

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `422` — A provided field is invalid

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `500` — The sample update committed but its MPP tissue recount could not be enqueued. Retry the identical patch to re-attempt the idempotent recount.

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl -X PATCH \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  -H "Content-Type: application/json" \
  https://app.strandai.com/api/v1/samples/{id} \
  -d '{ ... }'
```

### `GET /samples/{id}/segmentation`

**Get cell-segmentation lifecycle**

Credit-free owned-sample state. Public sample handles are read-only and resolve as not found on this mutation-capable surface.

Full path: `/api/v1/samples/{id}/segmentation`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — Lifecycle state

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | `enum ("not_started", "queued", "running", "postprocessing", "completed", "failed", "cancelled")` | yes |  |
| `retryable` | `boolean` | yes |  |
| `creditCost` | `enum (0)` | yes | Segmentation is credit-free. |
| `job` | `object | null` | yes |  |
| `layer` | `object | null` | yes |  |

#### `401` — Authentication required

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — OAuth token lacks a required scope

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Owned sample not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/samples/{id}/segmentation
```

### `POST /samples/{id}/segmentation`

**Start or retry cell segmentation**

State-idempotent and credit-free. Commits a durable queued job intent before best-effort task delivery and returns its job ID. Reuses completed/active work, retries the exact latest failed attempt after a 60-second cooldown, and rejects non-ready, archived, cross-org, and public samples.

Full path: `/api/v1/samples/{id}/segmentation`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — Existing completed or active work

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | `enum ("not_started", "queued", "running", "postprocessing", "completed", "failed", "cancelled")` | yes |  |
| `retryable` | `boolean` | yes |  |
| `creditCost` | `enum (0)` | yes | Segmentation is credit-free. |
| `job` | `object | null` | yes |  |
| `layer` | `object | null` | yes |  |

#### `202` — Start/retry queued

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | `enum ("not_started", "queued", "running", "postprocessing", "completed", "failed", "cancelled")` | yes |  |
| `retryable` | `boolean` | yes |  |
| `creditCost` | `enum (0)` | yes | Segmentation is credit-free. |
| `job` | `object | null` | yes |  |
| `layer` | `object | null` | yes |  |

#### `401` — Authentication required

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — OAuth token lacks a required scope

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Owned sample not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `409` — Sample not ready or archived

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `429` — Retry cooldown

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `503` — Segmentation is not configured in this environment

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl -X POST \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/samples/{id}/segmentation
```

### `GET /samples/{id}/segmentation/{layerId}/manifest`

**Get segmentation artifact manifest**

Returns mask OME-Zarr and true per-cell Parquet contracts. Marker-expression tables join morphology by instance_id; they are not pixel-grid AnnData.

Full path: `/api/v1/samples/{id}/segmentation/{layerId}/manifest`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |
| `layerId` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — Artifact manifest

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `schemaVersion` | `enum ("1.0")` | yes |  |
| `layerId` | `string <uuid>` | yes |  |
| `entityType` | `string` | yes |  |
| `modelVersion` | `string` | yes |  |
| `coordinateFrame` | `string` | yes |  |
| `instanceCount` | `integer | null` | yes |  |
| `createdAt` | `string <date-time>` | yes |  |
| `artifacts` | `object` | yes |  |
| `downloads` | `object | null` | no | Short-lived, generation-pinned bearer links for the exact provenance and cell Parquet objects. Present only in result-export responses after organization authorization; absent from the standalone segmentation manifest. |

#### `401` — Authentication required

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — OAuth token lacks a required scope

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Layer not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `409` — Layer not complete

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/samples/{id}/segmentation/{layerId}/manifest
```

### `GET /samples/{id}/segmentation/{layerId}/files/{path}`

**Read one segmentation artifact file**

Bearer-authenticated, org-scoped byte proxy for the paths declared by the segmentation manifest.

Full path: `/api/v1/samples/{id}/segmentation/{layerId}/files/{path}`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |
| `layerId` | `path` | yes | `string <uuid>` |  |
| `path` | `path` | yes | `string` |  |

#### Responses

#### `200` — Artifact bytes

Body (`application/octet-stream`):

Type: `string <binary>`

#### `400` — Invalid artifact path

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `401` — Authentication required

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — OAuth token lacks a required scope

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Artifact not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `500` — Storage proxy failure

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/samples/{id}/segmentation/{layerId}/files/{path}
```

### `PATCH /samples/{id}/expiration`

**Set sample expiration**

Set a sample to expire on a specific date, set it to never expire, or clear any custom expiration and follow the org default. Caller must be the sample's creator, an org owner/admin, or a Strand AI admin.

Full path: `/api/v1/samples/{id}/expiration`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Request body

Content-Type: `application/json` (**required**)

| Field | Type | Required | Description |
|---|---|---|---|
| `expiresAt` | `string <date-time> | null` | no |  |
| `neverExpire` | `boolean` | no |  |
| `useOrgDefault` | `boolean` | no |  |
| `reason` | `string` | no |  |

#### Responses

#### `200` — Updated sample

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string <uuid>` | yes |  |
| `expiresAt` | `string <date-time> | null` | no |  |
| `expiresAtSource` | `enum ("org_default", "custom", null)` | no |  |
| `expirationChangedAt` | `string <date-time> | null` | no |  |
| `expirationChangedBy` | `string | null` | no |  |
| `batchId` | `string <uuid>` | no |  |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — Not allowed for this sample

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Sample not found in this org

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `422` — Invalid input

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl -X PATCH \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  -H "Content-Type: application/json" \
  https://app.strandai.com/api/v1/samples/{id}/expiration \
  -d '{ ... }'
```

### `PATCH /samples/expiration`

**Set sample expiration (bulk)**

All-or-nothing: if any sample fails the permission gate the whole call is rejected before any writes. Max 500 IDs per call.

Full path: `/api/v1/samples/expiration`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Request body

Content-Type: `application/json` (**required**)

Type: `any`

#### Responses

#### `200` — Bulk update result

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `updated` | `integer` | yes |  |
| `batchId` | `string <uuid>` | yes |  |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — One or more samples are not in the API key's org

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `422` — Invalid input

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl -X PATCH \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  -H "Content-Type: application/json" \
  https://app.strandai.com/api/v1/samples/expiration \
  -d '{ ... }'
```

### `POST /samples/{id}/restore`

**Restore a sample from Trash**

Available within the 7-day Trash window until permanent deletion starts. Brings the sample back to the active list and extends its expiration so it isn't immediately re-trashed. A deletion already in progress is irreversible and returns 409. Same permission gate as the expiration endpoints.

Full path: `/api/v1/samples/{id}/restore`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — Restored sample

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string <uuid>` | yes |  |
| `trashedAt` | `null` | no |  |
| `expiresAt` | `string <date-time> | null` | no |  |

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `403` — Not allowed for this sample

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Sample not found in this org

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `409` — Permanent deletion is already in progress

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `412` — Sample is not archived

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl -X POST \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/samples/{id}/restore
```

### `GET /jobs/{id}/results/files/{path}`

**Stream a single result file (API-key authenticated)**

Proxies a single file from the OME-Zarr result store. `path` is the path within the zarr store (e.g. `zarr.json`, `CD3/zarr.json`, `CD3/c/0/0/0`). Authenticated via the same bearer API key as the rest of `/api/v1/`; org-scoped to the job. Use this when walking the zarr tree from a client that does not have direct GCS credentials.

Full path: `/api/v1/jobs/{id}/results/files/{path}`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | `path` | yes | `string <uuid>` |  |
| `path` | `path` | yes | `string` | Path within the zarr store; slashes are not URL-encoded. |

#### Responses

#### `200` — File bytes (binary for chunks, JSON for metadata).

Body (`application/octet-stream`):

Type: `string <binary>`

Body (`application/json`):

Type: `object`

#### `401` — Missing or invalid API key

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Job or file not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `409` — Job has not completed

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/jobs/{id}/results/files/{path}
```

### `GET /samples`

**List samples**

List the calling organization's samples, the public cohort, or both. Cursor-paginated newest-first; `all` always emits mine first and de-duplicates owned public samples in favor of the mine representation. Credit-free.

The facet parameters are byte-identical to the ones the Strand web app puts in a filtered `/samples` URL, so a browser query string is a valid API query string and vice versa. Facets compose with AND; `status` and `workflow` values compose with OR; repeated `marker` and `field` values compose with AND. Tags is addressed through the predefined Tags field definition. The retired `tag` parameter is rejected with 400 rather than ignored. Unknown values, malformed clauses and over-cap arrays are also rejected.

A public release supports title search (`q`) only. Under `scope=all` any other facet suppresses the public block; under `scope=public` it is a 400.

Full path: `/api/v1/samples`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `scope` | `query` | no | `enum ("mine", "public", "all")` |  |
| `limit` | `query` | no | `integer` |  |
| `cursor` | `query` | no | `string` | Opaque cursor returned as `nextCursor`; not bound to page size. |
| `q` | `query` | no | `string` | Case-insensitive substring match against the sample's display name or filename. The only facet a public release can answer. |
| `status` | `query` | no | `enum ("ready", "preprocessing", "uploading", "upload_failed", "preprocess_failed")[]` | Repeatable lifecycle-status filter. Values compose with OR. |
| `uploaded_from` | `query` | no | `string <date>` | Inclusive lower bound on upload date in years 0001-9999, as a UTC calendar day. Must not be later than `uploaded_to`. |
| `uploaded_to` | `query` | no | `string <date>` | Inclusive upper bound on upload date in years 0001-9999, as a UTC calendar day: the whole day is matched. |
| `workflow` | `query` | no | `enum ("ready_no_job", "in_flight", "has_results")[]` | Repeatable run-state filter: `ready_no_job` (preprocessed, never submitted), `in_flight` (a non-terminal job exists), `has_results` (a completed marker prediction exists). Values compose with OR. |
| `marker` | `query` | no | `string[]` | Repeatable per-marker state filter of the form `NAME:state`, where state is `requested` or `completed`. Marker names cannot contain `:` or `,`. Values compose with AND. |
| `field` | `query` | no | `string[]` | Repeatable custom-field filter of the form `DEFINITION_UUID:op[:VALUE]`. `op` is one of `eq`, `contains`, `gte`, `lte`, `is_empty`, `is_not_empty`; the first four carry a value that is JSON-encoded and then URL-encoded (so `"high"`, `3`, `true`). Operators are checked against the definition's type. Org-defined fields have no meaning on the public cohort. Under `scope=all` a field facet returns matching organization-owned samples and suppresses public rows; under `scope=public` it is rejected. Values compose with AND. |

#### Responses

#### `200` — A scoped sample page

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `items` | `any[]` | yes |  |
| `nextCursor` | `string | null` | yes | Opaque cursor for the next item, or null when exhausted. |
| `appUrl` | `string <uri>` | yes | Canonical browser URL for this exact scope and filter; pagination omitted. |

#### `400` — Invalid query or cursor

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `401` — Missing or invalid credential

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/samples
```

### `GET /public/samples/{publicId}/zarr/{path}`

**Stream a public sample's OME-Zarr pyramid**

Authenticated byte proxy for a public sample's OME-Zarr store — the H&E image (`he/...`) and every pinned marker channel (`markers/{marker}/...`). The root manifest is `zarr.json`. This is the way to read the actual marker pixel data. Only the `he/` and `markers/` namespaces are served; everything else is denied. Never reserves or spends credits.

Full path: `/api/v1/public/samples/{publicId}/zarr/{path}`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `publicId` | `path` | yes | `string <uuid>` |  |
| `path` | `path` | yes | `string` | Path within the zarr store; slashes are not URL-encoded (e.g. `zarr.json`, `he/0/zarr.json`, `markers/CD3/c/0/0/0`). |

#### Responses

#### `200` — File bytes (binary for chunks, JSON for metadata).

Body (`application/octet-stream`):

Type: `string <binary>`

Body (`application/json`):

Type: `object`

#### `401` — Missing or invalid credential

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — Public sample or file not found

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/public/samples/{publicId}/zarr/{path}
```

### `GET /public/samples/{publicId}/thumbnail`

**Get a public sample's thumbnail**

Authenticated JPEG thumbnail for a public sample. Streamed through this release-keyed endpoint (never a signed GCS URL). Never reserves or spends credits.

Full path: `/api/v1/public/samples/{publicId}/thumbnail`

Auth: `Authorization: Bearer <API key or OAuth access token>`

#### Parameters

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `publicId` | `path` | yes | `string <uuid>` |  |

#### Responses

#### `200` — JPEG thumbnail bytes.

Body (`image/jpeg`):

Type: `string <binary>`

#### `401` — Missing or invalid credential

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

#### `404` — No currently-public sample matches the given publicId

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |

#### Example

```bash
curl \
  -H "Authorization: Bearer $STRAND_API_KEY" \
  https://app.strandai.com/api/v1/public/samples/{publicId}/thumbnail
```

## Schemas

### `Error`

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `integer | null` | no |  |
| `retryAfterSeconds` | `integer | null` | no |  |

### `UploadCreated`

| Field | Type | Required | Description |
|---|---|---|---|
| `uploadId` | `string <uuid>` | yes |  |
| `uploadUrl` | `string` | no | Resumable upload URL. Present for a new upload; omitted when `existing` is true. |
| `gcsPath` | `string` | yes |  |
| `existing` | `boolean` | yes | True when `contentSha256` matched an existing active upload in the same organization. |

### `Upload`

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string <uuid>` | yes |  |
| `filename` | `string` | yes |  |
| `fileSize` | `string` | yes | bytes, stringified bigint |
| `status` | `string` | yes | uploading \| upload_failed \| deid_running \| deid_failed \| preprocessing \| ready \| preprocess_failed |
| `gcsPath` | `string` | yes |  |
| `createdAt` | `string <date-time> | null` | no |  |
| `widthPx` | `integer | null` | no |  |
| `heightPx` | `integer | null` | no |  |
| `autoSegment` | `boolean` | no | Resolved auto-segmentation decision for this upload (per-upload override, else org default, else true). |

### `UploadList`

| Field | Type | Required | Description |
|---|---|---|---|
| `uploads` | `Upload[]` | yes |  |
| `nextCursor` | `string | null` | no | Opaque cursor for the next page, or null on last page |

### `Estimate`

| Field | Type | Required | Description |
|---|---|---|---|
| `dryRun` | `enum (true)` | yes |  |
| `patchCount` | `integer` | yes |  |
| `markerCount` | `integer` | yes |  |
| `estimatedCredits` | `integer` | yes |  |
| `orgBalance` | `integer` | yes |  |
| `orgPending` | `integer` | yes |  |

### `Submission`

| Field | Type | Required | Description |
|---|---|---|---|
| `jobId` | `string <uuid>` | yes |  |
| `reservedCredits` | `integer` | yes |  |
| `status` | `enum ("queued")` | yes |  |

### `SampleFieldList`

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | `integer` | yes | Number of definitions in `fields`. |
| `fields` | `object[]` | yes |  |

### `MarkerList`

| Field | Type | Required | Description |
|---|---|---|---|
| `fullPanel` | `boolean` | yes | Whether this account is entitled to the full marker panel. `false` = the public panel only. |
| `count` | `integer` | yes | Number of markers in `markers`. |
| `markers` | `object[]` | yes |  |

### `SampleJob`

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string <uuid>` | yes |  |
| `status` | `string` | yes | Lifecycle: `queued` → `dispatching` → `preprocessing` → `running` → `postprocessing` → terminal. Terminal values: `completed`, `partial_failed`, `failed`, and `cancelled`. |
| `progress` | `number | null` | yes |  |
| `reservedCredits` | `integer | null` | yes |  |
| `markers` | `string[]` | yes |  |
| `createdAt` | `string <date-time> | null` | yes |  |
| `startedAt` | `string <date-time> | null` | yes |  |
| `completedAt` | `string <date-time> | null` | yes |  |
| `errorMessage` | `string | null` | yes |  |
| `resultsAvailable` | `boolean` | yes |  |

### `Results`

| Field | Type | Required | Description |
|---|---|---|---|
| `resultUrl` | `string` | yes |  |
| `resultBasePath` | `string` | no |  |
| `expiresAt` | `string <date-time>` | yes |  |

### `OmeTiffExport`

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | `enum ("pending", "running", "ready", "failed")` | yes |  |
| `format` | `enum ("ome-tiff")` | yes |  |
| `sizeBytes` | `integer | null` | yes |  |
| `downloadUrl` | `string <uri>` | no | One-hour signed download URL. Present only when status is `ready`. |
| `downloadUrlExpiresAt` | `string <date-time>` | no | Signed URL expiry. Present only when status is `ready`. |
| `error` | `string` | no | Export failure message. Present only when status is `failed`. |
| `updatedAt` | `string <date-time> | null` | yes |  |

### `ResultArchiveExport`

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | `enum ("pending", "running", "ready", "failed")` | yes |  |
| `format` | `enum ("ome-zarr-zip")` | yes |  |
| `sizeBytes` | `integer | null` | yes |  |
| `downloadUrl` | `string <uri>` | no | One-hour signed download URL. Present only when status is `ready`. |
| `downloadUrlExpiresAt` | `string <date-time>` | no | Signed URL expiry. Present only when status is `ready`. |
| `error` | `string` | no | Present only when status is `failed`. |
| `updatedAt` | `string <date-time> | null` | yes |  |

### `SegmentationState`

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | `enum ("not_started", "queued", "running", "postprocessing", "completed", "failed", "cancelled")` | yes |  |
| `retryable` | `boolean` | yes |  |
| `creditCost` | `enum (0)` | yes | Segmentation is credit-free. |
| `job` | `object | null` | yes |  |
| `layer` | `object | null` | yes |  |

### `SegmentationManifest`

| Field | Type | Required | Description |
|---|---|---|---|
| `schemaVersion` | `enum ("1.0")` | yes |  |
| `layerId` | `string <uuid>` | yes |  |
| `entityType` | `string` | yes |  |
| `modelVersion` | `string` | yes |  |
| `coordinateFrame` | `string` | yes |  |
| `instanceCount` | `integer | null` | yes |  |
| `createdAt` | `string <date-time>` | yes |  |
| `artifacts` | `object` | yes |  |
| `downloads` | `object | null` | no | Short-lived, generation-pinned bearer links for the exact provenance and cell Parquet objects. Present only in result-export responses after organization authorization; absent from the standalone segmentation manifest. |

### `ResultExport`

| Field | Type | Required | Description |
|---|---|---|---|
| `schemaVersion` | `enum ("1.0")` | yes |  |
| `status` | `enum ("pending", "running", "ready", "failed")` | yes |  |
| `format` | `enum ("ome-zarr", "ome-zarr-zip", "ome-tiff")` | yes |  |
| `selection` | `object` | yes |  |
| `expiresAt` | `string <date-time> | null` | yes |  |
| `retryable` | `boolean` | yes |  |
| `error` | `string | null` | yes |  |
| `artifacts` | `object` | yes |  |

### `ExpirationUpdate`

| Field | Type | Required | Description |
|---|---|---|---|
| `expiresAt` | `string <date-time> | null` | no |  |
| `neverExpire` | `boolean` | no |  |
| `useOrgDefault` | `boolean` | no |  |
| `reason` | `string` | no |  |

### `SampleFields`

Type: `object` — Unified field values keyed by stable definition key. Tags is a string array at `tags`; Public is a boolean at `public`. Values are validated against the organization's definitions.

### `SamplePatch`

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | `string | null` | no | Trimmed display name. Null or an empty string reverts to filename. NUL characters are rejected. |
| `fields` | `any` | no | Complete values for the named fields, not deltas. Null clears a field. `fields.tags` is the complete desired tag set; `fields.public` is read-only and returns 403. |
| `mpp` | `any` | no | User-reported microns per pixel. Object axes must be equal; anisotropic input is rejected. |

### `Sample`

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string <uuid>` | yes |  |
| `expiresAt` | `string <date-time> | null` | no |  |
| `expiresAtSource` | `enum ("org_default", "custom", null)` | no |  |
| `expirationChangedAt` | `string <date-time> | null` | no |  |
| `expirationChangedBy` | `string | null` | no |  |
| `batchId` | `string <uuid>` | no |  |

### `OwnedSampleDetail`

| Field | Type | Required | Description |
|---|---|---|---|
| `ownership` | `enum ("mine")` | yes |  |
| `id` | `string <uuid>` | yes |  |
| `appUrl` | `string <uri>` | yes | Canonical browser URL for this owned sample. |
| `name` | `string | null` | yes | Human-friendly display name, or null if unset (falls back to filename). |
| `filename` | `string` | yes | Original uploaded filename. |
| `status` | `string` | yes | Lifecycle status: `uploading`, `upload_failed`, `deid_running`, `deid_failed`, `preprocessing`, `ready`, or `preprocess_failed`. |
| `fileSize` | `string` | yes | Uploaded file size in bytes (string-encoded 64-bit integer). |
| `widthPx` | `integer | null` | yes | Level-0 width in pixels, or null before the dimensions probe completes. |
| `heightPx` | `integer | null` | yes | Level-0 height in pixels, or null before the dimensions probe completes. |
| `mpp` | `number | null` | yes | Effective isotropic microns per pixel (user override → calibrated slide value), or null when the sample has no usable scale yet. |
| `fields` | `SampleFields` | yes |  |
| `createdAt` | `string <date-time> | null` | yes |  |
| `expiresAt` | `string <date-time> | null` | yes | When the sample moves to Trash. Null when it never expires. |
| `expiresAtSource` | `enum ("org_default", "custom", null)` | yes | `org_default` = derived from the org policy at upload time. `custom` = set by an explicit expiration call (a date or a never-expire pin). Null when the sample has no expiration and no org policy applies. |
| `expiresInDays` | `integer | null` | yes | Whole days until expiry, clamped at 0 for a sample at/past its date but not yet swept. Null when the sample never expires. |
| `willExpire` | `boolean` | yes | True when the sample has an expiration date set. |
| `trashedAt` | `string <date-time> | null` | yes | When the sample entered Trash, or null if it is still active. Trashed samples are permanently deleted 7 days after this time. |
| `jobs` | `SampleJob[]` | yes |  |
| `jobCount` | `integer` | yes | Total inference-job count for the sample before the 50-job cap. |
| `segmentation` | `SegmentationState` | yes |  |

### `MineSampleListItem`

| Field | Type | Required | Description |
|---|---|---|---|
| `ownership` | `enum ("mine")` | yes |  |
| `id` | `string <uuid>` | yes | Organization-authorized sample id. |
| `name` | `string | null` | yes |  |
| `filename` | `string` | yes |  |
| `status` | `string` | yes |  |
| `fileSize` | `string` | yes | Decimal string for a 64-bit byte count. |
| `fields` | `SampleFields` | yes |  |
| `createdAt` | `string <date-time> | null` | yes |  |
| `appUrl` | `string <uri>` | yes | Canonical browser URL. |

### `PublicSampleListItem`

| Field | Type | Required | Description |
|---|---|---|---|
| `ownership` | `enum ("public")` | yes |  |
| `id` | `string <uuid>` | yes | Safe public share UUID. Never an internal sample id. |
| `title` | `string` | yes |  |
| `thumbnailUrl` | `string` | yes | Authenticated v1 public thumbnail route. |
| `appUrl` | `string <uri>` | yes | Canonical browser URL. |

### `SampleList`

| Field | Type | Required | Description |
|---|---|---|---|
| `items` | `any[]` | yes |  |
| `nextCursor` | `string | null` | yes | Opaque cursor for the next item, or null when exhausted. |
| `appUrl` | `string <uri>` | yes | Canonical browser URL for this exact scope and filter; pagination omitted. |

### `PublicSampleDetail`

| Field | Type | Required | Description |
|---|---|---|---|
| `ownership` | `enum ("public")` | yes |  |
| `id` | `string <uuid>` | yes |  |
| `appUrl` | `string <uri>` | yes | Canonical browser URL for this public sample. |
| `title` | `string` | yes |  |
| `thumbnailUrl` | `string` | yes | v1-relative path to the sample's JPEG thumbnail. |
| `geometry` | `object` | yes | Level-0 pixel dimensions and microns-per-pixel of the H&E image. |
| `viewer` | `object` | yes | How to read the pixel data: the OME-Zarr pyramid base path and the marker channels it carries. |
