# Strand AI Platform API (v1)

REST surface for uploading WSIs, submitting POSTMAN inference jobs, and streaming results. All endpoints require an `Authorization: Bearer sk-strand-...` header. Generate keys at `/settings/api-keys`.

_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 `Authorization: Bearer sk-strand-...` header. Keys are created at `/settings/api-keys` and are scoped to one organization.

## Endpoints

### `POST /uploads`

**Initiate a resumable upload**

Full path: `/api/v1/uploads`

Auth: `Authorization: Bearer sk-strand-...`

#### Request body

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

| Field | Type | Required | Description |
|---|---|---|---|
| `filename` | `string` | yes |  |
| `fileSize` | `integer` | yes |  |
| `contentType` | `string` | yes |  |

#### Responses

#### `200` — Resumable upload URL

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `uploadId` | `string <uuid>` | yes |  |
| `uploadUrl` | `string` | yes | Resumable upload URL — PUT slide bytes here |
| `gcsPath` | `string` | yes |  |

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

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `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 '{ ... }'
```

### `POST /uploads/{id}/complete`

**Finalize a resumable upload**

Marks the upload `ready` and writes width/height into metadata (used by credit estimates).

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

Auth: `Authorization: Bearer sk-strand-...`

#### Parameters

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

#### Responses

#### `200` — Upload finalized

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `uploadId` | `string <uuid>` | yes |  |
| `status` | `enum ("ready")` | yes |  |
| `widthPx` | `integer` | yes |  |
| `heightPx` | `integer` | yes |  |
| `dimensionsSource` | `enum ("sharp", "stub")` | no |  |

#### `404` — Upload not found

Body (`application/json`):

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

#### Example

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

### `POST /predict/estimate`

**Estimate credit cost for a prediction**

Full path: `/api/v1/predict/estimate`

Auth: `Authorization: Bearer sk-strand-...`

#### Request body

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

| Field | Type | Required | Description |
|---|---|---|---|
| `uploadId` | `string <uuid>` | yes |  |
| `markers` | `string[]` | yes |  |

#### Responses

#### `200` — Estimate

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `patchCount` | `integer` | yes |  |
| `markerCount` | `integer` | yes |  |
| `estimatedCredits` | `integer` | yes |  |
| `orgBalance` | `integer` | yes |  |
| `orgPending` | `integer` | no |  |

#### `409` — Upload missing dimensions

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `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/estimate \
  -d '{ ... }'
```

### `POST /predict`

**Submit a prediction**

Reserves credits atomically and enqueues preprocessing. Returns 202 + `jobId`. On insufficient balance returns 402 with rollback. On per-org concurrency cap returns 429 with `Retry-After`.

Full path: `/api/v1/predict`

Auth: `Authorization: Bearer sk-strand-...`

#### Request body

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

| Field | Type | Required | Description |
|---|---|---|---|
| `uploadId` | `string <uuid>` | yes |  |
| `markers` | `string[]` | yes |  |

#### Responses

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

Body (`application/json`):

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

#### `402` — Insufficient credits

Body (`application/json`):

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

#### `409` — Upload not ready

Body (`application/json`):

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

#### `429` — Per-org concurrent job cap exceeded

Headers:
- `Retry-After`: `integer` — Seconds to wait before retrying

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `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 sk-strand-...`

#### 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 |  |
| `progress` | `number | null` | no |  |
| `reservedCredits` | `integer | null` | no |  |
| `markers` | `string[]` | yes |  |
| `createdAt` | `string <date-time> | null` | no |  |
| `startedAt` | `string <date-time> | null` | no |  |
| `completedAt` | `string <date-time> | null` | no |  |
| `errorMessage` | `string | null` | no |  |
| `resultsAvailable` | `boolean` | no |  |

#### `404` — Not found

Body (`application/json`):

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

#### Example

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

### `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 sk-strand-...`

#### Parameters

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

#### Responses

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

Body (`text/event-stream`):

Type: `string`

#### 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 sk-strand-...`

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

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

Body (`application/json`):

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

#### Example

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

### `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 sk-strand-...`

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

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

Body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | `string` | yes |  |
| `message` | `string` | yes |  |
| `required` | `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 |  |

#### Example

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

## Schemas

### `Error`

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

### `UploadCreated`

| Field | Type | Required | Description |
|---|---|---|---|
| `uploadId` | `string <uuid>` | yes |  |
| `uploadUrl` | `string` | yes | Resumable upload URL — PUT slide bytes here |
| `gcsPath` | `string` | yes |  |

### `UploadComplete`

| Field | Type | Required | Description |
|---|---|---|---|
| `uploadId` | `string <uuid>` | yes |  |
| `status` | `enum ("ready")` | yes |  |
| `widthPx` | `integer` | yes |  |
| `heightPx` | `integer` | yes |  |
| `dimensionsSource` | `enum ("sharp", "stub")` | no |  |

### `Estimate`

| Field | Type | Required | Description |
|---|---|---|---|
| `patchCount` | `integer` | yes |  |
| `markerCount` | `integer` | yes |  |
| `estimatedCredits` | `integer` | yes |  |
| `orgBalance` | `integer` | yes |  |
| `orgPending` | `integer` | no |  |

### `Submission`

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

### `Job`

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string <uuid>` | yes |  |
| `status` | `string` | yes |  |
| `progress` | `number | null` | no |  |
| `reservedCredits` | `integer | null` | no |  |
| `markers` | `string[]` | yes |  |
| `createdAt` | `string <date-time> | null` | no |  |
| `startedAt` | `string <date-time> | null` | no |  |
| `completedAt` | `string <date-time> | null` | no |  |
| `errorMessage` | `string | null` | no |  |
| `resultsAvailable` | `boolean` | no |  |

### `Results`

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