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

# Cogram API

Integrate Cogram with your own systems: automate project management, sync user data, and build custom integrations against a stable REST API.

## Overview

The REST API lives on its own subdomain (`api.cogram.com`), authenticates with API keys, and exposes stable, versioned endpoints built for third-party integrations.

**Key features:**

* **Stable versioned endpoints** - The API is versioned (`/v1/`) to ensure backwards compatibility
* **Interactive documentation** - Explore and test endpoints directly at [api.cogram.com/v1/docs](https://api.cogram.com/v1/docs)
* **External ID mapping** - Link Cogram resources to your own system's identifiers

## Authentication

The Cogram API uses API key authentication. API keys are organization-scoped and can be created by organization administrators.

### Creating an API Key

1. Go to [Organization Settings → Integrations → API Keys](https://app.cogram.com/dashboard/settings/admin/integrations) in the Cogram app.
2. Click **Create API Key**.
3. Give your key a descriptive name and optionally set an expiration date.
4. Copy the key immediately. It will only be shown once.

### Using Your API Key

Include the API key in the `Authorization` header as a Bearer token:

```bash
curl -X GET "https://api.cogram.com/v1/projects" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Optional Headers

> **X-Forwarded-User**: Identifier of the user in your system (e.g., user ID or email) who triggered this action. When provided, Cogram's audit logs will attribute the action to this user rather than just the API key. Omit for automated system tasks with no associated user.

## Base URL

All API requests should be made to:

```
https://api.cogram.com
```

## Available Endpoints

### Projects

Manage projects within your organization.

| Method   | Endpoint            | Description            |
| -------- | ------------------- | ---------------------- |
| `GET`    | `/v1/projects`      | List all projects      |
| `POST`   | `/v1/projects`      | Create a new project   |
| `GET`    | `/v1/projects/{id}` | Get a specific project |
| `PATCH`  | `/v1/projects/{id}` | Update a project       |
| `DELETE` | `/v1/projects/{id}` | Archive a project      |

Project create and update requests, and all project responses, include an **`is_public`** field. When `true`, the project is discoverable: it appears in the organization-wide project directory and non-members can request access. When `false`, the project is not listed in the directory. See the [interactive API docs](https://api.cogram.com/v1/docs) for full request/response schemas.

#### Record counts per project

Add **`?include=counts`** to either `GET /v1/projects` or `GET /v1/projects/{id}` to populate a `related_counts` object on each project: how many emails, documents, drawings, drawing sets, submittals, RFIs, meetings, reports, observations, and transmittals are filed under it. Every field is present and defaults to `0`, so a project with nothing filed returns zeros rather than omitting keys. To request more than one section at once, see [Members per project](#members-per-project) below.

```bash
curl "https://api.cogram.com/v1/projects?include=counts&page_size=100" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Without the parameter, `related_counts` is `null` and no counting work is done; existing integrations are unaffected. The counts for a whole page cost one additional query regardless of `page_size`, so requesting them on a 100-project page is no more expensive than on one project.

Counts are commonly used to notice that a project has changed since a previous poll: fetch a page, compare against the values you stored last time, and act on the projects that moved. Three kinds of change are invisible to them, because the number of records does not change:

* An edit that leaves the record in place: a renamed document, a corrected email subject.
* A new revision of an existing drawing, or action items generated for an existing meeting. Only the top-level record is counted, so `drawings` and `meetings` stay flat.
* An addition and a deletion inside the same polling interval, which cancel out.

Soft-deleted meetings are excluded, matching the rest of the API. Cogram's built-in example records are counted; they are real records in the project.

#### Members per project

Add **`?include=members`** to either `GET /v1/projects` or `GET /v1/projects/{id}` to populate a `members` array on each project. Every entry carries the member's `user_id`, `email`, and project `role` (`OWNER`, `LEAD`, `MEMBER`, or `VIEWER`), ordered by email so two polls can be compared without sorting first. `LEAD` is a management role (like `OWNER` but without the ability to add or remove members or delete the project) and appears only for organizations that have it enabled.

Match on `user_id`, not `email`: two accounts can share an email address, and `user_id` is what the membership endpoints below are keyed by. Display names are not included; fetch `GET /v1/projects/{id}/members` when you need them.

```bash
curl "https://api.cogram.com/v1/projects?include=members" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Without the parameter, `members` is `null` and no membership work is done; existing integrations are unaffected. A project that genuinely has no members returns `[]`, which is how you tell "nobody is on it" apart from "I didn't ask".

`include` takes more than one section in a single request, spelled either way: repeat the parameter (`?include=members&include=counts`) or comma-separate the values (`?include=members,counts`). A name that matches no section is still rejected with a `422`, so a typo does not fail silently.

Two things the array does not cover:

* **Group-granted access is not listed.** Only direct per-user roles appear. Someone who can reach the project because their group was added to it is not a member here.
* **Users banned from your organization are omitted**, matching `GET /v1/projects/{id}/members`.

The roster for a whole page costs one additional query regardless of `page_size`, but unlike counts the response itself grows with every member it carries; a 100-project page returns the roster of all 100. If you only need one project's members, ask on `GET /v1/projects/{id}`.

#### Polling for changed projects

Add **`?updated_since=<timestamp>`** to `GET /v1/projects` to get only the projects that changed, instead of paging your whole organization to find out. Use it to keep an external system (a file server, a document store, a data warehouse) in step with Cogram.

```bash
curl "https://api.cogram.com/v1/projects?include_archived=true&updated_since=2026-08-17T18%3A03%3A11Z" \
  -H "Authorization: Bearer $COGRAM_API_KEY"
```

The timestamp must carry a UTC offset (`2026-08-17T18:03:11Z` or `2026-08-17T20:03:11+02:00`); one without an offset is rejected with `422`. Remember to URL-encode it: a raw `+` in a query string means a space.

It answers **what changed, not what changed to what**. Each project comes back in its current state, so hold the values you care about and compare. That is what tells you a project was archived, renamed, or had a Unanet field rewritten.

Four properties make it safe to build a loop on:

* **It reports creations too.** A project created inside the window has no `updated_at` yet, so it is matched on `created_at` instead. One cursor covers both, and you do not need a second poll for new projects.
* **Results are ordered oldest change first**, so a project modified while you are paging is appended rather than shifted into a page you already read.
* **The bound is inclusive.** Store the newest `updated_at` (or `created_at`, for a project that has never been updated) you were served and pass it back next time. You may be served that one project again; you will not miss another that shares its timestamp across a page boundary.
* **Archiving is a change, not a disappearance**, but only if you ask for it. Pass `include_archived=true`, or an archived project drops out of the response and reads as if it were deleted.

Two limits to design around:

* **Deletions are not reported.** A deleted project is absent from later responses. If that matters, reconcile against a full listing on a slower schedule.
* **`updated_at` tracks the project record only**: its name, client, archive state, and Unanet fields. Filing an email or a drawing under a project does not change it. For that, use `?include=counts` above.

### Project Members

Manage project membership and roles.

| Method   | Endpoint                              | Description            |
| -------- | ------------------------------------- | ---------------------- |
| `GET`    | `/v1/projects/{id}/members`           | List project members   |
| `PUT`    | `/v1/projects/{id}/members/{user_id}` | Add or update a member |
| `DELETE` | `/v1/projects/{id}/members/{user_id}` | Remove a member        |

Assigning the `LEAD` role (on create-with-members or `PUT .../members/{user_id}`) requires the role to be enabled for your organization; otherwise the request returns `422`. `OWNER`, `MEMBER`, and `VIEWER` are always assignable.

### Project Types

Manage the organization's project-type taxonomy (e.g. "Design-Build", "CM at Risk"). Project types are configured in the app at [Organization Settings → Projects → Project Types](https://app.cogram.com/dashboard/settings/admin/projects/project-types); the API exposes the same CRUD.

| Method   | Endpoint                 | Description                 |
| -------- | ------------------------ | --------------------------- |
| `GET`    | `/v1/project-types`      | List all project types      |
| `POST`   | `/v1/project-types`      | Create a new project type   |
| `GET`    | `/v1/project-types/{id}` | Get a specific project type |
| `PATCH`  | `/v1/project-types/{id}` | Update a project type       |
| `DELETE` | `/v1/project-types/{id}` | Delete a project type       |

Deleting a type clears the type on any project that was using it (the project is not deleted). See the [interactive API docs](https://api.cogram.com/v1/docs) for full request/response schemas.

### Users

Manage organization members.

| Method  | Endpoint              | Description               |
| ------- | --------------------- | ------------------------- |
| `GET`   | `/v1/users`           | List organization members |
| `PATCH` | `/v1/users/{user_id}` | Update a user's role      |

### Data Exports

Request and download a multi-part zip export of your organization's data: projects, meetings, emails, documents, drawings, reports, observations, transmittals, and Procore-synced submittals and RFIs. Builds run asynchronously; once complete, each part is delivered via short-lived signed URLs (1-hour validity, regenerated on every status read).

For an end-to-end walkthrough of what's inside each zip, see [Data export package layout](/organization-administration/data-export-package-layout.md). For the UI equivalent of these endpoints, see [Data exports](/organization-administration/data-exports.md).

Data Exports are admin-equivalent: only admins or owners can issue API keys, and the endpoints can only be invoked by keys issued for the same organization. Manage keys at [Organization Settings → Integrations → API Keys](https://app.cogram.com/dashboard/settings/admin/integrations).

| Method | Endpoint                       | Description                                        |
| ------ | ------------------------------ | -------------------------------------------------- |
| `POST` | `/v1/data-exports`             | Create a new data export (returns immediately)     |
| `GET`  | `/v1/data-exports`             | List data exports for the organization (paginated) |
| `GET`  | `/v1/data-exports/{id}`        | Get one data export's status and download URLs     |
| `POST` | `/v1/data-exports/{id}/cancel` | Cancel a `pending` or `running` data export        |

`GET /v1/data-exports` returns the standard paginated envelope `{ "data": [...], "total": N, "page": N, "page_size": N }`. Use `?page=N&page_size=N` (defaults: `page=1`, `page_size=50`, max `page_size=100`) to walk through the audit trail.

Meeting **audio recordings are never exported**. Each meeting in the zip ships as `meeting.json`, `meeting.md`, and `transcript.json` (when the meeting was transcribed). A rendered `meeting.docx` is added whenever your org has a default template (see [Data Exports → templates](/organization-administration/data-exports.md#choosing-a-meeting-template-before-exporting)). Photos and uploaded meeting attachments are included; audio is intentionally kept inside Cogram.

#### Request body: `POST /v1/data-exports`

| Field              | Type                   | Description                                                                                                                                                                                                                                                                       |
| ------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `project_ids`      | array\[string] \| null | Optional. Scope the export to these projects; list candidate ids with `GET /v1/projects` (add `?include_archived=true` to reach archived projects, which are still exportable). Org-wide exports (when omitted) must specify a time range under 6 calendar months.                |
| `time_filter`      | object                 | Discriminated by `mode`. `{"mode": "range", "start": <iso>, "end": <iso>}` or `{"mode": "all_time"}`. `all_time` is project-only.                                                                                                                                                 |
| `document_formats` | array\[string]         | Optional, defaults to `[]`. Pass `["docx"]` to render a `.docx` per field report alongside the JSON, plus one per observation its report does not already show. Does not affect `meeting.docx`, which every export includes. Rendering makes the build slower and the zip larger. |

#### Response fields

| Field                      | Type             | Description                                                                                                                     |
| -------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `id`                       | string           | Unique identifier (`dex_...`)                                                                                                   |
| `organization_id`          | string           | Org the export belongs to                                                                                                       |
| `source`                   | string           | `ui` or `api`: how the export was created                                                                                       |
| `requester_user_id`        | UUID \| null     | User who triggered it (UI source only)                                                                                          |
| `requester_api_key_id`     | string \| null   | API key that triggered it (API source only)                                                                                     |
| `requester_forwarded_user` | string \| null   | Value of the `X-Forwarded-User` header at create time (audit trail)                                                             |
| `requester_display`        | string           | Human-readable attribution: user's name, `API key '<name>'`, or `<forwarded_user> (via API key '<name>')` when both are present |
| `project_ids`              | array\[string]   | Scope as requested; empty for org-wide exports                                                                                  |
| `projects`                 | array            | Scope projects that still exist, with `id`, `name`, and `customer_project_id`                                                   |
| `time_filter`              | object           | Echo of the requested filter                                                                                                    |
| `document_formats`         | array\[string]   | Echo of the requested rendered document formats                                                                                 |
| `status`                   | string           | `pending`, `running`, `cancelling`, `cancelled`, `completed`, `failed`, or `expired`                                            |
| `error`                    | string \| null   | Failure reason when `status == "failed"`                                                                                        |
| `requested_at`             | datetime         | When the export was created                                                                                                     |
| `started_at`               | datetime \| null | When the build worker began                                                                                                     |
| `completed_at`             | datetime \| null | When the build finished (success, failure, or cancellation)                                                                     |
| `expires_at`               | datetime \| null | When the parts will be deleted from storage (7 days after `completed_at` for `completed`)                                       |
| `parts`                    | array            | Empty until `status == "completed"`; one entry per zip part with a freshly-signed URL                                           |

Each entry in `parts` includes:

| Field        | Type    | Description                                              |
| ------------ | ------- | -------------------------------------------------------- |
| `id`         | string  | Unique part identifier (`dxp_...`)                       |
| `part_index` | integer | Zero-indexed position within the multi-part set          |
| `size_bytes` | integer | Compressed part size                                     |
| `signed_url` | string  | One-hour pre-signed download URL; re-fetch on every read |

#### Polling pattern

```bash
# 1) Create: returns 201 with status=pending
curl -X POST "https://api.cogram.com/v1/data-exports" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"time_filter": {"mode": "range", "start": "2026-01-01T00:00:00Z", "end": "2026-04-01T00:00:00Z"}}'

# 1b) Or scope it to specific projects, where any time range is allowed
curl -X POST "https://api.cogram.com/v1/data-exports" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"project_ids": ["prj_...", "prj_..."], "time_filter": {"mode": "all_time"}}'

# 2) Poll until status == "completed"
curl "https://api.cogram.com/v1/data-exports/dex_..." \
  -H "Authorization: Bearer YOUR_API_KEY"

# 3) Download each part. The signed URLs are valid for 1 hour from the GET
#    above and serve a Content-Disposition header so the file lands with
#    a friendly name like
#    Cogram_data_export_2026-04-01_12-00-00_part_000_all_projects_2026-01-01_to_2026-04-01.zip
#
#    With wget, use --content-disposition to honor the suggested filename:
wget --content-disposition '<signed_url>'
#
#    With curl, use -OJ (capital O capital J). Plain `curl -o name.zip`
#    forces a local name and bypasses the friendly filename entirely.
curl -OJ '<signed_url>'
```

The zip's internal structure, including folders, file types, and how to identify Cogram's example/demo data (the `is_dummy` field on JSON entities, and the `X-Cogram-Is-Dummy` header on `.eml` files), is documented in [Data Export Package Layout](/organization-administration/data-export-package-layout.md#identifying-example--demo-data).

> **Date ranges are inclusive of the `end` timestamp.** The backend filter is `<=` on the `end` value you supply. If you want "everything modified through May 31", send `end=2026-06-01T00:00:00Z` (the next-day UTC midnight). Sending `2026-05-31T00:00:00Z` would only capture exactly midnight on May 31, not the full day. The Cogram UI does this conversion for you when you pick a date in the date-picker; API callers must do it themselves.

> **Cap rule**: Org-wide exports (no `project_ids`) are limited to a 6-month range and reject `all_time`. Project-scoped exports have no time-range limit. A violation returns `400` with `"error": "range_too_large"` (typed `ErrorCode`).

> **Project scope**: A project id your organization doesn't own returns `404` with `"error": "not_found"`, the same response as an id that doesn't exist anywhere, so the API never reveals another organization's projects. One bad id rejects the whole request, so you never receive a zip that silently covers fewer projects than you asked for. The check runs before the export is accepted, so a rejected scope costs you nothing: no export row is created and the one-active-export slot stays free for an immediate retry.

> **Deprecated `project_id`**: the original single-project field is still accepted and normalizes to a one-element `project_ids`. Sending both fields returns `422`. New integrations should use `project_ids`.

> **Concurrency**: At most one active export (`pending`, `running`, or `cancelling`) per organization, enforced both at the application layer and by a Postgres partial unique index. A second `POST /v1/data-exports` while an active one exists returns `409` with `"error": "data_export_in_progress"` and the existing export's id woven into the message. Poll that id and re-issue once it terminates.

> **Cancellation**: `pending` exports cancel immediately. `running` exports transition to `cancelling`; the worker stops between zip parts and the next status read reflects the terminal state. Terminal statuses (`completed`, `failed`, `cancelled`, `expired`) return `409 conflict`.

> **Stuck-row recovery**: If a worker dies hard mid-build (rare), a periodic sweep transitions rows stuck in `running`/`cancelling` to `failed` so the per-org concurrency slot doesn't stay blocked. Retry by creating a new export. A row is only swept after \~6 hours with no sign of progress, so a large export that is still running is never cut short, however long it takes. Cancel it if you no longer want it.

### Backup Runs

Report each backup run performed by the LucidLink connector, so Cogram can tell you when backups stop.

Cogram cannot detect a backup that never starts: a connector that has stopped cannot report that it stopped. Instead, the LucidLink connector tells Cogram when a run finishes and how often it is scheduled to run. If a scheduled run is missed (no successful run within that interval), Cogram emails the recipients configured at [Organization Settings → Integrations → LucidLink Backups](https://app.cogram.com/dashboard/settings/admin/integrations#backups). You get one email per outage, and it resets after the next successful run.

You do not need to call this endpoint yourself if you use the LucidLink connector; it reports its own runs.

| Method | Endpoint          | Description                              |
| ------ | ----------------- | ---------------------------------------- |
| `POST` | `/v1/backup-runs` | Report that a backup started or finished |

#### Request body: `POST /v1/backup-runs`

| Field                     | Type            | Description                                                                                       |
| ------------------------- | --------------- | ------------------------------------------------------------------------------------------------- |
| `event`                   | string          | `started` when a run begins, `finished` when it ends.                                             |
| `schedule_interval_hours` | integer         | How often the backup is scheduled to run, in hours (1–8760). Cogram measures health against this. |
| `status`                  | string \| null  | `success` or `failed`. Required on `finished`, and must be omitted on `started`.                  |
| `projects_synced`         | integer \| null | Optional. Projects filed by this run. Zero is valid: a run with no changed projects is healthy.   |
| `error`                   | string \| null  | Optional failure detail, up to 2000 characters. Only meaningful when `status` is `failed`.        |

#### Example

```bash
# A run begins
curl -X POST 'https://api.cogram.com/v1/backup-runs' \
  -H 'Authorization: Bearer <api-key>' \
  -H 'Content-Type: application/json' \
  -d '{"event": "started", "schedule_interval_hours": 24}'

# The same run finishes
curl -X POST 'https://api.cogram.com/v1/backup-runs' \
  -H 'Authorization: Bearer <api-key>' \
  -H 'Content-Type: application/json' \
  -d '{"event": "finished", "schedule_interval_hours": 24, "status": "success", "projects_synced": 12}'
```

> **Only successful runs count as healthy.** A `failed` run tells Cogram the connector is alive, but not that your data is backed up, so it does not clear a stale-backup alert.

> **Alerts are off until you add recipients.** Configure them under [Organization Settings → Integrations → LucidLink Backups](https://app.cogram.com/dashboard/settings/admin/integrations#backups). An empty recipient list turns alerting off.

### Action Items

Retrieve action items extracted from meetings within your organization.

| Method | Endpoint           | Description       |
| ------ | ------------------ | ----------------- |
| `GET`  | `/v1/action-items` | List action items |

#### Filtering and search

The list endpoint supports the following query parameters:

| Parameter          | Type    | Default      | Description                                                                  |
| ------------------ | ------- | ------------ | ---------------------------------------------------------------------------- |
| `page`             | integer | `1`          | Page number (1-indexed)                                                      |
| `page_size`        | integer | `50`         | Items per page (max 100)                                                     |
| `status`           | string  | —            | Filter by status: `SUGGESTED`, `ACCEPTED`, `REJECTED`, or `COMPLETED`        |
| `include_archived` | boolean | `false`      | Set to `true` to include archived items                                      |
| `project_id`       | string  | —            | Filter to items from meetings linked to this project                         |
| `meeting_id`       | string  | —            | Filter to items from a specific meeting                                      |
| `assignee`         | string  | —            | Case-insensitive partial match on assignee name                              |
| `q`                | string  | —            | Case-insensitive search in item description                                  |
| `sort_by`          | string  | `created_at` | Sort field: `created_at`, `due_date`, `status`, `assignee`, or `description` |
| `sort_dir`         | string  | `asc`        | Sort direction: `asc` or `desc`                                              |

> When filtering by `project_id`, action items from meetings not linked to any project are excluded. Unknown `project_id` or `meeting_id` values return an empty list, not a 404 error.

> Items with no value in nullable sort fields (`due_date`, `assignee`, `status`, `description`) are always placed at the bottom, regardless of sort direction.

#### Response fields

Each action item in the `data` array includes:

| Field          | Type             | Description                                         |
| -------------- | ---------------- | --------------------------------------------------- |
| `id`           | string           | Unique identifier                                   |
| `description`  | string \| null   | Action item text                                    |
| `assignee`     | string \| null   | Assigned person's name                              |
| `status`       | string \| null   | `SUGGESTED`, `ACCEPTED`, `REJECTED`, or `COMPLETED` |
| `origin`       | string \| null   | `AUTO` (AI-extracted) or `MANUAL` (user-created)    |
| `due_date`     | datetime \| null | Due date in ISO 8601 format                         |
| `completed_at` | datetime \| null | Timestamp when marked done                          |
| `archived`     | boolean          | Whether the item has been archived                  |
| `meeting_id`   | string           | ID of the meeting this item came from               |
| `meeting_name` | string \| null   | Name of the meeting                                 |
| `project_id`   | string \| null   | ID of the project the meeting belongs to, if any    |
| `created_at`   | datetime         | Creation timestamp in ISO 8601 format               |
| `updated_at`   | datetime \| null | Last update timestamp                               |

### Board Items

Retrieve kanban board items: action items that have been promoted to a project's board, either manually by users or automatically via meeting reconciliation. Read-only in v1.

| Method | Endpoint               | Description                    |
| ------ | ---------------------- | ------------------------------ |
| `GET`  | `/v1/board-items`      | List board items               |
| `GET`  | `/v1/board-items/{id}` | Get a single item with history |

#### Filtering and search

The list endpoint supports the following query parameters:

| Parameter            | Type    | Default      | Description                                                                                        |
| -------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------- |
| `page`               | integer | `1`          | Page number (1-indexed)                                                                            |
| `page_size`          | integer | `50`         | Items per page (max 100)                                                                           |
| `status`             | string  | —            | Filter by status: `TODO`, `IN_PROGRESS`, `BLOCKED`, `DONE`, or `DISMISSED`                         |
| `include_archived`   | boolean | `false`      | Set to `true` to include archived items                                                            |
| `project_id`         | string  | —            | Filter to items in this project                                                                    |
| `meeting_id`         | string  | —            | Filter to items originated from this meeting (matches `source_meeting_id`)                         |
| `meeting_series_uid` | string  | —            | Filter to items tied to a recurring meeting series                                                 |
| `assignee`           | string  | —            | Case-insensitive partial match on assignee name                                                    |
| `q`                  | string  | —            | Case-insensitive search across `title` and `body`                                                  |
| `sort_by`            | string  | `created_at` | Sort field: `created_at`, `updated_at`, `last_status_changed_at`, `status`, `title`, or `assignee` |
| `sort_dir`           | string  | `asc`        | Sort direction: `asc` or `desc`                                                                    |

> Unknown `project_id` or `meeting_id` values return an empty list, not a 404 error.

> Items with no value in nullable sort fields (`assignee`, `last_status_changed_at`) are always placed at the bottom, regardless of sort direction.

> The list endpoint excludes archived items by default. The get-by-id endpoint returns archived items.

#### Response fields

Each board item in the `data` array (and in the detail response) includes:

| Field                    | Type             | Description                                                          |
| ------------------------ | ---------------- | -------------------------------------------------------------------- |
| `id`                     | string           | Unique identifier (`pbi_...`)                                        |
| `project_id`             | string           | ID of the project this item belongs to                               |
| `project_name`           | string           | Name of the project, for convenience                                 |
| `title`                  | string           | Short item title                                                     |
| `body`                   | string \| null   | Optional long-form markdown description                              |
| `status`                 | string           | `TODO`, `IN_PROGRESS`, `BLOCKED`, `DONE`, or `DISMISSED`             |
| `priority`               | string \| null   | `URGENT`, `HIGH`, `NORMAL`, or `LOW`; `null` if unset                |
| `due_date`               | date \| null     | Calendar due date in ISO 8601 (`YYYY-MM-DD`); `null` if unset        |
| `assignee`               | string \| null   | Free-text assignee name (may differ from linked user's display name) |
| `assignee_user_id`       | UUID \| null     | Cogram user the item is assigned to, if matched                      |
| `source_meeting_id`      | string \| null   | Meeting this item was originated from, if any                        |
| `source_meeting_name`    | string \| null   | Name of the source meeting                                           |
| `meeting_series_uid`     | string \| null   | Identifier of the recurring meeting series, if applicable            |
| `archived`               | boolean          | Whether the item has been archived                                   |
| `created_at`             | datetime         | Creation timestamp in ISO 8601 format                                |
| `updated_at`             | datetime \| null | Last update timestamp                                                |
| `last_status_changed_at` | datetime \| null | Timestamp of the most recent history entry, or `null` if none        |

#### History (detail endpoint only)

`GET /v1/board-items/{id}` additionally returns a `history` array of timeline entries for the item, ordered oldest-first. Each entry represents a `BoardItemHistoryEntry` row, which may record a status transition, an assignee change, a priority shift, a due-date move, a comment, or any combination.

| Field                       | Type           | Description                                                                                                                 |
| --------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `id`                        | string         | Unique identifier (`bsc_...`)                                                                                               |
| `changed_at`                | datetime       | When the change was applied                                                                                                 |
| `changed_by_user_id`        | UUID \| null   | Cogram user who made the change; `null` for automated (LLM) changes                                                         |
| `changed_by_user_name`      | string \| null | Display name of the user, for convenience                                                                                   |
| `triggered_by_meeting_id`   | string \| null | Meeting that triggered this change, if any                                                                                  |
| `triggered_by_meeting_name` | string \| null | Name of the triggering meeting                                                                                              |
| `previous_status`           | string \| null | Status before the change (`null` on the initial creation entry, or on a non-status row)                                     |
| `new_status`                | string \| null | Status after the change. `null` on rows that aren't status transitions (assignee, priority, due-date, or pure-comment rows) |
| `previous_assignee`         | string \| null | Free-text assignee before the change                                                                                        |
| `new_assignee`              | string \| null | Free-text assignee after the change                                                                                         |
| `previous_assignee_user_id` | UUID \| null   | Linked user before the change                                                                                               |
| `new_assignee_user_id`      | UUID \| null   | Linked user after the change                                                                                                |
| `previous_priority`         | string \| null | Priority before the change (`URGENT`, `HIGH`, `NORMAL`, `LOW`, or `null`)                                                   |
| `new_priority`              | string \| null | Priority after the change                                                                                                   |
| `previous_due_date`         | date \| null   | Due date (ISO `YYYY-MM-DD`) before the change                                                                               |
| `new_due_date`              | date \| null   | Due date after the change                                                                                                   |
| `reason`                    | string \| null | LLM-authored justification (only set on reconciliation-triggered rows)                                                      |
| `comment`                   | string \| null | User-authored comment, if any                                                                                               |

To distinguish entry types from a single row:

* **Status transition**: `previous_status != new_status`
* **Assignee change**: `new_assignee != previous_assignee` (or the `*_user_id` equivalents)
* **Priority change**: `new_priority != previous_priority`
* **Due-date change**: `new_due_date != previous_due_date`
* **Comment**: `comment` is set and there is no other delta
* **Automated change**: `changed_by_user_id` is `null` and `triggered_by_meeting_id` is set

### Directory

Cogram builds a company for a firm and a contact for each person you correspond with. A contact only gets its company automatically when a transmittal goes out. A contact added in the directory, imported from a file, or created by inbound email keeps an empty company, even when your directory already holds the firm that owns the email domain.

These two endpoints close that gap. `GET` proposes a company for each such contact, and `POST` writes the proposals you accept.

| Method | Endpoint                              | Description                             |
| ------ | ------------------------------------- | --------------------------------------- |
| `GET`  | `/v1/directory/contact-company-links` | List proposed contact-to-company links  |
| `POST` | `/v1/directory/contact-company-links` | Apply proposed contact-to-company links |

`GET` returns the standard paginated envelope and writes nothing. A contact appears only when it has no company and a company in your directory lists the domain of its primary email. Archived contacts are not listed, and an archived company proposes nothing.

#### Response fields: `GET /v1/directory/contact-company-links`

| Field           | Type           | Description                                                                                       |
| --------------- | -------------- | ------------------------------------------------------------------------------------------------- |
| `contact_id`    | string         | The contact that has no company (`ctc_...`)                                                       |
| `primary_email` | string         | The contact's primary email address                                                               |
| `first_name`    | string \| null | Given name, if the contact has one                                                                |
| `last_name`     | string \| null | Family name, if the contact has one                                                               |
| `domain`        | string         | Domain of `primary_email`, lowercased                                                             |
| `company`       | object         | The company `POST` would link: `{ "id": "cmp_...", "name": "..." }`                               |
| `also_claiming` | array          | Other companies that list `domain`. Usually empty. When it is not, two companies carry one domain |

The proposal uses the oldest company that lists the domain, which is the same company a transmittal send would pick. Only the primary email decides. An address in a contact's additional emails is often a personal mailbox, so Cogram does not read a firm from it.

#### Request body: `POST /v1/directory/contact-company-links`

| Field         | Type                   | Description                                                                                                         |
| ------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `contact_ids` | array\[string] \| null | Optional. Apply only the proposals for these contacts. Omit to apply all. Unknown or already-linked ids are ignored |

#### Response fields: `POST /v1/directory/contact-company-links`

| Field     | Type    | Description                                                                                           |
| --------- | ------- | ----------------------------------------------------------------------------------------------------- |
| `applied` | array   | Every link the call wrote, as `{ "contact_id": "ctc_...", "company_id": "cmp_..." }`                  |
| `linked`  | integer | How many contacts were given a company                                                                |
| `skipped` | integer | Proposals the call did not write, because the contact gained a company between the read and the write |

Keep the `applied` list. It names the contacts that had no company before the call, so it is what you reverse a wrong run from — nothing else records that.

Read the proposal before you apply it. If a company in your directory lists a consumer domain such as `gmail.com`, every personal address in your organization is proposed for that company, and Cogram cannot tell that apart from a real firm. A contact that already has a company is never changed, so an assignment made by a person always wins.

```bash
# 1) Read the proposals
curl "https://api.cogram.com/v1/directory/contact-company-links?page=1&page_size=100" \
  -H "Authorization: Bearer YOUR_API_KEY"

# 2) Apply all of them
curl -X POST "https://api.cogram.com/v1/directory/contact-company-links" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

# 2b) Or apply only the contacts you reviewed
curl -X POST "https://api.cogram.com/v1/directory/contact-company-links" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contact_ids": ["ctc_...", "ctc_..."]}'
```

## Rate Limiting

API requests are rate limited on a per-key basis. The default limit is **1000 requests per minute**.

When you exceed the rate limit, the API returns a `429 Too Many Requests` response with a `Retry-After` header indicating when you can retry.

## Error Handling

The API uses standard HTTP status codes and returns errors in a consistent JSON format:

```json
{
  "error": "error_code",
  "message": "Human-readable description"
}
```

### Common Error Codes

| HTTP Status | Error Code                | Description                                                        |
| ----------- | ------------------------- | ------------------------------------------------------------------ |
| 400         | `range_too_large`         | Org-wide Data Export exceeded the 6-month cap (or used `all_time`) |
| 401         | `api_key_missing`         | No API key provided                                                |
| 401         | `api_key_invalid`         | API key not found or incorrect                                     |
| 401         | `api_key_expired`         | API key has expired                                                |
| 404         | `not_found`               | Resource does not exist                                            |
| 409         | `conflict`                | Resource already exists                                            |
| 409         | `data_export_in_progress` | Org already has an active Data Export; poll its id and retry       |
| 422         | `validation_error`        | Invalid request body                                               |
| 429         | `rate_limit_exceeded`     | Too many requests                                                  |

This is not an exhaustive list. For all possible error responses per each endpoint and all error codes, see the interactive documentation at [api.cogram.com/v1/docs](https://api.cogram.com/v1/docs).

## Pagination

List endpoints support pagination using query parameters:

| Parameter   | Default | Max | Description              |
| ----------- | ------- | --- | ------------------------ |
| `page`      | 1       | -   | Page number (1-indexed)  |
| `page_size` | 50      | 100 | Number of items per page |

Example:

```bash
curl "https://api.cogram.com/v1/projects?page=2&page_size=25" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Paginated response format:

```json
{
  "data": [...],
  "total": 150,
  "page": 2,
  "page_size": 25
}
```

## API Reference

For detailed endpoint documentation, request/response schemas, error codes and an interactive API explorer, visit:

[**api.cogram.com/v1/docs**](https://api.cogram.com/v1/docs)

## Security Best Practices

* **Keep your API keys secret** - Never expose them in client-side code or public repositories
* **Use environment variables** - Store API keys in environment variables, not in code
* **Rotate keys regularly** - Create new keys and revoke old ones periodically
* **Use descriptive names** - Name your keys by their purpose to track usage
* **Revoke unused keys** - Delete keys that are no longer needed

## Support

Questions or issues with the API? Email <support@cogram.com> or use the in-app Help menu.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.cogram.com/integrations/cogram-api.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
