> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.coperniq.io/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.coperniq.io/_mcp/server.

# Release Notes

Welcome to the Coperniq API release notes. This section highlights noteworthy changes across endpoints, schemas, and docs.

### Recent highlights

* New: **Pinned notes** with `isPinned` on note create and `PATCH /notes/{note_id}`. [Details](/release-notes/2026/11/13)
* Updated: **`POST /users`** invites with a seat (`seatType`, now required) and labor cost, and `GET /users` returns both. [Details](/release-notes/2026/11/11)
* Updated: **Work orders** can be labelled with `labels` on `PATCH`. [Details](/release-notes/2026/11/10)
* Updated: **Notes lists** return `replyToId` on replies, and `PATCH /notes/{note_id}` returns the edited note. [Details](/release-notes/2026/11/9)
* New: **Notes on files** with `GET` and `POST /files/{file_id}/notes`, including @mentions. [Details](/release-notes/2026/11/9)
* Docs: **API keys for Google sign-in users**. `POST /api-keys` needs a password; set one with Forgot password on the login page. SSO sign-in still works. [Details](/release-notes/2026/11/6)
* Updated: **`GET /users`** can include deactivated users with `include_inactive=true`, and returns `isActive`. [Details](/release-notes/2026/11/5)
* Updated: **Work orders** now return `createdBy`. [Details](/release-notes/2026/11/4)
* New: **`GET /forms`** lists your company's forms across all records, with `updated_after` and other filters. [Details](/release-notes/2026/11/4)
* Docs: **Work-order `collaborators`** on `PATCH` replaces the full list. [Details](/release-notes/2026/11/4)
* New: **Sub-accounts** with `parentAccountId` on accounts. [Details](/release-notes/2026/11/3)
* Updated: **Project and opportunity lists** return the project manager, owner and sales rep, plus phases with `include_phases=true`. [Details](/release-notes/2026/10/30)
* New: **`internalMemo`** on invoices and bills. [Details](/release-notes/2026/10/30)
* Updated: **Archived work orders** can be listed with `include_archived=true` and fetched by ID. [Details](/release-notes/2026/10/30)
* Updated: **Work order visits** now return their `description`. [Details](/release-notes/2026/10/30)
* Updated: **Viewer seats** can no longer create API keys. [Details](/release-notes/2026/10/20)
* Updated: **Calls** have custom reasons and dispositions, AI summaries and transcripts, and a company-wide `GET /calls`. [Details](/release-notes/2026/10/8)
* New: **API key management** for superadmins. [Details](/release-notes/2026/10/8)

> Looking for a specific date? See the entries below.

## November 13, 2026

## Pin notes

### New: pin and unpin notes

In Coperniq, a pinned note stays at the top of a record's notes. You can now pin and unpin notes through the API too.

Pin or unpin an existing note with `isPinned` on `PATCH /notes/{note_id}`:

```json
{ "isPinned": true }
```

* `true` pins the note and `false` unpins it. Pinning a note that is already pinned does nothing and returns `200`.
* `body` is now optional on this endpoint. Send `body`, `isPinned`, or both. A request with neither returns `400`.
* Pinning and unpinning only need access to view the note. Editing the text still requires you to be its author.

To pin a note as you create it, send `"isPinned": true` on any note create endpoint, for example `POST /projects/{project_id}/notes`. It defaults to `false`.

### Updated: note responses return `isPinned` and `pinnedAt`

Every note response now includes:

* `isPinned`: whether the note is pinned.
* `pinnedAt`: when it was pinned, or `null` if it isn't.

This covers the note lists (project, opportunity, account, work order and file notes) and the responses from creating and updating a note.

Lists stay newest-first, so pinning does not reorder them or shift pages. To show pinned notes on top, sort by `isPinned` and then `pinnedAt` in your app.

## November 11, 2026

## Seats and labor cost on user invites

### Updated: `POST /users` takes a seat and a labor cost

Invite User now matches **Invite Members** in Coperniq. Along with name, email, role and phone, you can set the member's seat and labor cost.

```json
{
  "email": "anna.smith@example.com",
  "firstName": "Anna",
  "lastName": "Smith",
  "seatType": "FIELD",
  "roleIds": [3],
  "phone": "(208) 555-0112",
  "laborHourlyRate": 40
}
```

* `seatType` is `FIELD` (Field), `OFFICE` (Office) or `READONLY` (Viewer).
* Field and Office seats must be on your plan, with one free. The API doesn't buy seats. If none are free, the request returns `400`, for example `No available Field seats`. Buy more seats in Coperniq, then try again.
* `laborHourlyRate` is optional, in dollars per hour.
* `roleIds` is required unless `seatType` is `READONLY`. A Viewer seat only takes roles whose `roleType` is `VIEW_AND_COMMENT`. Leave `roleIds` out to use the default Viewer role.
* **Behavior change:** `seatType` is now required. Requests without it return `400`.
* **Behavior change:** an invite that Coperniq couldn't complete now returns `400` with the reason. It used to return `201` with `"status": "failed"`.
* Inviting someone who is already an active member still returns `201` with `"status": "exists"`, and nothing changes.

### Updated: `GET /users` returns `seatType` and `laborHourlyRate`

Each user now includes `seatType` and `laborHourlyRate`. Each is `null` when not set.

### Updated: `GET /roles` returns `roleType`

Each role includes `roleType`, either `REGULAR` or `VIEW_AND_COMMENT`, so you can pick roles for Viewer seats.

## November 10, 2026

## Label work orders

### Updated: `PATCH` a work order with `labels`

You can now set a work order's labels through the API, the same way you can in Coperniq. This works on the project, opportunity, request, account and client work-order `PATCH` endpoints, for example `PATCH /projects/{project_id}/work-orders/{work_order_id}`.

```json
{ "labels": [12, 34] }
```

* `labels` is an array of label IDs and **replaces** the work order's full label set. Include every label that should stay.
* The order is kept. The first label is the primary (display) label.
* Send `[]` to remove all labels. Leave `labels` out to keep them unchanged.
* Send up to 20 unique IDs.

Every ID must be an existing `WORK`-type label in your company. If one isn't, the request returns `400` with `field: "labels"` and lists the invalid IDs, and the work order is not changed. Use `GET /labels?type=WORK` to find IDs, and `POST /labels` to create a label.

Labels are not accepted when you create a work order. A new work order gets the labels of its template, and you can `PATCH` it afterwards.

## November 9, 2026

## Notes on files

### New: `GET` and `POST /files/{file_id}/notes`

You can now read and add notes (comments) on files, the same way you can on projects and work orders.

* `GET /files/{file_id}/notes` lists a file's notes, newest first, with `page` and `page_size` (max 100).
* `POST /files/{file_id}/notes` adds a note. Send `body`, and optionally `replyToId` to reply to a note on the same file, or `fileIds` to attach files.

To @mention someone, put a token in `body`, for example `Please review [Nicolaus Copernicus|~id:99001]`. Mentioned users get a notification, as they do for notes made in Coperniq.

Edit or delete a file note with `PATCH` and `DELETE /notes/{note_id}`, like any other note. A file that isn't in your company returns `404`.

## Notes: replies in lists, pagination, and edit responses

### Updated: notes lists return `replyToId`

Every notes list now returns `replyToId` on notes that are replies, so you can rebuild a thread. It is the ID of the note being replied to, and it is left out on top-level notes. This applies to the notes lists for projects, opportunities, accounts, work orders and files.

### Docs: `page` and `page_size` on notes lists

The project, opportunity and account notes lists accept `page` and `page_size` (default 20, max 100), like the work order and file notes lists. They always worked. They are now documented.

### Fixed: `PATCH /notes/{note_id}` response

Editing a note returned `{ "note": "" }` even though the edit was saved. It now returns the edited note with its `id`, `createdAt`, `updatedAt`, and `replyToId` for replies.

## November 6, 2026

## API keys for Google sign-in users

### Docs: creating an API key if you sign in with Google

`POST /api-keys` needs your Coperniq email and password. If you sign in to Coperniq with Google, your account has no password yet, so the request returns `401`.

To set one:

1. On the Coperniq login page, click **Forgot password**.
2. Enter the email you sign in with and open the reset link we send you.
3. Choose a password.

Then call `POST /api-keys` with that email and password. Google sign-in keeps working as before.

## November 5, 2026

## Deactivated users on `GET /users`

### Updated: `GET /users` can include deactivated users

Records can still point to people who have since been deactivated, as `owner`, `salesRep`, `projectManager`, `createdBy`, `assignee` and so on. Add `include_inactive=true` to `GET /users` to return deactivated members alongside active ones, so you can resolve those ids to a name and email.

* `include_inactive` is `false` by default, so existing calls return the same members as before.
* Every user now includes `isActive`. It's always `true` unless you pass `include_inactive=true`.
* Lookups by `id` or `email` honor `include_inactive`. A deactivated user is only found when you pass it.
* A value other than `true` or `false`, or a non-integer `id`, returns `400`. Empty values (`?id=`) are ignored.
* **Behavior change:** a lookup by `id` or `email` that matches no user now returns `404` instead of `200` with an empty body. If your integration treats a missing user as a successful response, update its status handling.
* **Behavior change:** a lookup by `id` or `email` now returns the same fields as the list: `id`, `firstName`, `lastName`, `email`, `phone`, `role` and `isActive`. The internal `companyUsersByUserId` field is no longer returned; use `role` instead.

```json
{ "id": 123, "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com", "isActive": false }
```

## November 4, 2026

## List forms across your company

### New: `GET /forms`

Lists your company's forms across every project and opportunity, including forms on account work orders, paginated. Use it to sync forms incrementally instead of listing them one record at a time.

**Query parameters**

| Parameter                         | Description                                                                          |
| --------------------------------- | ------------------------------------------------------------------------------------ |
| `page`, `page_size`               | Pagination. `page_size` defaults to 20, max 100.                                     |
| `updated_after`, `updated_before` | ISO 8601 date or date-time.                                                          |
| `project_id`                      | Forms on this project.                                                               |
| `opportunity_id`                  | Forms on this opportunity.                                                           |
| `account_id`                      | Forms on this account's work orders. Forms can't be added to an account directly.    |
| `work_order_id`                   | Forms attached to this work order.                                                   |
| `template_id`                     | Forms created from this template.                                                    |
| `status`                          | `UNASSIGNED`, `ASSIGNED`, `REVIEW`, `CHANGES_REQUESTED`, `COMPLETED` or `CANCELLED`. |
| `include_archived`                | `true` to include archived forms. Default `false`.                                   |
| `order_by`                        | `updated_at` (default) or `created_at`. Ties are broken by id.                       |
| `order`                           | `asc` (default) or `desc`.                                                           |

Send only one of `project_id`, `opportunity_id` or `account_id`; more than one returns `400`.

**Each form returns** `id`, `name`, `templateId`, `projectId`, `opportunityId` or `accountId` (whichever record the form is on; the other two are `null`), `workOrderId` (`null` if it isn't on a work order), `status`, `isCompleted`, `completedAt`, `isArchived`, `createdAt` and `updatedAt`. Field values are not included; fetch them with `GET /forms/{form_id}`.

> **Note:** Archiving a form doesn't update its `updatedAt`, so `updated_after` doesn't pick up archive changes.

### Docs: work-order `collaborators` replaces the full list

On `PATCH` work orders, `collaborators` sets the full collaborator list. Include everyone who should remain, send `[]` to remove all collaborators, or omit the field to leave them unchanged. The behavior hasn't changed; the docs now say so.

### Updated: work orders return `createdBy`

Work orders now include a read-only `createdBy` field showing who created them, the same person the Coperniq UI shows as "Created by".

```json
"createdBy": { "id": 123, "firstName": "Jane", "lastName": "Doe" }
```

* Returned by `GET /work-orders`, `GET /work-orders/{id}`, and the work-order lists and gets under projects, opportunities, requests, accounts and clients.
* `null` when the creator is unknown.
* Update responses return the same shape as a `GET`. Create responses return `{ "id": 123 }` only.
* It's the equivalent of `createdByUser` on forms.

## November 3, 2026

## Sub-accounts with `parentAccountId`

### New: `parentAccountId` on accounts

Accounts can now be read and written as sub-accounts of another account.

| Field             | Type              | Description                                               |
| ----------------- | ----------------- | --------------------------------------------------------- |
| `parentAccountId` | integer, nullable | ID of the parent account. `null` for a top-level account. |

* **Read:** returned on `GET /accounts`, `GET /accounts/{account_id}`, `GET /accounts/search`, and the `POST`/`PATCH` responses.
* **Write:** accepted on `POST /accounts` and `PATCH /accounts/{account_id}`. Send `null` on `PATCH` to unlink an account from its parent.
* **Rules:** the parent must be an account in your company and must itself be top-level (1 level of nesting). An account that already has sub-accounts cannot become one, and an account cannot be its own parent. Breaking a rule returns `400` with `field: "parentAccountId"`.

```json
PATCH /accounts/2048
{
  "parentAccountId": 1024
}
```

### Changed: `GET /accounts/search` includes sub-accounts

Search used to leave sub-accounts out of its results, while `GET /accounts` returned them, so the two disagreed. Search now returns sub-accounts like any other account.

* To find the sub-accounts of an account: `?prop1=parentAccountId&op1=eq&value1=1024`.
* To keep only top-level accounts, drop results whose `parentAccountId` is not `null`.
* Search with two properties and `logic=and` now also keeps `updated_after`, `q`, `title` and the other list filters you pass. Before, the two-property clause could replace them.

### Updated: `GET /properties?record_type=account`

The built-in parent field is now listed as `parentAccountId` for accounts (it was listed as `accountId`).

## October 30, 2026

## Project and opportunity lists return more of what the single-record GET returns

### Updated: `GET /projects`, `GET /projects/search`, `GET /opportunities`, `GET /opportunities/search`

List and search responses now include the project manager and owner, sales rep and project manager ids, so a bulk sync no longer needs a follow-up `GET` per record.

* **New fields on every row:** `ownerId`, `salesRepId`, `projectManagerId`, and `projectManager` (`null` when unset).

### New: `include_phases=true`

Add `include_phases=true` to also return the phase history, in the same shape as the single-record GET.

* `phaseInstances[]` with each phase's `status`, `startedAt` and `completedAt`, ordered by position.
* `startedAt` and `completedAt` on the current phase (`phase`).
* It adds two relation reads per row, so use a `page_size` of 25 or less when it is on. It defaults to `false`, and responses without it are unchanged apart from the new fields above.

```
GET /projects?include_phases=true&page_size=25
```

### Updated: `updatedBy` is single-record only

`updatedBy` is returned only by `GET /projects/{project_id}` (and `GET /opportunities/{opportunity_id}`). List and search responses do not include it; reading it for every row made large pages slow. If you were passing the undocumented `include_updated_by` parameter, it is still accepted and now has no effect.

**Incremental sync change:** List and search no longer use the latest custom-property edit to advance `updatedAt`. A custom-property-only edit can still match `updated_after` while returning an older persisted `updatedAt`. If you previously passed `include_updated_by=true`, don't rely on `max(updatedAt)` from list or search responses as your custom-property sync watermark. The single-record GET still applies the feed-based timestamp adjustment.

## Archived work orders: list with `include_archived`, fetch by ID

### Updated: `GET /work-orders`, `GET /projects/{project_id}/work-orders`, `GET /opportunities/{opportunity_id}/work-orders`, `GET /accounts/{account_id}/work-orders`

These list endpoints now support an `include_archived` query parameter.

```
GET /work-orders?include_archived=true
GET /projects/{project_id}/work-orders?include_archived=true
```

* By default only active work orders are returned, so existing behavior is unchanged.
* `include_archived=true` also returns archived work orders. The value is case-insensitive (`TRUE` works); any other value is treated as `false`.
* Archived work orders have `isActive: false`.
* Work orders whose parent project, opportunity, or account is archived are never returned, even with `include_archived=true`. **This is a change for `GET /work-orders`**, which previously returned active work orders under archived projects, opportunities, and accounts.
* The `/requests/{request_id}/work-orders` and `/clients/{client_id}/work-orders` aliases behave the same as their `/opportunities` and `/accounts` counterparts.

### Updated: `GET /work-orders/{work_order_id}`

Archived work orders can now be fetched by ID instead of returning not found. Check `isActive`, which is `false` for an archived work order.

## `internalMemo` on invoices and bills

### Updated: invoices and bills

Invoices and bills now read and write the internal memo shown in the Coperniq app.

* **New field `internalMemo`** (string, `null` when empty) on invoice and bill responses: `GET`, list, and by-record calls, plus the create and update responses.
* **Accepted on create and update** for `POST`/`PATCH` `/invoices` and `/bills`.
* The memo is internal to your company. It is never shown to customers, on the PDF, or in the customer portal.
* **The memo is rich text** (HTML, as the app's editor stores it, for example `<p>Call first</p>`).
* **Clear a memo** by sending `null` or `""` on update. Responses return `null` when the memo is empty.
* An invoice created in the app from a quote, or a bill created from a purchase order, inherits the source's memo.

```
PATCH /invoices/{invoice_id}
{ "internalMemo": "Customer approved the revised scope by phone" }
```

## Work order visits now return their description

### Updated: `GET /work-orders/{work_order_id}`, `GET /work-orders`, `GET /projects/{project_id}/work-orders`

Each entry in `visits` now includes `description`, the text you set with `visits[].description` on `PATCH /projects/{project_id}/work-orders/{work_order_id}`. It is `null` when a visit has no description. Before, the description was saved but never returned, so integrations couldn't read back the tags they wrote.

### Updated: `404` for unknown routes

A request to a path or method the API doesn't serve now returns `Route {METHOD} {path} not found` instead of a bare `Not Found`, so it's no longer mistaken for a missing record.

## October 20, 2026

## Viewer seats can no longer create API keys

### Updated: `POST /api-keys`

Users with a Viewer (view-only) seat in the selected company now get a `403` from `POST /api-keys` and its legacy alias `GET /token`, and no key is created. Office and Field seats are unaffected.

* The seat checked is the one in the company the key is for: the `company_id` you pass, or your default company. A user who is a Viewer in one company and an Office user in another can still create keys for the second company.
* Keys that Viewer users created before this change keep working. A superadmin can find them with `GET /api-keys` and delete them with `DELETE /api-keys/{key_id}`.

## October 8, 2026

## API key management for superadmins, and calls updates

### New: List and delete API keys, and archive API users

Superadmins can now manage their company's API keys through the API. These endpoints authenticate with Basic Auth (your Coperniq email and password), the same as `POST /api-keys`, not with an API key. Each one takes an optional `company_id` for logins that belong to more than one company.

| Endpoint                            | What it does                                                                                                                                                  |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api-keys`                     | Lists the company's API keys.                                                                                                                                 |
| `DELETE /api-keys/{key_id}`         | Deletes an API key. It stops working immediately.                                                                                                             |
| `GET /api-users`                    | Lists the company's API users, the service accounts API keys are issued to, with `lastSeenAt`, when each one last made a request (accurate to about an hour). |
| `POST /api-users/{user_id}/archive` | Deletes all of an API user's keys, then deactivates the API user.                                                                                             |

* Key values are never returned. Each key's `id` is a non-secret identifier (`key_…`) used to delete it, and `keyPreview` shows the key's last 4 characters so you can recognize it. If you've lost a key, create a new one with `POST /api-keys`, or contact support.
* To retire an API user, use `POST /api-users/{user_id}/archive` instead of archiving it in the Coperniq UI. Archiving it in the UI doesn't delete its keys.
* Archiving doesn't block future keys: if the person who created the API user calls `POST /api-keys` again, the API user is reactivated with a new key.
* These endpoints are for superadmins only. Other users get a `403`.

## Calls: custom reasons and dispositions, AI summary and transcript

### Updated: `reason` and `disposition` are company-defined

A call's `reason` and `disposition` now hold the name of one of your company's call reasons or dispositions, set up in Coperniq. They're plain strings of up to 255 characters instead of a fixed list.

* Calls logged before custom options existed keep their legacy uppercase values, such as `SERVICE` or `INFO_PROVIDED`. Newer calls hold the option's name, such as `Service` or `Info provided`.
* `outcome` can now also be `VOICEMAIL`.

### New: `GET /calls`

Lists calls across your whole company, paginated with `page` and `page_size` (max 100). Filter by `updated_after`/`updated_before`, `outcome`, `reason` or `disposition`, and sort by creation time with `order_by` (newest first by default). Each call includes `projectId`, `opportunityId` or `accountId` for the record it's logged on.

### New: `GET /calls/classification-options`

Lists your company's call reasons and dispositions. Pass `type=REASON` or `type=DISPOSITION` to get one kind.

### New call fields

`GET` and `POST` call responses now include:

| Field                | What it holds                                                                      |
| -------------------- | ---------------------------------------------------------------------------------- |
| `summary`            | The AI-generated summary of the call.                                              |
| `transcriptText`     | The AI transcript as plain text, one sentence per line.                            |
| `classificationNote` | For calls classified by Coperniq AI, why it chose the reason and disposition.      |
| `resolution`         | For calls handled by a Coperniq AI agent: `RESOLVED`, `ESCALATED` or `UNRESOLVED`. |

`note` is the human-editable note. The per-record call lists (`GET /projects/{project_id}/calls` and the opportunity and account equivalents) now return calls newest first, and return `502` if Coperniq's backend errors.

## September 30, 2026

## Opportunity `status` on read endpoints, now `WON`/`LOST`

### Fixed: `status` is now returned when reading opportunities

`GET /opportunities/{opportunity_id}`, `GET /opportunities`, and `GET /opportunities/search` now include the opportunity's `status`. Create and update already accepted it, but these read endpoints never returned it. The legacy `/requests` aliases return it too.

### Changed: closed opportunities are `WON` or `LOST`

Opportunity `status` uses `WON` and `LOST` for closed opportunities:

| Value     | Meaning |
| --------- | ------- |
| `ACTIVE`  | Open    |
| `ON_HOLD` | On hold |
| `WON`     | Won     |
| `LOST`    | Lost    |

* Every opportunity response uses these values, including the response from `POST`/`PATCH /opportunities`.
* `POST`/`PATCH /opportunities` and status search filters (e.g. `?prop1=status&op1=eq&value1=WON`) take the same values.
* Project `status` is unchanged.

### Changed: `status` search filters only accept exact-match operators

On `GET /opportunities/search` and `GET /projects/search`, a `status` filter now supports only `eq`, `neq`, `in`, and `nin`. Any other operator (e.g. `contains`, `gt`, `between`) returns a `400 VALIDATION_ERROR` instead of a `200` with no results.

## September 11, 2026

## Catalog item units, measurements, and categories

### New: Units, measurements, and quantity-calculation fields on catalog items

Catalog items now expose the same unit and measurement-driven quantity calculation available in the Coperniq UI.

`GET`/`POST`/`PATCH /catalog-items` responses and request bodies gain:

| Field                                                  | Type             | Description                                                                      |
| ------------------------------------------------------ | ---------------- | -------------------------------------------------------------------------------- |
| `unit` (response) / `unitId` (request)                 | object / integer | The unit this item is sold or measured in.                                       |
| `measurements` (response) / `measurementIds` (request) | array            | Measurements (or a single sub-measurement) linked to drive this item's quantity. |
| `coverage`                                             | number           | Measurement units covered per item unit (e.g. a bundle covering \~33.33 SF).     |
| `wasteFactorMode` / `wasteFactorValue`                 | string / number  | Waste factor applied when computing quantity. Set together, or not at all.       |

```json
POST /catalog-items
{
  "name": "Architectural Shingle",
  "type": "PRODUCT",
  "category": "ROOF_COVERING",
  "tradeGroup": "ENVELOPE",
  "cost": 45,
  "price": 90,
  "unitId": 1924,
  "measurementIds": [2727],
  "coverage": 33.33,
  "wasteFactorMode": "STATIC",
  "wasteFactorValue": 10
}
```

#### Notes

* `measurementIds` is **replace-all** on `PATCH`, not a merge: omit it to leave existing links unchanged, or pass `[]` to clear all links. There's no way to append a single measurement without resending the full set.
* A linked measurement can be a plain measurement (one or more, summed — all must share a unit or be unitless) or a single sub-calculation ("sub-measurement"), never both. Linking a sub-calculation alongside anything else returns `400`.

### New: List and get catalog units

| Method | Path                               | Description                                         |
| ------ | ---------------------------------- | --------------------------------------------------- |
| `GET`  | `/catalog-units`                   | List the units of measure available to your company |
| `GET`  | `/catalog-units/{catalog_unit_id}` | Get a specific unit by ID                           |

Use these IDs when setting `unitId` on a catalog item. Every company starts with a seeded default set (Each, Square Feet, Linear Feet, etc.); `isCustom` distinguishes those from any additional units a company has added.

### New: List and get measurements

| Method | Path                             | Description                                                      |
| ------ | -------------------------------- | ---------------------------------------------------------------- |
| `GET`  | `/measurements`                  | List measurements and sub-calculations available to your company |
| `GET`  | `/measurements/{measurement_id}` | Get a specific measurement by ID                                 |

Filter with `?type=MEASUREMENT` or `?type=SUB_CALCULATION`. Use these IDs when setting `measurementIds` on a catalog item. A sub-calculation's read-only `formula` shows what it computes from, but formulas can only be authored in the Coperniq UI.

### New: List catalog categories

`GET /catalog-categories` returns the valid `category` codes for catalog items, grouped by `PRODUCT`/`SERVICE`, plus the available `tradeGroup` values — everything needed to populate `category` and `tradeGroup` on `POST`/`PATCH /catalog-items`.

### Changed: `tradeGroup` is derived from `category` when omitted

`POST /catalog-items` previously stored `tradeGroup: "OTHER"` whenever the field was omitted, even for a product category that clearly belongs to another group. It is now derived from the category — `ROOF_COVERING` resolves to `ENVELOPE`, `PV_MODULE` to `ENERGY`, and so on. Services and category `OTHER` still resolve to `OTHER`. Pass `tradeGroup` explicitly to keep full control.

### Changed: `PATCH /catalog-items/{catalog_item_id}` validates the resulting item, not just the patch

A partial update is now merged over the stored item and the result checked before the write, so a patch can no longer leave an item in a state `POST` would have rejected — for example setting `category` to a code outside the item's stored `tradeGroup`, or adding an `sku` to an item that is already a `SERVICE`. These return `400` with the offending `field`. Inconsistencies that already existed on the stored item are left alone, so unrelated edits to older items keep working.

Converting a `PRODUCT` that has an `sku` into a `SERVICE` is still allowed and does not require clearing the `sku` first — services have no `sku`, so it is dropped as part of the conversion. Sending `sku: null` during that conversion is accepted and has the same effect; for an item already stored as a `SERVICE`, a legacy `sku` cannot be cleared or changed by this API and the request returns `400`.

## August 31, 2026

## Payment updates and deletes

### New: Update and delete a payment by its own id

You can now edit or remove a recorded payment directly — these are the first payment endpoints that aren't scoped under an invoice or bill.

| Method   | Path                     | Description                                         |
| -------- | ------------------------ | --------------------------------------------------- |
| `PATCH`  | `/payments/{payment_id}` | Update a payment (`PUT` also accepted, as an alias) |
| `DELETE` | `/payments/{payment_id}` | Delete a payment                                    |

#### Update a payment

```
PATCH /payments/{payment_id}
```

```json
{
  "amount": 150,
  "notes": "Corrected amount after customer call"
}
```

All fields are optional, but at least one is required: `amount`, `paymentMethod`, `paymentDate`, `notes`, `paymentReference` — the same set accepted on create. `status` is not exposed here, matching the Coperniq UI's edit form.

#### Notes

* If the payment is **external** (processed by Stripe), `PATCH` only accepts `notes` — any other field returns `400`. `DELETE` is rejected outright for external payments; use a refund instead.
* Changing `amount` can return `400` if the new total would exceed the parent invoice's or bill's outstanding balance.
* Updating or deleting a payment recalculates the parent invoice's/bill's `amountPaid` and status automatically (e.g. back to `PARTIALLY_PAID` or `SENT` after a delete).
* Responses use the same `Payment`/`BillPayment` shape as the existing list/get endpoints — `billNumber` for a bill payment, `invoiceNumber` for an invoice payment.

## Choose the company for a new API key

`POST /api-keys` (and its legacy alias `GET /token`) now accepts an optional `company_id` query parameter, for accounts where the same login belongs to more than one company. Your company ID can be found in the URL after logging in to the Coperniq UI: `https://app.coperniq.io/{company_id}/inbox`.

```
POST /api-keys?company_id=123
```

* If you belong to more than one company and pass `company_id`, the new key is created against that company. You must belong to it, or the request is rejected.
* If `company_id` is omitted, the company is now chosen deterministically: your first non-collaborator company, falling back to your first company. Previously this could disagree with which company the key actually got created against, occasionally surfacing as a `Failed to fetch token` error even though a key had been created — just against an unexpected company.
* Single-company accounts are unaffected either way.

## August 24, 2026

## File properties and search field fixes

#### Notes

* A **recognised** public field name that doesn't apply to the record type you're searching (for
  example `value` on an account, or `trades` on a vendor) now returns **`400`** naming the field,
  instead of an empty `200`. If you were relying on an empty result for one of these, you'll now get
  an explicit error — which is the point: an empty list was indistinguishable from "no matches".
* A field name the API does **not** recognise as a standard field is still treated as a possible
  custom property, unchanged. An empty result there still means "no records matched" — we can't tell
  a typo from a real custom property name without knowing your company's columns, so only fields we
  can prove don't apply are rejected.
* `trades` holds a list of values, so a scalar comparison matches if any entry equals your value.
  Range and substring operators are rejected with a `400` rather than silently matching nothing.

## August 18, 2026

## Folders & Folder Contents

Added CRUD for project folders.

* `GET /projects/{project_id}/folders` — list root-level folders (not nested in another folder), plus workflow phase rows. Pass `include_archived=true` to include archived folders (default `false`).
* `GET /opportunities/{opportunity_id}/folders` — same functionality as list project folders.
* `POST /projects/{project_id}/folders` - create folder.
* `POST /opportunities/{opportunity_id}/folders` - create folder.
* `GET /folders/{folder_id}` — get by id.
* `PATCH /folders/{folder_id}` — rename, archive/unarchive (`isActive`), or move (set `parentId`/`phaseInstanceId`) — moving isn't a separate operation.
* `DELETE /folders/{folder_id}` — delete (also removes any subfolders and their files).
* `GET /folders/{folder_id}/contents` — list a folder's direct children (subfolders, files, forms).
* `GET /phases/{phase_instance_id}/contents` — list a phase's direct children, same shape as folder contents.

`name` is required on create. `parentId` and `phaseInstanceId` are mutually exclusive — a folder sits either at the root of a phase or nested under another folder, not both — and folders nest at most 2 levels deep. Folders use `isActive` for their archived state.

A project's or opportunity's Docs tab groups everything under workflow phases, but phases aren't real `Folder` rows — they're synthesized from `PhaseInstance`s. So the two list endpoints above return those phase rows (`type: "phase"`) alongside root-level real folders (`type: "folder"`), matching what the UI shows.

Every item from a `/contents` endpoint carries a `type` field (`folder`, `file`, or `form`) — forms are ordinary files under the hood (`isForm: true`), inheriting their folder/phase placement, so they surface here rather than as a separate concept. On these `/contents` endpoints only, a file's MIME/category label is named `fileType` instead of `type`, to avoid colliding with the folder/file/form discriminator; this does not affect `GET /projects/{project_id}/files`.

Also added:

* `GET`/`PATCH`/`DELETE /files/{file_id}` - replace the entity-scoped get/update/delete endpoints, which will still work until fully deprecated.

## Create Form PDF

You can now use the API to create the form PDF similar to what an automation email would send.

* `POST /forms/{form_id}/pdf` to export a form to PDF.
* Optional body fields: `imagesOnly`, `imagesInRow` (0-6), `includeImageMetadata`.
* Returns `{ pdfBase64, contentType, name }`.

## Technical Details page, and documenting how to archive records

Added a **Technical Details** page (between Quick Start and Webhooks in the sidebar). Rate limits moved there from the Authentication page — same content, new home. Also added a new **Archiving records** section: most records can be archived instead of permanently deleted — use that object's `PATCH` endpoint and set `isActive` to `false` (some resources still use `isArchived`; check the endpoint reference).

## August 17, 2026

## Create a form on a work order

Added `POST /work-orders/{work_order_id}/forms` to create a form instance directly on a work order, closing the gap where forms could only be created on projects and opportunities.

`templateId` is required. The form is created with its template's name — a work order already sits in a phase, so `phaseInstanceId` is not accepted on this endpoint; the form inherits its placement from the work order's parent element instance. The response includes `workOrderId` alongside `projectId` for correlation, and (when present) `phaseInstanceId`/`phaseTemplateId`.

## August 12, 2026

## Create forms on opportunities

Added `POST /opportunities/{opportunity_id}/forms` to create a form instance directly on an opportunity, mirroring the existing `POST /projects/{project_id}/forms`. `templateId` is required, `name` is optional (defaults to the template's name), and opportunities cannot be assigned to a phase, so `phaseInstanceId` is not accepted or returned on this endpoint.

The response from both create-form endpoints now also returns `templateId`, `name`, `createdAt`, and `updatedAt` directly on the top-level object.

## August 9, 2026

## Archive work orders via the API

Work order update endpoints now accept `isActive` in the request body. Set `isActive: false` to archive a work order, or `isActive: true` to unarchive it — handled independently of any other fields in the same request.

Work order response payloads now also return `isActive`. The existing `isArchived` field is deprecated but still returned for backward compatibility — no breaking change.

## August 6, 2026

## `updatedAt` and `updated_after`/`updated_before` now reflect custom property changes

`updated_after`/`updated_before` on `GET /projects`, `GET /projects/search`, and the other list endpoints (requests/deals, clients/accounts, vendors) now also match records whose custom property values changed within the window. This is always on; there is no new query param.

The `updatedAt` field on a returned record now reflects the most recent change to that record, including changes to its custom property values.

#### Caveats

* There may be a short delay before a custom property change is reflected — build in a small overlap when polling with `updated_after`.
* Within a page of results, ordering now matches the `updatedAt` value shown in the response. Page boundaries (`page`/`page_size`) are still determined by each record's own persisted `updatedAt`, so a record that only qualifies via a custom property change may land on a different page than its displayed `updatedAt` would suggest.

_Showing the 20 most recent of 45 entries. Append `/llms.txt` to the changelog URL for the complete index._