> 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

How to use the Lab Atlas public API

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 contents 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.

Every member of your organization can create their own keys. No admin role is needed. External Users can't create keys.

To create a key:

1. Sign in to your Lab Atlas tenant.
2. Click **Settings** in the sidebar, then go to **API access → API keys**.
3. Click **New API Key**.
4. In the **Create API Key** dialog, give the key a memorable **Name** (for example, `lims-importer`, `nightly-backup`) and choose a **Duration**: 7, 30, 90, or 365 days. Then click **Submit**.
5. Copy the key value from the dialog. **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 revoke or delete your existing keys from the same page. Revoking a key takes effect immediately; any request presenting the revoked key will fail with `401 Unauthorized`.

> **Heads up:** the **API access** section only appears when the API feature (`apiEnabled`) is turned on for your Lab Atlas tenant, which is the case on Enterprise deployments. If you don't see it under **Settings**, 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.
* **Role-gated operations** — a few endpoints check the key owner's role rather than record membership:

  * Team writes, project deletes, assay type status changes and deletes, and storage drive reads require the `ORGANIZATION_ADMIN` role.
  * Project create, update, status changes and archiving require the `SUPER_USER` role.
  * An External User's key can work in the projects it belongs to as a member's would, but gets `403` deleting, archiving, restoring or moving a study or assay.
  * Assay type create and update, and collaborator create, update and delete, are open to any internal member, but an External User's key gets `403`.

  A key whose owner lacks the role gets `403` on these endpoints.

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`              | Values above 2000 are capped at 2000.                                     |
| `sort` | resource-specific | `field,direction` pairs. Repeat the parameter to sort by multiple fields. |

Not every list endpoint is paginated. The nested lists (notes, tasks, external links, storage folders, and relationships under a project, study, or assay), `/organization-notes`, and `/shared-storage-folders` return a plain JSON array.

#### Status codes

| Code               | Meaning                                                                                                                                                                             |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200 OK`           | Successful read or update. Action endpoints (`status`, `archive`, task `complete`, team `users`, collection `studies`, storage folder detach) return `200` with an empty body.      |
| `201 Created`      | New resource created. Body contains the created representation.                                                                                                                     |
| `204 No Content`   | Successful delete. No body.                                                                                                                                                         |
| `400 Bad Request`  | Malformed payload, invalid query parameter, a rule the request breaks (for example a status change that isn't allowed), or a duplicate (for example a keyword that already exists). |
| `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.                                                                                                          |
| `423 Locked`       | The record, or a record it belongs to, is locked. See [Locked records](#locked-records-projects-studies-assays).                                                                    |
| `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.

#### Owner and projected dates (Projects, Studies, Assays)

Every project, study, and assay has one **owner**, the member accountable for it, and a **projected** start and end alongside its actual `startDate` and `endDate`. See [Planning](/using-lab-atlas/planning.md#owners).

* Responses carry `owner` (an organization user), `projectedStartDate`, and `projectedEndDate`.
* On create, `ownerId` is optional and defaults to the API key's user. The owner is always added to the record's `users`.
* On update, omitting `ownerId`, `projectedStartDate`, or `projectedEndDate` keeps the current value. Only the current owner or an organization admin can change the owner (`403` otherwise). On a transfer, both the new and the outgoing owner stay members.
* **`users` replaces the member list, and it must include the owner.** A `PUT` whose `users` leaves out the owner (including one that omits `users` entirely) is refused with `400`. Transfer ownership first, or include the owner's id.
* A projected end later than the parent's extends the parent (study, then project). A parent's projected dates can't be set so that they no longer cover a child's (`400`). A blank projected end is open-ended.

#### Planning statuses (Projects, Studies, Assays)

Records may be `IN_PLANNING`: planned, with a code, but not started, so no storage folder, ELN entry, or Git repository exists yet. They are returned like any other record. Private `DRAFT` records are never returned, by id or in lists, and neither is anything under one.

* Records created through the API always start immediately. A `status` in a create payload is ignored. Because of that, creating a study under an `IN_PLANNING` project, or an assay under an `IN_PLANNING` study, is refused with `400`.
* Status changes only through `POST …/{id}/status`. A `PUT` whose `status` differs from the record's is refused with `400`. Sending the current status back is fine.
* The status endpoint can't set `DRAFT`, `IN_PLANNING`, `CREATED`, `INITIALIZING`, or `INITIALIZATION_FAILED`, and can't change the status of an `IN_PLANNING` record: promoting and activating aren't available through the public API. Either is refused with `400`.

#### Locked records (Projects, Studies, Assays)

A record can be [locked](/using-lab-atlas/locking-records.md) to stop anyone changing it. A lock applies to the record and everything under it.

* Responses carry `locked` (whether this record itself is locked) and `lockedBy` (the record whose lock applies: this one or its nearest locked parent, with its `type`, `id`, `code`, and `name`; `null` when nothing is locked). Both are read-only.
* Any write to a locked record or anything under it is refused with `423 Locked`. That covers fields, status, members and owner, tasks, notes, storage folders, external links, collaborators, and deletion. Deleting a record is also refused when anything under it is locked. Archiving is still allowed.
* The `423` response body includes `"errorCode": "RECORD_LOCKED"` and a `lockedBy` object naming the record to unlock.
* Locking and unlocking aren't available through the public API.

#### Projects

Projects are the top-level container for studies and assays. **Project writes need the same roles as in the app: `SUPER_USER` to create, update, change status or archive (and contribute permission on the project for all but create), and `ORGANIZATION_ADMIN` to delete.** A plain member's key gets `403` on them.

| 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. Returns `201`.                                  | Super User                 |
| PUT    | `/projects/{id}`                              | Update name, description, visibility, members, etc.                   | Super User and contributor |
| POST   | `/projects/{id}/status`                       | Change project status. Body: `{"status": "COMPLETE"}`. Returns `200`. | Super User and contributor |
| POST   | `/projects/{id}/archive?archived=true\|false` | Archive or restore. Returns `200`.                                    | Super User and contributor |
| DELETE | `/projects/{id}`                              | Delete the project (and its studies, assays). Returns `204`.          | Org Admin                  |

**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"
     }'
```

**Project payload** (`POST` and `PUT`)

| Field                                    | Type    | Notes                                                                                                      |
| ---------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
| `code`                                   | string  | **Required.** Letters and numbers only (see below).                                                        |
| `name`                                   | string  | **Required.**                                                                                              |
| `description`                            | string  | **Required.**                                                                                              |
| `visibility`                             | enum    | `PUBLIC`, `PROTECTED`, `PRIVATE`.                                                                          |
| `ownerId`                                | UUID    | Organization user id. See [Owner and projected dates](#owner-and-projected-dates-projects-studies-assays). |
| `projectedStartDate`, `projectedEndDate` | date    |                                                                                                            |
| `users`                                  | UUID\[] | Organization user ids of the members. On update, replaces the member list.                                 |
| `teams`                                  | UUID\[] | Team ids.                                                                                                  |
| `status`                                 | enum    | Ignored on create; on update it must equal the current status.                                             |
| `storageFolder`                          | object  | Optional. See *Choosing storage* below.                                                                    |
| `benchlingFolder`, `cddVaultFolder`      | object  | Optional. See *Choosing an ELN notebook* below.                                                            |
| `defaultGitGroup`                        | object  | Optional. See *Choosing a default Git group* below.                                                        |

**Project response** carries `id`, `organizationId`, `code`, `name`, `description`, `slug`, `status`, `visibility`, `archived`, `startDate`, `endDate`, `owner`, `projectedStartDate`, `projectedEndDate`, `users` (organization users), `teams`, `storageFolders`, `notebookFolders`, `locked`, `lockedBy`, `createdAt`, `updatedAt`, `createdBy` and `lastModifiedBy`.

**Project status values**: `CREATED`, `DRAFT`, `IN_PLANNING`, `INITIALIZING`, `INITIALIZATION_FAILED`, `ACTIVE`, `COMPLETE`, `ON_HOLD`, `DEACTIVATED` (shown as **Cancelled** in the web app). The status endpoint accepts `ACTIVE`, `COMPLETE`, `ON_HOLD` and `DEACTIVATED` (see [Planning statuses](#planning-statuses-projects-studies-assays)).

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

**Project codes**: a project's `code` must contain only letters and numbers (`^[A-Za-z0-9]+$`) — no spaces, hyphens, underscores, or other punctuation. It is the prefix every study and assay code is built from (`{ProjectCode}-{N}` and `{StudyCode}-{N}`). A code that violates the pattern is rejected with the standard `400` validation envelope rather than being silently sanitized.

**Activation settings on update**: `storageFolder`, `benchlingFolder` and `cddVaultFolder` are never returned when reading a project. On `PUT`, omitting one keeps the stored setting rather than clearing it.

**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 `storageFolder` object on the create payload:

| Field     | Type   | Description                                                                                                                                                                                            |
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `driveId` | UUID   | **Required** when `storageFolder` is present. The storage drive to use (see `GET /storage-drives`, which needs `ORGANIZATION_ADMIN`).                                                                  |
| `mode`    | enum   | Optional; defaults to `CREATE_NEW`. `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 `storageFolder` object entirely to use the default cloud location; a `storageFolder` without a `driveId` is refused with `400`. Each project has exactly one primary folder at creation; attach additional folders afterward via `POST /projects/{id}/storage-folders` (see [Storage folders](#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",
       "storageFolder": {
         "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 — `benchlingFolder` 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.                                                                                 |
| `name`          | string | Optional. The folder's name, shown in the project's Activation settings until it is activated. A label only: the folder is always found by `folderId`. |
| `path`          | string | Optional. The folder's path in Benchling, shown beside its name.                                                                                       |
| `webUrl`        | string | Optional. A link to the folder in Benchling.                                                                                                           |

CDD Vault — `cddVaultFolder` 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.                                                                                                                          |
| `name`          | string | Optional. The project's name, shown in the Lab Atlas project's Activation settings until it is activated. A label only: the project is always found by `projectId`. |
| `webUrl`        | string | Optional. A link to the project in CDD Vault.                                                                                                                       |

Without a `name`, Lab Atlas shows the folder or project by its id. An update that resends the same `folderId` or `projectId` without a `name` keeps the name already stored; these fields are not returned when reading a project.

```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",
       "benchlingFolder": {
         "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. |
| `name`, `path`  | string | Optional display snapshot of the group.                                               |

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. Returns `201`.                                  | Contributor on parent project                     |
| PUT    | `/studies/{id}`                              | Update study fields.                                                                | Contributor                                       |
| POST   | `/studies/{id}/status`                       | Change status. Body: `{"status": "COMPLETE"}`. Returns `200`.                       | Contributor                                       |
| POST   | `/studies/{id}/archive?archived=true\|false` | Archive or restore. Returns `200`.                                                  | Contributor (not External Users)                  |
| PATCH  | `/studies/{id}/move`                         | Move to a different project. Body: `{"projectId": "..."}`. Returns the moved study. | Contributor on both projects (not External Users) |
| DELETE | `/studies/{id}`                              | Delete the study. Returns `204`.                                                    | Contributor (not External Users)                  |

**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",
       "startDate": "2026-05-10",
       "keywords": ["AKT1", "kinase"]
     }'
```

**Study payload** (`POST` and `PUT`)

| Field                                    | Type      | Notes                                                                                                      |
| ---------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------- |
| `projectId`                              | UUID      | **Required.**                                                                                              |
| `name`                                   | string    | **Required.**                                                                                              |
| `description`                            | string    | **Required.**                                                                                              |
| `startDate`, `endDate`                   | date      |                                                                                                            |
| `ownerId`                                | UUID      | Organization user id. See [Owner and projected dates](#owner-and-projected-dates-projects-studies-assays). |
| `projectedStartDate`, `projectedEndDate` | date      |                                                                                                            |
| `users`                                  | UUID\[]   | Organization user ids of the members. On update, replaces the member list.                                 |
| `aliases`                                | string\[] |                                                                                                            |
| `keywords`                               | string\[] | Keyword values. See *Keywords* below.                                                                      |
| `collaborators`                          | object\[] | `[{"collaboratorId": "<uuid>", "code": "<external code>"}]`; `collaboratorId` is required in each entry.   |
| `status`                                 | enum      | Ignored on create; on update it must equal the current status.                                             |
| `benchlingEntry`, `cddVaultEntry`        | object    | Optional. See *Choosing an ELN notebook* below.                                                            |
| `gitRepository`                          | object    | Optional. See *Creating a Git repository* below.                                                           |

**Study response** carries `id`, `projectId`, `code`, `name`, `description`, `slug`, `status`, `archived`, `aliases`, `startDate`, `endDate`, `owner`, `projectedStartDate`, `projectedEndDate`, `users`, `keywords` (`{id, value}` objects), `collaborators`, `externalLinks`, `storageFolders`, `notebookFolders`, `relationships`, `locked`, `lockedBy`, `createdAt`, `updatedAt`, `createdBy` and `lastModifiedBy`.

**Study status values**: `CREATED`, `DRAFT`, `IN_PLANNING`, `INITIALIZING`, `INITIALIZATION_FAILED`, `ACTIVE`, `COMPLETE`, `ON_HOLD`, `CANCELLED`, `NEEDS_ATTENTION`. The status endpoint accepts `ACTIVE`, `COMPLETE`, `ON_HOLD`, `CANCELLED` and `NEEDS_ATTENTION`.

**Keywords**

`keywords` is a list of keyword values, for example `["AKT1", "kinase"]`. A value that doesn't exist yet in your organization is created. On `PUT`, a list replaces the study's keywords, an empty list `[]` clears them, and omitting `keywords` keeps the current keywords. Responses return each keyword as an object with `id` and `value`.

**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 — `benchlingEntry` 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 }`). |
| `templateName`   | string | Optional display label for the template. Never used to find it.            |

CDD Vault — `cddVaultEntry` object: set `useCddVault` to `true` to create 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",
       "benchlingEntry": {
         "templateId": "tmpl_abc123",
         "templateFields": { "Owner": "Jane Doe" }
       },
       "cddVaultEntry": { "useCddVault": false }
     }'
```

**Creating a Git repository (optional)**

If your organization has a connected GitLab integration, include a `gitRepository` object to create a repository for the study. Omit it for no repository.

| Field           | Type   | Description                                              |
| --------------- | ------ | -------------------------------------------------------- |
| `integrationId` | UUID   | The GitLab integration to use (see `GET /integrations`). |
| `groupId`       | string | The Git group to create the repository in.               |
| `name`          | string | Optional repository name.                                |

`benchlingEntry`, `cddVaultEntry` and `gitRepository` are never returned when reading a study. On `PUT`, omitting one keeps the stored setting rather than clearing it.

***

#### 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`. Returns `201`. | Contributor on parent study                      |
| PUT    | `/assays/{id}`                              | Update assay fields.                                                                            | Contributor                                      |
| POST   | `/assays/{id}/status`                       | Change status. Body: `{"status": "COMPLETE"}`. Returns `200`.                                   | Contributor                                      |
| POST   | `/assays/{id}/archive?archived=true\|false` | Archive or restore. Returns `200`.                                                              | Contributor (not External Users)                 |
| PATCH  | `/assays/{id}/move`                         | Move to a different study. Body: `{"studyId": "..."}`. Returns the moved assay.                 | Contributor on both studies (not External Users) |
| DELETE | `/assays/{id}`                              | Delete the assay. Returns `204`.                                                                | Contributor (not External Users)                 |

**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-...",
       "startDate": "2026-05-12",
       "fields": { "cell_line": "HEK293" }
     }'
```

**Assay payload** (`POST` and `PUT`)

The same as the study payload, with `studyId` (**required**) in place of `projectId`, plus:

| Field         | Type   | Notes                                                                  |
| ------------- | ------ | ---------------------------------------------------------------------- |
| `assayTypeId` | UUID   | **Required.**                                                          |
| `fields`      | object | Values for the assay type's fields, keyed by each field's `fieldName`. |

A study whose setup hasn't finished (`CREATED`, `INITIALIZING`, `INITIALIZATION_FAILED`) doesn't accept new assays (`400`).

**Assay response** carries the same fields as a study response, with `studyId` and `assayTypeId` in place of `projectId`, plus `fields`; it has no `relationships`.

**Assay status values**: the same as studies — `CREATED`, `DRAFT`, `IN_PLANNING`, `INITIALIZING`, `INITIALIZATION_FAILED`, `ACTIVE`, `COMPLETE`, `ON_HOLD`, `CANCELLED`, `NEEDS_ATTENTION`.

**Keywords**: as for studies. On `PUT`, a list replaces the assay's keywords, `[]` clears them, and omitting `keywords` keeps them.

**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 — `benchlingEntry` object: creates a notebook folder under the parent study's Benchling folder and a notebook entry in it. Fields as for studies (`templateId`, `templateFields`, `templateName`).

CDD Vault — `cddVaultEntry` object: set `useCddVault` to `true` to create 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",
       "benchlingEntry": {
         "templateId": "tmpl_abc123",
         "templateFields": { "Owner": "Jane Doe" }
       },
       "cddVaultEntry": { "useCddVault": false }
     }'
```

**Creating a Git repository (optional)**: a `gitRepository` object (`integrationId`, `groupId`, optional `name`), as for studies. As with studies, the ELN and Git objects are never returned on read, and omitting one on `PUT` keeps the stored setting.

***

#### 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. Returns `201`.                                                       |
| PUT    | `/projects/{projectId}/tasks/{taskId}`          | Update a task.                                                                   |
| PATCH  | `/projects/{projectId}/tasks/{taskId}/complete` | Mark a task complete. Optional body for completion data. Returns `200`, no body. |
| DELETE | `/projects/{projectId}/tasks/{taskId}`          | Remove a task. Returns `204`.                                                    |
| 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** (`POST` and `PUT`)

```json
{
  "label": "QC the raw data",
  "description": "Check plate 3 first.",
  "status": "TODO",
  "order": 0,
  "dueDate": "2026-05-20",
  "assignedToId": "73c1d4f2-...",
  "data": { "instrument": "Bruker 400" }
}
```

`label`, `status` and `order` are **required**; a body missing any of them is refused with `400`. `assignedToId` is an organization user id. The `PATCH .../complete` body is optional and only its `data` map is read, e.g. `{"data": {"instrument": "Bruker 400"}}`.

Status values: `TODO`, `IN_PROGRESS`, `COMPLETE`, `INCOMPLETE`. `TODO` and `IN_PROGRESS` are the *active* states; `COMPLETE` (finished) and `INCOMPLETE` (closed without completing — abandoned or superseded) are the *closed* states.

**Task response fields**

In addition to the input fields, responses carry:

| Field                    | Description                                                                                                                                                                                                                                                                                                                                        |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`                   | An organization-scoped counter, rendered `T-{N}` in the UI (e.g. `42` → `T-42`). Assigned as max+1 across all tasks including archived ones, so numbers are not recycled. It is a display aid, not an identifier — it carries no uniqueness constraint and is not guaranteed collision-free under concurrent creation. Use `id` to address a task. |
| `startedAt`              | When the task most recently entered `IN_PROGRESS`. Cleared if it returns to `TODO`.                                                                                                                                                                                                                                                                |
| `completedAt`            | When the task most recently entered a closed status. Cleared if the task is reopened.                                                                                                                                                                                                                                                              |
| `data`                   | Free-form map of recorded field values.                                                                                                                                                                                                                                                                                                            |
| `createdAt`, `updatedAt` | Timestamps.                                                                                                                                                                                                                                                                                                                                        |

**Required fields and the completion gate**

A task may carry required custom fields (configured in the Lab Atlas web UI). Such a task cannot become `COMPLETE` until every one of those fields has a value recorded in `data` — supply them in the `PATCH .../complete` body. A missing value returns `400` with a message naming the fields that are unrecorded. Moving a task to `INCOMPLETE` bypasses the gate. The field definitions themselves are not exposed on the public API.

All task writes require **contribute** permission on the parent project, study, or assay.

Deleting a task archives it; archived tasks are not returned by any endpoint. Archiving a project, study, or assay cascade-archives its tasks.

***

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

Both fields are **required**, and `url` must be an absolute URL. Responses carry `id`, `label` and `url`. Create returns `201`; delete returns `204`.

***

#### 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. **Any internal member can create, update and delete a collaborator; External Users can read collaborators but get `403` on every write.**

| Method | Path                  | Description                  | Auth                        |
| ------ | --------------------- | ---------------------------- | --------------------------- |
| GET    | `/collaborators`      | Paginated list.              | Member                      |
| GET    | `/collaborators/{id}` | Fetch a single collaborator. | Member                      |
| POST   | `/collaborators`      | Create.                      | Member (not External Users) |
| PUT    | `/collaborators/{id}` | Update.                      | Member (not External Users) |
| DELETE | `/collaborators/{id}` | Remove.                      | Member (not External Users) |

**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
}
```

`label`, `organizationName` and `code` are **required**; `active` defaults to `true`. Responses add `id` and `organizationId`. Create returns `201`; delete returns `204`.

***

#### 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. Returns `201`.                                  | Admin  |
| PUT    | `/teams/{id}`       | Update name, description, active flag, member list.            | Admin  |
| POST   | `/teams/{id}/users` | Add users. Body: `{"userIds": ["...", "..."]}`. Returns `200`. | Admin  |
| DELETE | `/teams/{id}/users` | Remove users. Body: `{"userIds": [...]}`. Returns `200`.       | Admin  |

**Team input body** (`POST` and `PUT`)

```json
{
  "name": "Screening",
  "description": "The screening group.",
  "active": true,
  "userIds": ["73c1d4f2-..."]
}
```

`name` and `description` are **required**; `active` defaults to `true`. `userIds` are organization user ids; on `PUT` they replace the team's members, so omitting `userIds` empties the team. Responses carry `id`, `organizationId`, `name`, `description`, `active`, `users` (organization users), `createdAt` and `updatedAt`. Teams can't be deleted through the public API.

***

#### 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"}`. Returns `201`. |

Keyword values are case-sensitive and must be unique within an organization: creating a value that already exists returns `400`. Keywords can't be deleted through the public API.

***

#### Assay types

Assay types define the metadata schema (fields and standard tasks) for assays. **Any internal member can create and update an assay type; changing its status or deleting it requires `ORGANIZATION_ADMIN`.** External Users get `403` on every assay-type write.

| 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. Returns `201`.                                      | Member (not External Users) |
| PUT    | `/assay-types/{id}`        | Update.                                                     | Member (not External Users) |
| POST   | `/assay-types/{id}/status` | Change status. Body: `{"status": "ACTIVE"}`. Returns `200`. | Admin                       |
| DELETE | `/assay-types/{id}`        | Delete. Returns `204`.                                      | Admin                       |

Status values: `DRAFT`, `ACTIVE`, `INACTIVE`, `ARCHIVED`, `DELETED`. A new assay type without a `status` is `ACTIVE`. Deleting an assay type sets its status to `DELETED`. A deleted assay type is left out of `GET /assay-types`, and `GET`, `PUT` and `DELETE` on `/assay-types/{id}` return `404` for it. `POST /assay-types/{id}/status` still accepts it, so an admin can restore it with `{"status": "ACTIVE"}`.

**Assay type input body**

| Field         | Type      | Notes                                                                                                                                                                                                                                                                                                                                               |
| ------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`        | string    | **Required.**                                                                                                                                                                                                                                                                                                                                       |
| `description` | string    | **Required.**                                                                                                                                                                                                                                                                                                                                       |
| `status`      | enum      | On `PUT`, applied only when the caller is an `ORGANIZATION_ADMIN`. For anyone else, or when it's left out, the stored status is kept.                                                                                                                                                                                                               |
| `fields`      | object\[] | Assay fields. Each: `displayName` (**required**), `type` (`STRING`, `TEXT`, `INTEGER`, `FLOAT`, `DATE`, `BOOLEAN`, `FILE`, `DROPDOWN`; default `STRING`), `fieldName` (derived from `displayName` when omitted), `required`, `active` (default `true`), `description`, `fieldOrder`, `defaultValue`, `dropdownOptions`, and `id` for a saved field. |
| `tasks`       | object\[] | Default tasks copied onto each new assay. Each: `label` (**required**), `status` (default `TODO`), `description`, `order`, `fields` (same shape as assay fields), and `id` for a saved task.                                                                                                                                                        |

**Updating fields and tasks**

On `PUT /assay-types/{id}`, `fields` and `tasks` are each the complete list:

* Leave `fields` or `tasks` out of the request to keep the saved ones unchanged.
* If you send a list, even an empty one, it replaces the saved list. Include each saved entry you want to keep, with its `id`.
* A saved field missing from the list is deactivated, not deleted: its `active` property becomes `false`. Existing assays keep the values they recorded for it. You can also deactivate a field by sending it with `"active": false`.
* A saved default task missing from the list is deleted. Assays that already have a copy of the task keep it.
* A saved field's `type` and `fieldName` can't change.
* An `id` that doesn't belong to this assay type is refused with `400`.

The whole request is checked before anything is saved, so a rejected update changes nothing.

***

#### Study collections

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

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

**Create body**

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

`name` is **required**. Responses carry `id`, `organizationId`, `name`, `slug`, `description`, `visibility`, `studyIds`, `createdAt`, `updatedAt` and `createdBy`.

Updating a collection, or adding and removing studies, is allowed for its creator, its members, an organization admin, or anyone when the collection is `PUBLIC` (`403` otherwise). Deleting it is allowed only for its creator or an organization admin.

***

#### 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. Returns `201`.            |
| DELETE | `/studies/{studyId}/relationships/{relationshipId}` | Remove a relationship. Returns `204`.                |

**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`.

Both fields are **required**. When you create a relationship, the inverse relationship is automatically created on the target study; if the two studies are already related, the existing pair's types are replaced. A `DRAFT` study can't be related (`400`). Deleting removes both directions. Creating and deleting both require **contribute** permission on the study in the path (`403` otherwise); a relationship that doesn't originate from that study returns `404`.

Each relationship in a response carries its `id`, `type` and `targetStudy`: a summary of the related study (`id`, `projectId`, `code`, `name`, `slug`, `status`, `description`, `archived`). `targetStudy` is `null` when you can't view the related 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": ["USER"], "status": "ACTIVE" }
}
```

Every activity endpoint except `GET /activity/{id}` is paginated with the standard `page`, `size`, and `sort` parameters (see [Pagination](#pagination)). Only `GET /activity` takes `category`.

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 the Activity Stream: `RESEARCH` and `GOVERNANCE` activity, minus a few low-signal event types (note edits before publishing, task deletions, link edits and deletions, assay type changes, user changes). `ADMINISTRATIVE` activity is returned only when you ask for it, even to an admin's key. Administrative activity isn't tied to a project, study, or assay, so only keys owned by an organization admin can see it: a non-admin key that asks for `ADMINISTRATIVE` gets an empty page rather than an error.

***

#### 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. Detach returns `200` with an empty body. Attaching, detaching, or changing the primary folder of a `DRAFT`, `IN_PLANNING`, or `INITIALIZATION_FAILED` record is refused with `400`: its storage is set up by its Activation settings.

**Attach body**

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

`driveId` and `path` are **required**. The folder at `path` must already exist on the drive. **Set-primary body** is `{"primary": true}`; `{"primary": false}` changes nothing.

***

#### 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}` (archived, system-managed and personal integrations are not returned) |
| Storage drives         | `GET /storage-drives`, `GET /storage-drives/{id}` (**`ORGANIZATION_ADMIN` only**)                                   |
| Shared storage folders | `GET /shared-storage-folders`, `GET /shared-storage-folders/{id}`                                                   |

***

### Errors

Errors are returned as a JSON envelope. Most errors (`400` rule violations, `403`, `404`, `5xx`) carry `message`, `timestamp`, and a `details` field holding the request path:

```json
{
  "message": "Project not found: b1d1b15a-19e0-4d6f-8d33-7a3d3aef6cba",
  "details": "uri=/api/v1/projects/b1d1b15a-19e0-4d6f-8d33-7a3d3aef6cba",
  "timestamp": "2026-05-06T14:32:11.000+00:00"
}
```

For validation failures and malformed bodies (`400`), `statusCode`, `error` and `path` are added, and validation failures also describe 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). Here 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 `423 Locked` response adds `errorCode` and `lockedBy`:

```json
{
  "message": "...",
  "details": "uri=/api/v1/studies/f4a01b78-...",
  "timestamp": "2026-05-06T14:32:11.000+00:00",
  "errorCode": "RECORD_LOCKED",
  "lockedBy": { "type": "PROJECT", "id": "b1d1b15a-...", "code": "OPLAB", "name": "Operations Lab" }
}
```

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 the standard envelope. A `401` has no 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:

* **OpenAPI JSON**: `https://<your-tenant>.labatlas.com/docs/api-docs/public-v1`
* **Swagger UI**: `https://<your-tenant>.labatlas.com/docs/swagger-ui` (select the **public-v1** group), when it is enabled for your deployment. It is off by default.

The `/docs/**` pages themselves need no key. To try requests from the Swagger UI, paste your key into the **Authorize** dialog (header name `X-API-KEY`).

> The specification lists each endpoint under its `/api/beta/...` path. The `/api/v1/...` paths are identical; swap the prefix.

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)
