> For the complete documentation index, see [llms.txt](https://docs.labatlas.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.labatlas.com/developer-tools/rest-api-reference.md).

# REST API Reference

The Lab Atlas public REST API lets external clients — scripts, integrations, internal tooling, and partner systems — interact with the same projects, studies, assays, and supporting resources that the Lab Atlas web client uses. This guide explains how to obtain credentials, authenticate requests, and call each endpoint.

{% hint style="info" %}
The Lab Atlas public API is currently available only to Enterprise customers.
{% endhint %}

{% hint style="warning" %}
The public API is currently in beta, some endpoints and their conetns may change over time.
{% endhint %}

### Quick start

```bash
# Set your tenant base URL and API key
export LA_BASE=https://your-tenant.labatlas.com
export LA_KEY=lak_xxxxxxxx.xxxxxxxxxxxxxxxxxxxx

# List the projects visible to your account
curl -s "$LA_BASE/api/v1/projects" \
     -H "Authorization: ApiKey $LA_KEY" | jq .

# Create a new project
curl -s -X POST "$LA_BASE/api/v1/projects" \
     -H "Authorization: ApiKey $LA_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "code": "MYPROJ",
       "name": "My Project",
       "description": "Created via the public API.",
       "visibility": "PRIVATE"
     }'
```

***

### Getting an API key

API keys are issued from inside the Lab Atlas web application. Each key is owned by a specific **organization user**, and every request made with that key executes under that user's permissions — there is no separate service account.

To create a key:

1. Sign in to your Lab Atlas tenant.
2. Open the user menu in the top-right corner and choose **Account settings**.
3. Select the **API keys** tab.
4. Click **Create new key**.
5. Give the key a memorable display name (for example, `lims-importer`, `nightly-backup`) and choose how long it should remain valid.
6. Copy the key value from the modal that appears. **This is the only time the full key value is shown.** Store it somewhere safe — a secret manager, your CI's environment variables, or an encrypted note.

You can also list, rotate, or revoke your existing keys from the same screen. Revoking a key takes effect immediately; any request presenting the revoked key will fail with `401 Unauthorized`.

> **Heads up:** the API-key UI is only available when your Lab Atlas tenant has the Enterprise feature flag enabled. If you do not see the **API keys** tab, contact your administrator.

A key value looks like `lak_<keyId>.<secret>`. The `lak_` prefix and `keyId` segment are stable identifiers; the segment after the dot is the secret material.

#### Per-user, per-organization

Each key is bound to exactly one `(user, organization)` pairing. If you belong to two organizations, you need a separate key for each. Switching your active organization in the web UI does not change the org a previously-issued key is scoped to.

#### Key lifecycle

| Action    | Result                                                                                                        |
| --------- | ------------------------------------------------------------------------------------------------------------- |
| Create    | Key is active and usable immediately                                                                          |
| View list | Shows key metadata only (display name, creation date, last-used time). Secret material is never re-displayed. |
| Rotate    | Revoke the old key and create a new one. There is no atomic rotation primitive.                               |
| Revoke    | Future requests with this key return `401 Unauthorized`. The key remains in the audit log.                    |
| Expire    | Keys expire automatically after their configured duration. Expired requests return `401`.                     |

***

### Authentication

Every request to `/api/v1/**` must present a valid API key. Two header formats are accepted; pick whichever fits your client best:

```http
Authorization: ApiKey lak_xxxxxxxx.xxxxxxxxxxxxxxxxxxxx
```

```http
X-API-KEY: lak_xxxxxxxx.xxxxxxxxxxxxxxxxxxxx
```

> **Do not use `Authorization: Bearer …`.** Bearer tokens are reserved for the internal `/api/i/**` API and will be rejected on the public endpoints.

Missing, expired, malformed, or revoked keys return `401 Unauthorized` with no response body. Keys that are valid but lack the permission required for the action return `403 Forbidden`.

#### Required transport

All requests must be made over HTTPS. The API enforces HSTS and will reject plain-HTTP traffic at the load balancer.

#### CORS

The public API is intended for server-to-server use. Cross-origin browser requests are not enabled by default. If you need to call the API from a browser-based application, deploy a thin server-side proxy in front of it.

***

### Permissions model

Because every key is bound to an organization user, authorization decisions follow the same rules used by the web UI:

* **Visibility** — `findAll` and `findById` endpoints only return resources the key's owning user can view. For projects this is determined by the project's visibility setting (`PUBLIC`, `PROTECTED`, `PRIVATE`) plus the user's role and team memberships.
* **Contribute** — `create`, `update`, `delete`, `status`, and `archive` endpoints require the user to be a project member (or higher). Calls that fail this check return `403 Forbidden`. Looking up a record the user cannot view returns `404 Not Found` instead — this is intentional and avoids leaking the existence of records the user has no business knowing about.
* **Admin-only operations** — Team writes and Assay-Type writes additionally require the `ORGANIZATION_ADMIN` role on the owning user. A non-admin key on these endpoints returns `403`.

If you revoke a user's admin role, every key they own immediately loses access to admin-only endpoints.

***

### Conventions

#### Base URL & versioning

| Mount           | Status                                                                                                                                                       |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/api/v1/...`   | Current. Use this.                                                                                                                                           |
| `/api/beta/...` | Deprecated. Still resolves to the same handlers, but every response carries a `Deprecation: true` header and a `Link` header naming the `/api/v1` successor. |
| `/api/i/...`    | Internal API used by the Lab Atlas web client. **Not for external use.** API keys are rejected on this mount.                                                |

{% hint style="warning" %}
The public API was originally published under `/api/beta`. If you built against that mount, swap the prefix for `/api/v1` — the paths, payloads, and behavior are otherwise identical.
{% endhint %}

#### Content types

All requests and responses use `application/json` with UTF-8 encoding.

#### Resource identifiers

Identifiers are UUIDs (version 4), serialised as canonical lowercase strings with hyphens — e.g. `b1d1b15a-19e0-4d6f-8d33-7a3d3aef6cba`.

#### Dates

Dates are ISO 8601 strings in UTC. Both date-only (`2026-05-06`) and date-time (`2026-05-06T14:32:11.000+00:00`) formats are accepted on input.

#### Pagination

List endpoints return a page object compatible with Spring Data's `Page<T>` envelope:

```json
{
  "content": [
    { /* DTO */ },
    { /* DTO */ }
  ],
  "totalElements": 142,
  "totalPages": 8,
  "size": 20,
  "number": 0,
  "first": true,
  "last": false,
  "empty": false,
  "pageable": {
    "pageNumber": 0,
    "pageSize": 20,
    "sort": { /* ... */ },
    "offset": 0,
    "paged": true,
    "unpaged": false
  }
}
```

You can request a specific page and size using query parameters:

```
GET /api/v1/projects?page=2&size=50&sort=name,asc
```

| Param  | Default           | Notes                                                                             |
| ------ | ----------------- | --------------------------------------------------------------------------------- |
| `page` | `0`               | Zero-indexed.                                                                     |
| `size` | `20`              | Maximum permitted size depends on the resource; large pages may be capped at 200. |
| `sort` | resource-specific | `field,direction` pairs. Repeat the parameter to sort by multiple fields.         |

#### Status codes

| Code               | Meaning                                                                                          |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| `200 OK`           | Successful read or update. Response body present.                                                |
| `201 Created`      | New resource created. Body contains the created representation.                                  |
| `204 No Content`   | Successful delete. No body.                                                                      |
| `400 Bad Request`  | Malformed payload or invalid query parameter.                                                    |
| `401 Unauthorized` | Missing, expired, malformed, or revoked API key.                                                 |
| `403 Forbidden`    | Authenticated but lacking the required permission (e.g. not a contributor, or not an org admin). |
| `404 Not Found`    | Resource does not exist, or does exist but the key's owner cannot view it.                       |
| `409 Conflict`     | Concurrent update conflict or duplicate identifier.                                              |
| `5xx`              | Server-side error. Try again or contact support if it persists.                                  |

***

### Endpoint reference

The tables below list every public endpoint. Path-prefix is `https://<tenant>/api/v1`. Where an endpoint requires `ORGANIZATION_ADMIN`, this is noted in the right-most column.

#### Projects

Projects are the top-level container for studies and assays.

| Method | Path                                          | Description                                          | Auth        |
| ------ | --------------------------------------------- | ---------------------------------------------------- | ----------- |
| GET    | `/projects`                                   | Paginated list of projects visible to the caller.    | Member      |
| GET    | `/projects/{id}`                              | Fetch a single project.                              | Member      |
| POST   | `/projects`                                   | Create a new project.                                | Member      |
| PUT    | `/projects/{id}`                              | Update name, description, visibility, etc.           | Contributor |
| POST   | `/projects/{id}/status`                       | Change project status. Body: `{"status": "ACTIVE"}`. | Contributor |
| POST   | `/projects/{id}/archive?archived=true\|false` | Archive or restore.                                  | Contributor |
| DELETE | `/projects/{id}`                              | Delete the project (and its studies, assays).        | Contributor |

**Create example**

```bash
curl -X POST "$LA_BASE/api/v1/projects" \
     -H "Authorization: ApiKey $LA_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "code": "OPLAB",
       "name": "Operations Lab",
       "description": "Ops experiments for FY26.",
       "visibility": "PROTECTED",
       "status": "ACTIVE"
     }'
```

**Project status values**: `CREATED`, `INITIALIZING`, `INITIALIZATION_FAILED`, `ACTIVE`, `COMPLETE`, `ON_HOLD`, `DEACTIVATED`.

**Visibility values**: `PUBLIC`, `PROTECTED`, `PRIVATE`.

**Choosing storage (optional)**

By default a new project's files are provisioned in your organization's default cloud storage. To place the project's primary folder elsewhere, include an optional `storage` object on the create payload:

| Field     | Type   | Description                                                                                                                                                                                            |
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `driveId` | UUID   | The storage drive to use (see `GET /storage-drives`).                                                                                                                                                  |
| `mode`    | enum   | `CREATE_NEW` — create a new folder under `path`; `USE_EXISTING` — attach the folder that already exists at `path`.                                                                                     |
| `path`    | string | For `CREATE_NEW`, the parent folder the new folder is created under; for `USE_EXISTING`, the folder itself. Ignored when `driveId` is the default cloud drive (the org's project root is always used). |
| `name`    | string | Optional folder name (`CREATE_NEW` only).                                                                                                                                                              |

Omit the `storage` object entirely to use the default cloud location. Each project has exactly one primary folder at creation; attach additional folders afterward via `POST /projects/{id}/storage-folders` (see Storage folders).

```bash
curl -X POST "$LA_BASE/api/v1/projects" \
     -H "Authorization: ApiKey $LA_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "code": "OPLAB",
       "name": "Operations Lab",
       "description": "Ops experiments for FY26.",
       "visibility": "PROTECTED",
       "storage": {
         "driveId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
         "mode": "CREATE_NEW",
         "path": "/projects",
         "name": "Operations Lab"
       }
     }'
```

**Choosing an ELN notebook (optional)**

If your organization has a connected Electronic Lab Notebook (ELN), you can link the new project to an existing notebook folder at creation time. Each platform has its own block; include the one matching your integration. Omit both to leave ELN provisioning off. The folder must already exist — the API links it as the project's primary notebook folder, it does not create a new one.

Benchling — `benchling` object:

| Field           | Type   | Description                                                            |
| --------------- | ------ | ---------------------------------------------------------------------- |
| `integrationId` | UUID   | The Benchling integration to use (see `GET /integrations`).            |
| `folderId`      | string | The Benchling folder to link as the project's primary notebook folder. |

CDD Vault — `cddVault` object (CDD Vault has no nested folders, so you link an existing project container):

| Field           | Type   | Description                                                 |
| --------------- | ------ | ----------------------------------------------------------- |
| `integrationId` | UUID   | The CDD Vault integration to use (see `GET /integrations`). |
| `projectId`     | number | The CDD Vault project (container) to link.                  |

```bash
curl -X POST "$LA_BASE/api/v1/projects" \
     -H "Authorization: ApiKey $LA_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "code": "OPLAB",
       "name": "Operations Lab",
       "description": "Ops experiments for FY26.",
       "visibility": "PROTECTED",
       "benchling": {
         "integrationId": "3f1a2b4c-5d6e-4f70-8a91-2b3c4d5e6f70",
         "folderId": "lib_abc123"
       }
     }'
```

**Choosing a default Git group (optional)**

If your organization has a connected GitLab integration, you can give the project a default Git group via a `defaultGitGroup` object on create/update. It is a convenience pre-fill only: when Git repositories are created for studies or assays under this project, this group is pre-selected — callers may always choose a different group, and **no repository is created for the project itself**. Omit it for no default.

| Field           | Type   | Description                                                                           |
| --------------- | ------ | ------------------------------------------------------------------------------------- |
| `integrationId` | UUID   | The GitLab integration the group belongs to (see `GET /integrations`).                |
| `groupId`       | string | The id of the Git group to use as the default, as it appears in your GitLab instance. |

A stored default that no longer resolves (group removed, integration disconnected) is simply ignored when pre-filling — it never blocks repository creation.

***

#### Studies

Studies belong to projects and group together related experiments.

| Method | Path                                         | Description                                                | Auth                          |
| ------ | -------------------------------------------- | ---------------------------------------------------------- | ----------------------------- |
| GET    | `/studies`                                   | Paginated list of studies visible to the caller.           | Member                        |
| GET    | `/studies/{id}`                              | Fetch a single study.                                      | Member                        |
| POST   | `/studies`                                   | Create a new study under a project.                        | Contributor on parent project |
| PUT    | `/studies/{id}`                              | Update study fields.                                       | Contributor                   |
| POST   | `/studies/{id}/status`                       | Change status. Body: `{"status": "COMPLETE"}`.             | Contributor                   |
| POST   | `/studies/{id}/archive?archived=true\|false` | Archive or restore.                                        | Contributor                   |
| PATCH  | `/studies/{id}/move`                         | Move to a different project. Body: `{"projectId": "..."}`. | Contributor on both projects  |
| DELETE | `/studies/{id}`                              | Delete the study.                                          | Contributor                   |

**Create example**

```bash
curl -X POST "$LA_BASE/api/v1/studies" \
     -H "Authorization: ApiKey $LA_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "name": "Cell viability screen",
       "description": "12-point dose response.",
       "projectId": "b1d1b15a-19e0-4d6f-8d33-7a3d3aef6cba",
       "status": "ACTIVE",
       "startDate": "2026-05-10"
     }'
```

**Study status values**: `CREATED`, `INITIALIZING`, `INITIALIZATION_FAILED`, `ACTIVE`, `COMPLETE`, `ON_HOLD`, `DEACTIVATED`.

**Choosing an ELN notebook (optional)**

If the parent project is linked to an ELN, you can provision notebook content for the study at creation time. Unlike a project (which links an existing folder), a study inherits the project's ELN integration and gets its own child content. Omit both fields to leave ELN provisioning off.

Benchling — `benchling` object: creates a notebook folder under the project's Benchling folder and a notebook entry in it.

| Field            | Type   | Description                                                                |
| ---------------- | ------ | -------------------------------------------------------------------------- |
| `templateId`     | string | Optional Benchling entry template to apply. Omit for a blank entry.        |
| `templateFields` | object | Optional field values for the chosen template (`{ "Field name": value }`). |

CDD Vault — `useCddVault` boolean: when `true`, creates a notebook entry under the project's CDD Vault container. CDD Vault has no nested folders, so there is nothing else to configure per study.

```bash
curl -X POST "$LA_BASE/api/v1/studies" \
     -H "Authorization: ApiKey $LA_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "name": "Cell viability screen",
       "description": "12-point dose response.",
       "projectId": "b1d1b15a-19e0-4d6f-8d33-7a3d3aef6cba",
       "benchling": {
         "templateId": "tmpl_abc123",
         "templateFields": { "Owner": "Jane Doe" }
       },
       "useCddVault": false
     }'
```

***

#### Assays

Assays are the lowest-level experimental unit; each assay belongs to one study and has a defined assay type.

| Method | Path                                        | Description                                                                      | Auth                        |
| ------ | ------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------- |
| GET    | `/assays`                                   | Paginated list of assays visible to the caller.                                  | Member                      |
| GET    | `/assays/{id}`                              | Fetch a single assay.                                                            | Member                      |
| POST   | `/assays`                                   | Create a new assay under a study. Body must include `studyId` and `assayTypeId`. | Contributor on parent study |
| PUT    | `/assays/{id}`                              | Update assay fields.                                                             | Contributor                 |
| POST   | `/assays/{id}/status`                       | Change status.                                                                   | Contributor                 |
| POST   | `/assays/{id}/archive?archived=true\|false` | Archive or restore.                                                              | Contributor                 |
| PATCH  | `/assays/{id}/move`                         | Move to a different study. Body: `{"studyId": "..."}`.                           | Contributor on both studies |
| DELETE | `/assays/{id}`                              | Delete the assay.                                                                | Contributor                 |

**Create example**

```bash
curl -X POST "$LA_BASE/api/v1/assays" \
     -H "Authorization: ApiKey $LA_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "name": "Plate 1",
       "description": "MTT readout.",
       "studyId": "f4a01b78-...",
       "assayTypeId": "8b2c3d4e-...",
       "status": "ACTIVE",
       "startDate": "2026-05-12"
     }'
```

**Choosing an ELN notebook (optional)**

If the assay's project is linked to an ELN, you can provision notebook content for the assay at creation time. Like a study, an assay inherits the project's ELN integration and gets its own child content. Omit both fields to leave ELN provisioning off.

Benchling — `benchling` object: creates a notebook folder under the parent study's Benchling folder and a notebook entry in it.

| Field            | Type   | Description                                                                |
| ---------------- | ------ | -------------------------------------------------------------------------- |
| `templateId`     | string | Optional Benchling entry template to apply. Omit for a blank entry.        |
| `templateFields` | object | Optional field values for the chosen template (`{ "Field name": value }`). |

CDD Vault — `useCddVault` boolean: when `true`, creates a notebook entry under the project's CDD Vault container. CDD Vault has no nested folders, so there is nothing else to configure per assay.

```bash
curl -X POST "$LA_BASE/api/v1/assays" \
     -H "Authorization: ApiKey $LA_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "name": "Plate 1",
       "description": "MTT readout.",
       "studyId": "f4a01b78-...",
       "assayTypeId": "8b2c3d4e-...",
       "startDate": "2026-05-12",
       "benchling": {
         "templateId": "tmpl_abc123",
         "templateFields": { "Owner": "Jane Doe" }
       },
       "useCddVault": false
     }'
```

***

#### Notes

Notes are free-text annotations attached to a project, study, assay, or your organization. The v1 API exposes the current content of a note; full version history is internal-only. **Note endpoints are read-only on the public API** — create, update, and archive are performed through the Lab Atlas web UI.

| Method | Path                               | Description                       |
| ------ | ---------------------------------- | --------------------------------- |
| GET    | `/projects/{projectId}/notes`      | List notes on a project.          |
| GET    | `/projects/{projectId}/notes/{id}` | Fetch a single note.              |
| GET    | `/studies/{studyId}/notes`         | List notes on a study.            |
| GET    | `/studies/{studyId}/notes/{id}`    | Fetch a single note.              |
| GET    | `/assays/{assayId}/notes`          | List notes on an assay.           |
| GET    | `/assays/{assayId}/notes/{id}`     | Fetch a single note.              |
| GET    | `/organization-notes`              | List notes on your organization.  |
| GET    | `/organization-notes/{id}`         | Fetch a single organization note. |

Archived notes are not returned by either the list or get-by-id endpoints. The id returned by a list is the note's id and resolves directly via the matching get-by-id endpoint.

**Note representation**

A note's `type` is one of `NOTE`, `COMMENT`, `CONCLUSION`, `TEMPLATE`; its `format` is one of `TIPTAP`, `MARKDOWN`, `TEXT`, `QUILL`. The `content` field holds the current version's body.

***

#### Tasks

Tasks are checklist items attached to a project, study, or assay.

| Method | Path                                            | Description                                              |
| ------ | ----------------------------------------------- | -------------------------------------------------------- |
| GET    | `/projects/{projectId}/tasks`                   | List tasks.                                              |
| GET    | `/projects/{projectId}/tasks/{taskId}`          | Fetch a single task.                                     |
| POST   | `/projects/{projectId}/tasks`                   | Add a task.                                              |
| PUT    | `/projects/{projectId}/tasks/{taskId}`          | Update a task.                                           |
| PATCH  | `/projects/{projectId}/tasks/{taskId}/complete` | Mark a task complete. Optional body for completion data. |
| DELETE | `/projects/{projectId}/tasks/{taskId}`          | Remove a task.                                           |
| GET    | `/studies/{studyId}/tasks`                      | List tasks.                                              |
| GET    | `/studies/{studyId}/tasks/{taskId}`             | Fetch a single task.                                     |
| POST   | `/studies/{studyId}/tasks`                      | Add a task.                                              |
| PUT    | `/studies/{studyId}/tasks/{taskId}`             | Update a task.                                           |
| PATCH  | `/studies/{studyId}/tasks/{taskId}/complete`    | Mark a task complete. Optional body for completion data. |
| DELETE | `/studies/{studyId}/tasks/{taskId}`             | Remove a task.                                           |
| GET    | `/assays/{assayId}/tasks`                       | List tasks.                                              |
| GET    | `/assays/{assayId}/tasks/{taskId}`              | Fetch a single task.                                     |
| POST   | `/assays/{assayId}/tasks`                       | Add a task.                                              |
| PUT    | `/assays/{assayId}/tasks/{taskId}`              | Update.                                                  |
| PATCH  | `/assays/{assayId}/tasks/{taskId}/complete`     | Mark complete.                                           |
| DELETE | `/assays/{assayId}/tasks/{taskId}`              | Remove.                                                  |

**Task input body**

```json
{
  "label": "QC the raw data",
  "status": "TODO",
  "dueDate": "2026-05-20",
  "assignedToId": "73c1d4f2-..."
}
```

Status values: `TODO`, `IN_PROGRESS`, `COMPLETE`, `INCOMPLETE`.

***

#### External links

External links are URL references attached to a project, study, or assay.

| Method | Path                                            | Description          |
| ------ | ----------------------------------------------- | -------------------- |
| GET    | `/projects/{projectId}/external-links`          | List links.          |
| GET    | `/projects/{projectId}/external-links/{linkId}` | Fetch a single link. |
| POST   | `/projects/{projectId}/external-links`          | Add a link.          |
| PUT    | `/projects/{projectId}/external-links/{linkId}` | Update.              |
| DELETE | `/projects/{projectId}/external-links/{linkId}` | Remove.              |
| GET    | `/studies/{studyId}/external-links`             | List links.          |
| GET    | `/studies/{studyId}/external-links/{linkId}`    | Fetch.               |
| POST   | `/studies/{studyId}/external-links`             | Add.                 |
| PUT    | `/studies/{studyId}/external-links/{linkId}`    | Update.              |
| DELETE | `/studies/{studyId}/external-links/{linkId}`    | Remove.              |
| GET    | `/assays/{assayId}/external-links`              | List links.          |
| GET    | `/assays/{assayId}/external-links/{linkId}`     | Fetch.               |
| POST   | `/assays/{assayId}/external-links`              | Add.                 |
| PUT    | `/assays/{assayId}/external-links/{linkId}`     | Update.              |
| DELETE | `/assays/{assayId}/external-links/{linkId}`     | Remove.              |

**External-link input body**

```json
{
  "label": "Vendor data sheet",
  "url": "https://supplier.example.com/cat/AB12345.pdf"
}
```

***

#### Collaborators

Collaborators model the external organizations (CROs, partner labs, vendors) you work with. They are organization-scoped — every collaborator belongs to one Lab Atlas organization.

| Method | Path                  | Description                  |
| ------ | --------------------- | ---------------------------- |
| GET    | `/collaborators`      | Paginated list.              |
| GET    | `/collaborators/{id}` | Fetch a single collaborator. |
| POST   | `/collaborators`      | Create.                      |
| PUT    | `/collaborators/{id}` | Update.                      |
| DELETE | `/collaborators/{id}` | Remove.                      |

**Collaborator input body**

```json
{
  "label": "Acme CRO",
  "organizationName": "Acme Contract Research",
  "organizationLocation": "Boston, MA",
  "contactPersonName": "Jane Doe",
  "contactEmail": "jane.doe@acme.example.com",
  "code": "ACME",
  "active": true
}
```

***

#### Teams

Teams are reusable groups of organization users that can be granted access to projects in one step. **Team writes require `ORGANIZATION_ADMIN`.**

| Method | Path                | Description                                         | Auth   |
| ------ | ------------------- | --------------------------------------------------- | ------ |
| GET    | `/teams`            | Paginated list.                                     | Member |
| GET    | `/teams/{id}`       | Fetch a team.                                       | Member |
| POST   | `/teams`            | Create a team.                                      | Admin  |
| PUT    | `/teams/{id}`       | Update name, description, active flag, member list. | Admin  |
| POST   | `/teams/{id}/users` | Add users. Body: `{"userIds": ["...", "..."]}`.     | Admin  |
| DELETE | `/teams/{id}/users` | Remove users. Body: `{"userIds": [...]}`.           | Admin  |

***

#### Keywords

Keywords are simple tag strings shared across an organization.

| Method | Path             | Description                        |
| ------ | ---------------- | ---------------------------------- |
| GET    | `/keywords`      | Paginated list.                    |
| GET    | `/keywords/{id}` | Fetch a keyword.                   |
| POST   | `/keywords`      | Create. Body: `{"value": "AKT1"}`. |

Keyword values are case-sensitive and must be unique within an organization.

***

#### Assay types

Assay types define the metadata schema (fields and standard tasks) for assays. **Assay-type writes require `ORGANIZATION_ADMIN`.**

| Method | Path                       | Description                                                | Auth   |
| ------ | -------------------------- | ---------------------------------------------------------- | ------ |
| GET    | `/assay-types`             | Paginated list.                                            | Member |
| GET    | `/assay-types/{id}`        | Fetch a single assay type, including its fields and tasks. | Member |
| POST   | `/assay-types`             | Create.                                                    | Admin  |
| PUT    | `/assay-types/{id}`        | Update.                                                    | Admin  |
| POST   | `/assay-types/{id}/status` | Change status. Body: `{"status": "ACTIVE"}`.               | Admin  |
| DELETE | `/assay-types/{id}`        | Delete.                                                    | Admin  |

Status values: `DRAFT`, `ACTIVE`, `INACTIVE`, `ARCHIVED`, `DELETED`.

***

#### Study collections

Study collections are named groupings of studies, useful for ad-hoc reporting or cross-project comparisons.

| Method | Path                                        | Description                                       |
| ------ | ------------------------------------------- | ------------------------------------------------- |
| GET    | `/study-collections`                        | List collections visible to the caller.           |
| GET    | `/study-collections/{id}`                   | Fetch a single collection (includes its studies). |
| POST   | `/study-collections`                        | Create.                                           |
| PUT    | `/study-collections/{id}`                   | Update name/description/visibility.               |
| DELETE | `/study-collections/{id}`                   | Delete.                                           |
| POST   | `/study-collections/{id}/studies/{studyId}` | Add a study to the collection.                    |
| DELETE | `/study-collections/{id}/studies/{studyId}` | Remove a study from the collection.               |

**Create body**

```json
{
  "name": "Q2 hit follow-up",
  "description": "Studies tracking validated hits from Q1.",
  "visibility": "PROTECTED"
}
```

***

#### Study relationships

Study relationships record links between studies — e.g. "is parent of", "is blocked by", "is related to".

| Method | Path                                                | Description                                          |
| ------ | --------------------------------------------------- | ---------------------------------------------------- |
| GET    | `/studies/{studyId}/relationships`                  | List relationships originating from the given study. |
| POST   | `/studies/{studyId}/relationships`                  | Create a new relationship.                           |
| DELETE | `/studies/{studyId}/relationships/{relationshipId}` | Remove a relationship.                               |

**Create body**

```json
{
  "type": "IS_RELATED_TO",
  "targetStudyId": "73c1d4f2-..."
}
```

Relationship types: `IS_RELATED_TO`, `IS_PARENT_OF`, `IS_CHILD_OF`, `IS_BLOCKING`, `IS_BLOCKED_BY`, `IS_PRECEDED_BY`, `IS_SUCCEEDED_BY`.

When you create a relationship, the inverse relationship is automatically created on the target study.

***

#### Activity

Activity records describe events that have happened in the organization — study status changes, assay creation, note edits, and so on. Activity is **read-only**; events are generated by Lab Atlas itself as side effects of other API calls.

Visibility follows the same rules as the underlying resources: a caller can only see activity for projects, studies, and assays they are allowed to view. Cross-organization lookups return `404`.

| Method | Path                                | Description                                         |
| ------ | ----------------------------------- | --------------------------------------------------- |
| GET    | `/activity`                         | Paginated org-wide activity visible to the caller.  |
| GET    | `/activity/{id}`                    | Fetch a single activity event by UUID.              |
| GET    | `/projects/{id}/activity`           | Activity scoped to a single project.                |
| GET    | `/studies/{id}/activity`            | Activity scoped to a single study.                  |
| GET    | `/assays/{id}/activity`             | Activity scoped to a single assay.                  |
| GET    | `/organization-users/{id}/activity` | Activity performed by a specific organization user. |

**Activity DTO shape**

```json
{
  "id": "1f3e6c14-...",
  "eventType": "study.status_changed",
  "data": { "oldStatus": "ACTIVE", "newStatus": "COMPLETE" },
  "date": "2026-05-06T14:32:11.000+00:00",
  "organizationId": "b1d1b15a-...",
  "projectId": "8b2c3d4e-...",
  "studyId": "f4a01b78-...",
  "assayId": null,
  "organizationUserId": "73c1d4f2-...",
  "user": { "id": "73c1d4f2-...", "organizationId": "b1d1b15a-...", "user": { /* UserDtoV1 */ }, "roles": ["ORGANIZATION_USER"], "status": "ACTIVE" }
}
```

The `eventType` field is a dotted string (e.g. `study.created`, `assay.status_changed`, `note.updated`). The shape of `data` depends on the event type — treat it as an opaque map of strings to JSON values when consuming generically.

**Filtering by category**

`GET /activity` accepts an optional `category` parameter, repeatable or comma-separated, restricting results to one or more classifications:

| Category         | Covers                                                                                  |
| ---------------- | --------------------------------------------------------------------------------------- |
| `RESEARCH`       | Work on a record — studies, assays, notes, task completion.                             |
| `GOVERNANCE`     | Access and visibility changes scoped to a record — team membership, visibility changes. |
| `ADMINISTRATIVE` | Organization-level changes — invitations, role changes, integration connections.        |

```bash
curl -s "$LA_BASE/api/v1/activity?category=GOVERNANCE,ADMINISTRATIVE" \
     -H "Authorization: ApiKey $LA_KEY" | jq .
```

Omit the parameter to receive every category the caller can see.

***

#### Personal integrations

Your own OAuth connections to external services. These endpoints only ever operate on integrations you own — there is no way to read or disconnect another user's, and an id belonging to someone else returns `404`.

| Method | Path                    | Description                                                             |
| ------ | ----------------------- | ----------------------------------------------------------------------- |
| GET    | `/me/integrations`      | Paginated list of your active personal integration connections.         |
| GET    | `/me/integrations/{id}` | Fetch one of your personal integrations.                                |
| DELETE | `/me/integrations/{id}` | Disconnect. Stored credentials are deleted and the connection archived. |

Disconnecting preserves any drives or folders the connection backed; it removes Lab Atlas's ability to reach them on your behalf. Organization integrations are read through `GET /integrations` instead, and are not modifiable through the public API.

***

#### Storage folders

Storage folders link a project, study, or assay to a folder on a connected storage drive. Each record can have several attached folders, one of which is the **primary** folder. `{folderId}` is the id of the attachment (the join record), not the underlying drive folder. Responses contain folder metadata only — the API never lists the folder's subfolders or files.

| Method | Path                                               | Description                                          |
| ------ | -------------------------------------------------- | ---------------------------------------------------- |
| GET    | `/projects/{projectId}/storage-folders`            | List folders attached to a project.                  |
| GET    | `/projects/{projectId}/storage-folders/{folderId}` | Fetch a single attachment.                           |
| POST   | `/projects/{projectId}/storage-folders`            | Attach an existing drive folder. Returns `201`.      |
| PATCH  | `/projects/{projectId}/storage-folders/{folderId}` | Update the attachment (set as primary).              |
| DELETE | `/projects/{projectId}/storage-folders/{folderId}` | Detach the folder (the drive folder is left intact). |
| GET    | `/studies/{studyId}/storage-folders`               | List folders attached to a study.                    |
| GET    | `/studies/{studyId}/storage-folders/{folderId}`    | Fetch a single attachment.                           |
| POST   | `/studies/{studyId}/storage-folders`               | Attach an existing drive folder.                     |
| PATCH  | `/studies/{studyId}/storage-folders/{folderId}`    | Set as primary.                                      |
| DELETE | `/studies/{studyId}/storage-folders/{folderId}`    | Detach.                                              |
| GET    | `/assays/{assayId}/storage-folders`                | List folders attached to an assay.                   |
| GET    | `/assays/{assayId}/storage-folders/{folderId}`     | Fetch a single attachment.                           |
| POST   | `/assays/{assayId}/storage-folders`                | Attach an existing drive folder.                     |
| PATCH  | `/assays/{assayId}/storage-folders/{folderId}`     | Set as primary.                                      |
| DELETE | `/assays/{assayId}/storage-folders/{folderId}`     | Detach.                                              |

All write operations require **contribute** permission on the parent project/study/assay.

**Attach body**

```json
{
  "driveId": "8b2c3d4e-...",
  "path": "/bucket/projects/CPA/Data"
}
```

The folder at `path` must already exist on the drive. **Set-primary body** is `{"primary": true}`.

***

#### Read-only resources

The following resources are exposed for **reads only** in the public API. Create/update/delete must be performed through the Lab Atlas web UI.

| Resource               | Endpoints                                                         |
| ---------------------- | ----------------------------------------------------------------- |
| Users                  | `GET /users`, `GET /users/{id}`                                   |
| Organization users     | `GET /organization-users`, `GET /organization-users/{id}`         |
| Integrations           | `GET /integrations`, `GET /integrations/{id}`                     |
| Storage drives         | `GET /storage-drives`, `GET /storage-drives/{id}`                 |
| Shared storage folders | `GET /shared-storage-folders`, `GET /shared-storage-folders/{id}` |

***

### Errors

Errors are returned as a JSON envelope:

```json
{
  "timestamp": "2026-05-06T14:32:11.000+00:00",
  "statusCode": 404,
  "error": "Not Found",
  "message": "Project not found: b1d1b15a-19e0-4d6f-8d33-7a3d3aef6cba",
  "path": "/api/v1/projects/b1d1b15a-19e0-4d6f-8d33-7a3d3aef6cba"
}
```

For validation failures (`400`), additional fields are included describing each invalid field:

```json
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "Validation failed",
  "details": "code: must not be blank; description: must not be blank",
  "path": "/api/v1/projects",
  "errors": [
    { "field": "code",        "message": "must not be blank" },
    { "field": "description", "message": "must not be blank" }
  ]
}
```

The `statusCode` field carries the HTTP status as an integer (separate from the per-resource `status` enum some response bodies carry). The `details` field is a one-line human-readable summary of all field violations — handy for log lines and quick triage; for programmatic handling, iterate `errors[]`.

A request that authenticates successfully but is denied at the controller layer (e.g. an admin endpoint called by a non-admin key) returns `403 Forbidden` with an empty body. A request that targets a resource the caller cannot view returns `404 Not Found` rather than `403` — this avoids leaking the existence of records the caller has no business knowing about.

***

### OpenAPI / Swagger

The full machine-readable specification is available at:

* **Swagger UI**: `https://<your-tenant>.labatlas.com/docs/swagger-ui` (select the **public-v1** group)
* **OpenAPI JSON**: `https://<your-tenant>.labatlas.com/docs/api-docs/public-v1`

The Swagger UI is gated behind the same API-key check as the API itself — paste your key into the **Authorize** dialog (header name `X-API-KEY`) to try requests inline.

If you generate client SDKs from the OpenAPI document, regenerate after each Lab Atlas release; field additions are backwards-compatible but new endpoints may appear.

***

### Support

If you hit a bug, need an endpoint that isn't listed here, or want to raise a security concern, email **<support@labatlas.com>** and include:

* The full URL of the failing request
* The HTTP status code returned
* The response body (with any sensitive data redacted)
* A timestamp (UTC) so support can correlate against server logs
* The **key id** portion of the API key in use (the `lak_xxxxxxxx` prefix is safe to share; never share the secret after the dot)
