# TrueScope REST API reference (v1)

Base URL: https://truescopeapps.com

## How to use this API (read first)

- Authenticate every request with the header `Authorization: Bearer <API_KEY>` (or `x-api-key: <API_KEY>`). The key belongs to one company; every request only sees and changes that company's data.
- All endpoints are under `/api/v1`. Bodies and responses are JSON (`Content-Type: application/json`).
- List endpoints return `{ "data": [...], "pagination": { "limit", "offset", "total", "hasMore" } }`. Use `limit` (1-200, default 50) and `offset` to page; keep requesting while `hasMore` is true.
- Ids are server-generated strings. Never invent an id: look it up with a list endpoint (e.g. `GET /api/v1/clients?search=acme`) and use the returned `id`.
- Do not send `id` or `companyId` in request bodies; unknown or read-only fields are rejected with 400 and the response lists `details.allowedFields`.
- `PATCH` changes only the fields you send. For estimates, sales orders and purchase orders, sending `lineItems` replaces all existing lines.
- There are no DELETE endpoints. Deactivate clients, vendors and products with `POST .../:id/deactivate` (undo with `/reactivate`); close documents by changing their `status`.
- Creates return 201 with the full created record. Errors return `{ "error": "message" }`: 400 invalid input, 401 missing/invalid key, 403 API disabled or endpoint not available to keys, 404 not found, 422 referenced record missing or business rule failed, 429 rate limited (max 100 requests per 10 seconds, retry after a pause), 500 server error.
- `POST /api/v1/team-mail/messages` sends a real email immediately from the chosen mailbox and cannot be undone. Unless your instructions say otherwise, show the recipient, subject and body to a person and get approval before sending.
- Book keeping endpoints are read-only; money moves only through invoices (`POST /api/v1/invoices/:id/payments`) and project expenses.
- Takeoff costs (`GET /api/v1/takeoffs/:id/costs`) are calculated the same way as the takeoff preview in the app; use `byCostType` / `byGroup` for summaries and `lines` for detail.
- Settings, user management, roles, billing and API keys are not available through the API.

Line items (estimates, sales orders, purchase orders, invoices): Array of { productId?, name, description?, quantity, unitPrice, unit?, taxRate?, notes?, section? }. Line total = quantity × unitPrice; document total = sum of line totals (pre-tax). On update, the array replaces all existing lines.

## API Overview

The TrueScope REST API gives you programmatic access to your company data. All /api/v1 requests are authenticated with your company API key and only ever see your own company's records. Requests and responses are JSON. Lists are paginated, updates are partial (PATCH), lists that belong to a record such as a BOM or a routing are replaced as a whole (PUT), and records are deactivated rather than deleted.

### GET /api/v1/ping

Verify that the API is reachable and that your API key is valid and enabled.

Example response (200, abridged):

```json
{
  "pong": true,
  "authenticated": true,
  "timestamp": "2026-10-22T18:00:00.000Z"
}
```

## Authentication & Errors

Send your company API key on every request as "Authorization: Bearer YOUR_API_KEY" (the header "x-api-key: YOUR_API_KEY" also works). Find, copy or regenerate the key in Settings → System Settings → Data & API; regenerating revokes the old key, and switching "API Enabled" off blocks all key access. Keys can only reach /api/v1 — settings, users, roles, billing and key management are never available to API keys. Errors return { "error": "message" } with these status codes: 400 invalid input or unknown field, 401 missing/invalid key, 403 API disabled or endpoint not available to keys, 404 record not found (including ids that belong to another company), 422 a referenced record (clientId, productId…) does not exist in your company or a business rule failed, 429 too many requests (max 100 per 10 seconds), 500 server error.

## Clients & Contacts

Clients and leads (contact records). Email and billing email must be unique within your company.

### GET /api/v1/clients

List clients and leads, sorted by name.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `search` (string) — Case-insensitive text match on name, contact name, organization, email and phone.
- `contactType` (string) — Client or Lead.
- `isActive` (boolean) — true = active only, false = deactivated only.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
      "name": "Acme Corp",
      "contactName": "Jane Smith",
      "email": "billing@acme.com",
      "phone": "604-555-0100",
      "city": "Vancouver",
      "contactType": "Client",
      "isActive": true,
      "isOrganization": 1
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/clients/:id

Get one client, including notes.

Example response (200, abridged):

```json
{
  "id": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
  "name": "Acme Corp",
  "email": "billing@acme.com",
  "notes": [],
  "contactType": "Client",
  "isActive": true,
  "isOrganization": true
}
```

### POST /api/v1/clients

Create a client or lead. The id is generated by the server and returned with the full record (201).

Body fields:

- `name` (string, required) — Display name (company or person).
- `contactName` (string) — Primary contact person.
- `organization` (string) — Organization name.
- `email` (email) — Unique within your company.
- `billingEmail` (email) — Unique within your company.
- `phone` (string)
- `website` (string)
- `address, city, state, postalCode, country` (string) — Billing address.
- `shippingAddress, shippingCity, shippingState, shippingPostalCode, shippingCountry` (string) — Shipping address.
- `contactType` ("Client" | "Lead") — Default Client.
- `freightType` ("Prepaid" | "Collect") — Default Prepaid.
- `carrier, carrierAccountNumber` (string) — Carrier account (only kept for Collect freight).
- `isOrganization` (boolean)

Example request body:

```json
{
  "name": "Acme Corp",
  "contactName": "Jane Smith",
  "email": "billing@acme.com",
  "phone": "604-555-0100"
}
```

Example response (201, abridged):

```json
{
  "id": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
  "name": "Acme Corp",
  "contactName": "Jane Smith",
  "email": "billing@acme.com",
  "isActive": true
}
```

### PATCH /api/v1/clients/:id

Update only the fields you send (same fields as create). Notes and other fields are left untouched.

Body fields: same as create; all optional.

Example request body:

```json
{
  "phone": "604-555-0200",
  "city": "Burnaby"
}
```

Example response (200, abridged):

```json
{
  "id": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
  "name": "Acme Corp",
  "phone": "604-555-0200",
  "city": "Burnaby",
  "isActive": true
}
```

### POST /api/v1/clients/:id/deactivate

Deactivate a client (hidden from pickers, history kept). Use /reactivate to undo.

Example response (200, abridged):

```json
{
  "id": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
  "name": "Acme Corp",
  "isActive": false
}
```

### POST /api/v1/clients/:id/reactivate

Reactivate a deactivated client.

Example response (200, abridged):

```json
{
  "id": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
  "isActive": true
}
```

## Vendors

Suppliers and subcontractors. Vendor names are unique within your company.

### GET /api/v1/vendors

List vendors, sorted by name.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `search` (string) — Case-insensitive text match on name, contact name, email and category.
- `category` (string) — Exact category match.
- `isActive` (boolean) — true = active only, false = deactivated only.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "7b0d3f1e-2a4c-4e59-8d61-0c9a8b7e6f51",
      "name": "Industrial Lumber Co",
      "category": "Lumber",
      "email": "orders@lumber.example",
      "isActive": true
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/vendors/:id

Get one vendor.

Example response (200, abridged):

```json
{
  "id": "7b0d3f1e-2a4c-4e59-8d61-0c9a8b7e6f51",
  "name": "Industrial Lumber Co",
  "contactName": "Sam Lee",
  "currency": "USD",
  "isActive": true
}
```

### POST /api/v1/vendors

Create a vendor (201).

Body fields:

- `name` (string, required) — Company name, unique within your company.
- `category` (string)
- `contactName` (string)
- `email` (email)
- `phone` (string)
- `address, city, state, postalCode, country` (string)
- `vendorTaxCode` (string)
- `notes` (string)

Example request body:

```json
{
  "name": "Industrial Lumber Co",
  "category": "Lumber",
  "email": "orders@lumber.example"
}
```

Example response (201, abridged):

```json
{
  "id": "7b0d3f1e-2a4c-4e59-8d61-0c9a8b7e6f51",
  "name": "Industrial Lumber Co",
  "isActive": true
}
```

### PATCH /api/v1/vendors/:id

Update only the fields you send (same fields as create).

Body fields: same as create; all optional.

Example request body:

```json
{
  "phone": "604-555-0300"
}
```

Example response (200, abridged):

```json
{
  "id": "7b0d3f1e-2a4c-4e59-8d61-0c9a8b7e6f51",
  "phone": "604-555-0300"
}
```

### POST /api/v1/vendors/:id/deactivate

Deactivate a vendor. Use /reactivate to undo.

Example response (200, abridged):

```json
{
  "id": "7b0d3f1e-2a4c-4e59-8d61-0c9a8b7e6f51",
  "isActive": false
}
```

### POST /api/v1/vendors/:id/reactivate

Reactivate a vendor.

Example response (200, abridged):

```json
{
  "id": "7b0d3f1e-2a4c-4e59-8d61-0c9a8b7e6f51",
  "isActive": true
}
```

## Products & Catalog

Items, services and parts in your catalog. Names and part numbers are unique within your company.

### GET /api/v1/products

List catalog items, sorted by name.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `search` (string) — Case-insensitive text match on name, part number, description and manufacturer.
- `itemType` (string) — e.g. Item, Service.
- `categoryId` (string) — Category id.
- `isActive` (boolean) — true = active only, false = deactivated only.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
      "name": "Steel Bracket",
      "partNumber": "BRK-100",
      "price": 12.5,
      "unit": "EA",
      "itemType": "Item",
      "isActive": true
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/products/:id

Get one product, including its manufacturing routing and bill of materials.

Example response (200, abridged):

```json
{
  "id": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "name": "Steel Bracket",
  "partNumber": "BRK-100",
  "price": 12.5,
  "standardCost": 6.1,
  "isActive": true,
  "routing": [],
  "bom": []
}
```

### POST /api/v1/products

Create a catalog item (201).

Body fields:

- `name` (string, required) — Unique within your company.
- `partNumber` (string) — Unique within your company.
- `description` (string)
- `notes` (string)
- `price` (number) — Sell price.
- `standardCost` (number)
- `unit` (string) — e.g. EA, FT, HR.
- `itemType` (string) — Default Item.
- `categoryId` (string)
- `manufacturer` (string)
- `isSellable, isPurchasable, isKeepInStock` (boolean) — Default true.

Example request body:

```json
{
  "name": "Service Labor",
  "unit": "HR",
  "price": 85,
  "itemType": "Service",
  "isKeepInStock": false
}
```

Example response (201, abridged):

```json
{
  "id": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "name": "Service Labor",
  "price": 85,
  "isActive": true
}
```

### PATCH /api/v1/products/:id

Update only the fields you send (same fields as create).

Body fields: same as create; all optional.

Example request body:

```json
{
  "price": 92.5
}
```

Example response (200, abridged):

```json
{
  "id": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "price": 92.5
}
```

### POST /api/v1/products/:id/deactivate

Deactivate a catalog item. Use /reactivate to undo.

Example response (200, abridged):

```json
{
  "id": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "isActive": false
}
```

### POST /api/v1/products/:id/reactivate

Reactivate a catalog item.

Example response (200, abridged):

```json
{
  "id": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "isActive": true
}
```

### GET /api/v1/products/:id/bom

Bill of materials of a manufactured part: the components and quantities needed for one finished unit, with the rolled-up material cost.

Example response (200, abridged):

```json
{
  "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "data": [
    { "id": "bom-...", "componentId": "8a1b...", "name": "Steel plate 3mm", "quantity": 2, "unit": "EA", "isCustom": false, "unitCost": 15, "lineCost": 30 },
    { "id": "bom-...", "componentId": null, "name": "Paint", "quantity": 0.5, "unit": "L", "isCustom": true, "unitCost": 20, "lineCost": 10 }
  ],
  "totalCost": 40
}
```

### PUT /api/v1/products/:id/bom

Replace the whole BOM (send an empty array to clear it). New work orders for this product copy it. Components must be products of your company and cannot be the product itself (422).

Body fields:

- `items` (array, required) — Array of { componentId?, name?, quantity, unit?, description?, unitCost? }. componentId is a product id (name, unit and unitCost default from that product); without it, name makes a custom line. quantity is per one finished unit.

Example request body:

```json
{
  "items": [
    {
      "componentId": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
      "quantity": 2
    },
    {
      "name": "Paint",
      "quantity": 0.5,
      "unit": "L",
      "unitCost": 20
    }
  ]
}
```

Example response (200, abridged):

```json
{
  "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "data": [ ... ],
  "totalCost": 40
}
```

### GET /api/v1/products/:id/routing

Routing of a manufactured part: the ordered operations and their time per part.

Example response (200, abridged):

```json
{
  "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "data": [
    { "id": "rt-...", "stepNumber": 1, "operationId": "op-laser", "workcenter": "Laser Cutting", "description": "Cut blanks", "timeMinutes": 12 },
    { "id": "rt-...", "stepNumber": 2, "operationId": "op-weld", "workcenter": "Welding", "description": "Weld frame", "timeMinutes": 25 }
  ],
  "totalMinutes": 37
}
```

### PUT /api/v1/products/:id/routing

Replace the whole routing, in order. Each step names an operation (see /api/v1/operations). New work orders for this product copy it.

Body fields:

- `steps` (array, required) — Array of { operationId? or workcenter (operation name), description?, timeMinutes?, notes?, costPerPart?, isExternal?, vendorId?, checklistTemplateIds?, formTemplateIds? }. Checklist and form templates are attached to the step on new work orders.

Example request body:

```json
{
  "steps": [
    {
      "operationId": "op-laser",
      "timeMinutes": 12,
      "description": "Cut blanks"
    },
    {
      "operationId": "op-weld",
      "timeMinutes": 25,
      "description": "Weld frame"
    }
  ]
}
```

Example response (200, abridged):

```json
{
  "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "data": [ ... ],
  "totalMinutes": 37
}
```

## Inventory & Warehouses

Stock records (one per product / warehouse / location), stock adjustments with an audit trail, warehouses and calculated stock levels.

### GET /api/v1/inventory

List stock records, most recently updated first. Removed (written-off) records are hidden unless isRemoved=true.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `productId` (string) — Only this product.
- `warehouseId` (string) — Only this warehouse.
- `search` (string) — Case-insensitive text match on serial and batch numbers.
- `isRemoved` (boolean) — true to list removed records instead.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "inv-5e2c...",
      "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
      "warehouseId": "wh-main",
      "locationId": "A-01",
      "quantity": 140,
      "minQty": 20,
      "unit": "EA",
      "updatedAt": "2026-10-20T15:04:00.000Z"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/inventory/:id

Get one stock record.

Example response (200, abridged):

```json
{
  "id": "inv-5e2c...",
  "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "quantity": 140
}
```

### POST /api/v1/inventory/:id/adjustments

Adjust the quantity of a stock record and log an ADJUST transaction. Send exactly one of quantityChange or newQuantity. The result cannot be negative (422).

Body fields:

- `quantityChange` (number) — Relative change, e.g. -5 or 20.
- `newQuantity` (number) — Absolute new quantity (counted stock).
- `reason` (string) — Short reason shown in the transaction log.
- `notes` (string)

Example request body:

```json
{
  "quantityChange": -5,
  "reason": "Damaged in yard"
}
```

Example response (201, abridged):

```json
{
  "previousQuantity": 140,
  "quantity": 135,
  "quantityChange": -5,
  "inventory": { "id": "inv-5e2c...", "quantity": 135 }
}
```

### GET /api/v1/inventory-transactions

Inventory movement log, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `productId` (string)
- `warehouseId` (string)
- `inventoryId` (string) — Transactions of one stock record.
- `type` (string) — e.g. ADJUST, IN, OUT, TRANSFER.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "tx-91d0...",
      "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
      "transactionType": "ADJUST",
      "quantityChanged": -5,
      "source": "Damaged in yard",
      "createdAt": "2026-10-22T18:00:00.000Z"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/warehouses

List warehouses and their locations.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `search` (string) — Case-insensitive text match on name and address.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "wh-main",
      "name": "Main Yard",
      "address": "100 Industrial Way",
      "locations": [{ "id": "A-01", "name": "Rack A-01" }]
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/warehouses/:id

Get one warehouse with its storage locations.

Example response (200, abridged):

```json
{
  "id": "wh-main",
  "name": "Main Yard",
  "locations": [{ "id": "A-01", "name": "Rack A-01" }]
}
```

### POST /api/v1/warehouses

Create a warehouse (201). Names must be unique (422).

Body fields:

- `name` (string, required)
- `description, address` (string)
- `locations` (array) — Storage locations (bins, racks): array of { name, description? }. Ids are generated.

Example request body:

```json
{
  "name": "North Yard",
  "address": "200 Industrial Way",
  "locations": [
    {
      "name": "Rack A-01"
    },
    {
      "name": "Rack A-02"
    }
  ]
}
```

Example response (201, abridged):

```json
{
  "id": "wh-...",
  "name": "North Yard",
  "locations": [{ "id": "loc-...", "name": "Rack A-01" }, { "id": "loc-...", "name": "Rack A-02" }]
}
```

### PATCH /api/v1/warehouses/:id

Update only the fields you send. "locations" replaces the whole list; keep existing locations by sending their id (stock records point at location ids).

Body fields: same as create; all optional.

Example request body:

```json
{
  "locations": [
    {
      "id": "A-01",
      "name": "Rack A-01"
    },
    {
      "name": "Rack A-03"
    }
  ]
}
```

Example response (200, abridged):

```json
{
  "id": "wh-main",
  "locations": [ ... ]
}
```

### GET /api/v1/stock-levels

Calculated stock levels per product (in stock, on order, in production, sales history and reorder minimum) as shown on the Stock Levels screen.

Example response (200, abridged):

```json
{
  "data": [
    {
      "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
      "productName": "Steel Bracket",
      "partNumber": "BRK-100",
      "unit": "EA",
      "minQty": 20,
      "qtyInStock": 135,
      "qtyOrdered": 200,
      "qtyProduction": 50,
      "qtySold": 410,
      "qtyBalance": 385
    }
  ]
}
```

## Estimates & Quotes

Estimates (quotes) with line items. Numbers are assigned automatically.

### GET /api/v1/estimates

List estimates, newest first. Line items are omitted from lists; fetch one estimate to get them.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string) — Exact status, case-insensitive.
- `clientId` (string)
- `search` (string) — Case-insensitive text match on estimate number, description and notes.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "e5f6a7b8-...",
      "estimateNumber": 1042,
      "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
      "status": "NEW",
      "total": 1250,
      "createdAt": "2026-10-22T18:00:00.000Z"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/estimates/:id

Get one estimate with its line items.

Example response (200, abridged):

```json
{
  "id": "e5f6a7b8-...",
  "estimateNumber": 1042,
  "status": "NEW",
  "total": 1250,
  "lineItems": [
    { "id": "...", "name": "Service Labor", "quantity": 10, "unitPrice": 85, "total": 850 }
  ]
}
```

### POST /api/v1/estimates

Create an estimate (201). Status defaults to NEW.

Body fields:

- `clientId` (string, required) — Client id from /api/v1/clients.
- `lineItems` (array) — Array of { productId?, name, description?, quantity, unitPrice, unit?, taxRate?, notes?, section? }. Line total = quantity × unitPrice; document total = sum of line totals (pre-tax). On update, the array replaces all existing lines.
- `status` (string) — Default NEW.
- `description` (string)
- `notes` (string) — Internal notes.
- `externalNotes` (string) — Shown to the client.
- `clientRefNo` (string)
- `address` (string) — Job site address.
- `additionalTerms` (string)

Example request body:

```json
{
  "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
  "description": "Furnace replacement",
  "lineItems": [
    {
      "name": "Service Labor",
      "quantity": 10,
      "unitPrice": 85,
      "unit": "HR"
    },
    {
      "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
      "name": "Steel Bracket",
      "quantity": 32,
      "unitPrice": 12.5
    }
  ]
}
```

Example response (201, abridged):

```json
{
  "id": "e5f6a7b8-...",
  "estimateNumber": 1043,
  "status": "NEW",
  "total": 1250,
  "lineItems": [ ... ]
}
```

### PATCH /api/v1/estimates/:id

Update only the fields you send. Sending lineItems replaces every line and recalculates the total.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "SENT",
  "externalNotes": "Valid for 30 days."
}
```

Example response (200, abridged):

```json
{
  "id": "e5f6a7b8-...",
  "status": "SENT",
  "externalNotes": "Valid for 30 days."
}
```

## Sales Orders

Customer sales orders with line items, shipping and freight details.

### GET /api/v1/sales-orders

List sales orders, newest first (line items omitted).

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `clientId` (string)
- `search` (string) — Case-insensitive text match on order number, description and notes.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "a9b8c7d6-...",
      "salesOrderNumber": 310,
      "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
      "status": "OPEN",
      "total": 4800,
      "requiredDate": "2026-11-15"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/sales-orders/:id

Get one sales order with its line items (including qtyPicked / qtyShipped).

Example response (200, abridged):

```json
{
  "id": "a9b8c7d6-...",
  "salesOrderNumber": 310,
  "lineItems": [
    { "name": "Steel Bracket", "quantity": 100, "unitPrice": 12.5, "total": 1250, "qtyShipped": 0 }
  ]
}
```

### POST /api/v1/sales-orders

Create a sales order (201).

Body fields:

- `clientId` (string, required)
- `estimateId` (string) — Link to the source estimate.
- `lineItems` (array) — Array of { productId?, name, description?, quantity, unitPrice, unit?, taxRate?, notes?, section? }. Line total = quantity × unitPrice; document total = sum of line totals (pre-tax). On update, the array replaces all existing lines.
- `status` (string) — Default NEW.
- `description, notes, externalNotes, clientRefNo, address, additionalTerms, shippingInstructions` (string)
- `freightType` ("Prepaid" | "Collect")
- `carrier, carrierAccountNumber` (string)
- `requiredDate` (date)

Example request body:

```json
{
  "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
  "requiredDate": "2026-11-15",
  "lineItems": [
    {
      "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
      "name": "Steel Bracket",
      "quantity": 100,
      "unitPrice": 12.5
    }
  ]
}
```

Example response (201, abridged):

```json
{
  "id": "a9b8c7d6-...",
  "salesOrderNumber": 311,
  "total": 1250
}
```

### PATCH /api/v1/sales-orders/:id

Update only the fields you send. Sending lineItems replaces every line.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "IN_PROGRESS",
  "requiredDate": "2026-11-20"
}
```

Example response (200, abridged):

```json
{
  "id": "a9b8c7d6-...",
  "status": "IN_PROGRESS"
}
```

## Purchase Orders

Supplier purchase orders with line items.

### GET /api/v1/purchase-orders

List purchase orders, newest first (line items omitted).

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `vendorId` (string)
- `projectId` (string)
- `search` (string) — Case-insensitive text match on PO number, description and notes.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "p0o9i8u7-...",
      "poNumber": 88,
      "vendorId": "7b0d3f1e-2a4c-4e59-8d61-0c9a8b7e6f51",
      "status": "NEW",
      "total": 940,
      "expectedDeliveryDate": "2026-11-01"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/purchase-orders/:id

Get one purchase order with its line items (including qtyReceived).

Example response (200, abridged):

```json
{
  "id": "p0o9i8u7-...",
  "poNumber": 88,
  "lineItems": [ { "name": "2x4 Lumber", "quantity": 200, "unitPrice": 4.7, "qtyReceived": 0 } ]
}
```

### POST /api/v1/purchase-orders

Create a purchase order (201).

Body fields:

- `vendorId` (string, required) — Vendor id from /api/v1/vendors.
- `projectId` (string) — Charge to a project.
- `lineItems` (array) — Array of { productId?, name, description?, quantity, unitPrice, unit?, taxRate?, notes?, section? }. Line total = quantity × unitPrice; document total = sum of line totals (pre-tax). On update, the array replaces all existing lines.
- `status` (string) — Default NEW.
- `description, notes, poInstructions, vendorRefNo, deliveryAddress, shippingInstructions` (string)
- `expectedDeliveryDate` (date)
- `freightType` ("Prepaid" | "Collect")
- `carrier, carrierAccountNumber` (string)

Example request body:

```json
{
  "vendorId": "7b0d3f1e-2a4c-4e59-8d61-0c9a8b7e6f51",
  "expectedDeliveryDate": "2026-11-01",
  "lineItems": [
    {
      "name": "2x4 Lumber",
      "quantity": 200,
      "unitPrice": 4.7
    }
  ]
}
```

Example response (201, abridged):

```json
{
  "id": "p0o9i8u7-...",
  "poNumber": 89,
  "total": 940
}
```

### PATCH /api/v1/purchase-orders/:id

Update only the fields you send. Sending lineItems replaces every line.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "APPROVED"
}
```

Example response (200, abridged):

```json
{
  "id": "p0o9i8u7-...",
  "status": "APPROVED"
}
```

## Invoices & Payments

Customer invoices and payments. Recording a payment updates the balance, the invoice status and the bookkeeping journal exactly like the payment dialog in the app.

### GET /api/v1/invoices

List invoices, newest first (items omitted).

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string) — e.g. OUTSTANDING, PAID.
- `clientId` (string)
- `projectId` (string)
- `salesOrderId` (string)
- `estimateId` (string)
- `search` (string) — Case-insensitive text match on invoice number and notes.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "i1n2v3o4-...",
      "invoiceNumber": 2051,
      "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
      "status": "OUTSTANDING",
      "subtotal": 1000,
      "tax": 50,
      "total": 1050,
      "amountPaid": 0,
      "dueDate": "2026-11-21T00:00:00.000Z"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/invoices/:id

Get one invoice with its items.

Example response (200, abridged):

```json
{
  "id": "i1n2v3o4-...",
  "invoiceNumber": 2051,
  "total": 1050,
  "amountPaid": 0,
  "items": [ { "description": "Service Labor", "quantity": 10, "unitPrice": 100, "currentAmount": 1000, "taxRate": 5, "taxAmount": 50 } ]
}
```

### POST /api/v1/invoices

Create an invoice (201). Subtotal, tax and total are calculated from the line items; status starts as OUTSTANDING; due date defaults to 30 days.

Body fields:

- `clientId` (string, required)
- `lineItems` (array, required) — Array of { productId?, name, description?, quantity, unitPrice, unit?, taxRate? } (at least one).
- `projectId, salesOrderId, estimateId` (string) — Optional links.
- `dueDate` (date)
- `internalNotes, externalNotes, additionalTerms` (string)

Example request body:

```json
{
  "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
  "dueDate": "2026-11-21",
  "lineItems": [
    {
      "name": "Service Labor",
      "quantity": 10,
      "unitPrice": 100,
      "taxRate": 5
    }
  ]
}
```

Example response (201, abridged):

```json
{
  "id": "i1n2v3o4-...",
  "invoiceNumber": 2052,
  "status": "OUTSTANDING",
  "total": 1050
}
```

### PATCH /api/v1/invoices/:id

Update status, notes, client or submitted date. Use /payments to record money received.

Body fields:

- `status` (string)
- `notes` (string)
- `clientId` (string)
- `dateSubmitted` (date)

Example request body:

```json
{
  "dateSubmitted": "2026-10-22"
}
```

Example response (200, abridged):

```json
{
  "id": "i1n2v3o4-...",
  "dateSubmitted": "2026-10-22"
}
```

### POST /api/v1/invoices/:id/payments

Record a payment. It cannot exceed the balance due (422). When the balance reaches zero the invoice becomes PAID.

Body fields:

- `amount` (number, required) — Amount received (> 0).
- `paymentType` (string) — e.g. Cheque, EFT, Card.
- `accountName` (string) — Deposit account (defaults to the company setting).
- `notes` (string)
- `datePaid` (date)

Example request body:

```json
{
  "amount": 500,
  "paymentType": "EFT"
}
```

Example response (201, abridged):

```json
{
  "id": "i1n2v3o4-...",
  "status": "OUTSTANDING",
  "total": 1050,
  "amountPaid": 500
}
```

## Projects

Construction / job projects. The API manages the project header; daily logs, records, checklists and schedules stay in the app.

### GET /api/v1/projects

List projects, newest first (large sections such as records and schedules are omitted).

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `clientId` (string)
- `isTemplate` (boolean) — true for project templates.
- `search` (string) — Case-insensitive text match on name and project number.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "pr0j-...",
      "projectNumber": 57,
      "name": "Oak Bridge Overpass",
      "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
      "status": "OPEN"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/projects/:id

Get one project with all of its sections.

Example response (200, abridged):

```json
{
  "id": "pr0j-...",
  "projectNumber": 57,
  "name": "Oak Bridge Overpass",
  "status": "OPEN",
  "scopeOfWork": [],
  "schedule": { "phases": [] }
}
```

### POST /api/v1/projects

Create a project (201). Status defaults to OPEN.

Body fields:

- `name` (string, required)
- `clientId` (string)
- `estimateId` (string)
- `status` (string)
- `siteInstructions` (string)

Example request body:

```json
{
  "name": "Oak Bridge Overpass",
  "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708"
}
```

Example response (201, abridged):

```json
{
  "id": "pr0j-...",
  "projectNumber": 58,
  "name": "Oak Bridge Overpass",
  "status": "OPEN"
}
```

### PATCH /api/v1/projects/:id

Update name, client, estimate link, status or site instructions.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "ON_HOLD"
}
```

Example response (200, abridged):

```json
{
  "id": "pr0j-...",
  "status": "ON_HOLD"
}
```

### GET /api/v1/projects/:id/schedule

Project schedule: phases and action items with their dates, as shown on the project schedule (Gantt) tab.

Example response (200, abridged):

```json
{
  "projectId": "pr0j-...",
  "schedule": {
    "phases": [ { "id": "...", "name": "Foundations", "startDate": "2026-11-01", "endDate": "2026-11-20" } ],
    "actionItems": [ ... ]
  }
}
```

### GET /api/v1/projects/:id/expenses

Expenses charged to the project, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `category` (string)
- `vendorId` (string)
- `search` (string) — Case-insensitive text match on description, vendor and category.
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "ex-...",
      "projectId": "pr0j-...",
      "description": "Rebar delivery",
      "amount": 845.5,
      "date": "2026-10-20",
      "category": "Materials",
      "vendorName": "Steel Supply Co",
      "reimburse": 0
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### POST /api/v1/projects/:id/expenses

Add an expense to the project (201). Receipt images are added in the app.

Body fields:

- `description` (string, required)
- `amount` (number, required)
- `date` (date) — Default today.
- `category` (string)
- `vendorId` (string) — Vendor id (or vendorName for a one-off payee).
- `vendorName` (string)
- `vendorTaxCode` (string)
- `taxRateValue` (number) — Tax %
- `reimburse` (boolean) — Owed back to the employee.

Example request body:

```json
{
  "description": "Rebar delivery",
  "amount": 845.5,
  "category": "Materials",
  "vendorId": "7b0d3f1e-2a4c-4e59-8d61-0c9a8b7e6f51"
}
```

Example response (201, abridged):

```json
{
  "id": "ex-...",
  "projectId": "pr0j-...",
  "amount": 845.5
}
```

### PATCH /api/v1/projects/:id/expenses/:expenseId

Update only the fields you send (same fields as create).

Body fields: same as create; all optional.

Example request body:

```json
{
  "amount": 860
}
```

Example response (200, abridged):

```json
{
  "id": "ex-...",
  "amount": 860
}
```

### GET /api/v1/projects/:id/time-entries

Labour time entries booked to the project.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `workerId` (string) — User id of the worker.
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "te-...",
      "projectId": "pr0j-...",
      "workerId": "user-12",
      "hours": 8,
      "date": "2026-10-21",
      "description": "Formwork"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/projects/:id/daily-logs

Daily site logs for the project.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `workerId` (string)
- `search` (string) — Case-insensitive text match on log text.
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "dl-...",
      "date": "2026-10-21",
      "content": "Poured footings on grid A-C.",
      "hours": 9,
      "workerId": "user-12"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### POST /api/v1/projects/:id/daily-logs

Record a daily log for the project (201). With hours and a worker it also counts as a timesheet entry.

Body fields:

- `date` (date, required)
- `content` (string, required) — What was done.
- `hours` (number) — 0-24.
- `startTime, finishTime` (string) — e.g. 07:00.
- `workerId` (string) — Worker (mailbox id from /team-mail/mailboxes).
- `referenceCodeId` (string) — Timesheet reference code id.

Example request body:

```json
{
  "date": "2026-10-21",
  "content": "Poured footings on grid A-C.",
  "hours": 9,
  "workerId": "user-12"
}
```

Example response (201, abridged):

```json
{
  "id": "dl-...",
  "projectId": "pr0j-...",
  "date": "2026-10-21",
  "hours": 9
}
```

### GET /api/v1/projects/:id/change-orders

Change orders with version, status and signature status.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "co-...",
      "version": 2,
      "description": "Add mezzanine stairs",
      "status": "APPROVED",
      "signatureStatus": "SIGNED",
      "changes": [ ... ]
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/projects/:id/records

Project records such as RFIs, issues, notes and deficiencies.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `type` (string) — Record type, e.g. RFI.
- `status` (string)
- `assignedTo` (string)
- `search` (string) — Case-insensitive text match on content and notes.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "rec-...",
      "type": "RFI",
      "content": "Confirm anchor bolt spacing",
      "status": "OPEN",
      "dueDate": "2026-10-25",
      "response": null
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/projects/:id/checklists

Checklists attached to the project, with their items and completion.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `category` (string)

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "cl-...",
      "name": "Pre-pour inspection",
      "completedAt": null,
      "items": [ ... ]
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

## Service Dispatch

Service calls (field service jobs). Call numbers are assigned automatically.

### GET /api/v1/service-calls

List service calls, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `clientId` (string)
- `priority` (string) — LOW, NORMAL, HIGH or URGENT.
- `scheduledDate` (string) — YYYY-MM-DD
- `search` (string) — Case-insensitive text match on call number, service type, address and notes.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "sc-...",
      "callNumber": "SC-1203",
      "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
      "serviceType": "HVAC Repair",
      "status": "PENDING",
      "priority": "HIGH",
      "scheduledDate": "2026-10-23"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/service-calls/:id

Get one service call with its activity log, checklists and records.

Example response (200, abridged):

```json
{
  "id": "sc-...",
  "callNumber": "SC-1203",
  "status": "PENDING",
  "activityLog": []
}
```

### POST /api/v1/service-calls

Create a service call (201).

Body fields:

- `clientId` (string, required)
- `serviceType` (string)
- `serviceAddress` (string)
- `priority` ("LOW" | "NORMAL" | "HIGH" | "URGENT") — Default NORMAL.
- `status` (string) — Default PENDING.
- `scheduledDate` (date)
- `scheduledTime, scheduledEndTime` (string) — e.g. 09:00.
- `estimatedDuration` (number) — Hours.
- `notes, completionNotes, siteInstructions` (string)
- `estimateId` (string)

Example request body:

```json
{
  "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
  "serviceType": "HVAC Repair",
  "priority": "HIGH",
  "scheduledDate": "2026-10-23",
  "scheduledTime": "09:00"
}
```

Example response (201, abridged):

```json
{
  "id": "sc-...",
  "callNumber": "SC-1204",
  "status": "PENDING"
}
```

### PATCH /api/v1/service-calls/:id

Update only the fields you send (same fields as create).

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "COMPLETED",
  "completionNotes": "Replaced compressor valve."
}
```

Example response (200, abridged):

```json
{
  "id": "sc-...",
  "status": "COMPLETED"
}
```

### GET /api/v1/service-schedules

Recurring service schedules (maintenance contracts) that generate service calls.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `clientId` (string)
- `serviceType` (string)
- `isActive` (boolean) — true = active only, false = deactivated only.
- `search` (string) — Case-insensitive text match on name, service type and address.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "ss-...",
      "name": "Quarterly HVAC service",
      "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
      "recurrencePattern": "MONTHLY",
      "startDate": "2026-01-01",
      "isActive": true
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

## Manufacturing Setup

The building blocks of production. Operations are the process steps (laser cutting, welding, galvanizing...) with their labor / machine rates, or an outside vendor and cost per part for external processes. Work centers are the shop resources and list which operations they run and for how many hours a day (the scheduler uses this). A product's routing (see Products) is an ordered list of operations. Typical setup: create operations, then work centers, then set each part's BOM and routing, then create work orders.

### GET /api/v1/operations

List operations, sorted by name.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `category` (string)
- `search` (string) — Case-insensitive text match on name, description and category.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "op-laser",
      "name": "Laser Cutting",
      "category": "Cutting",
      "laborRate": 45,
      "machineRate": 80,
      "unit": "hrs",
      "isExternal": false
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/operations/:id

Get one operation.

Example response (200, abridged):

```json
{
  "id": "op-laser",
  "name": "Laser Cutting",
  "laborRate": 45
}
```

### POST /api/v1/operations

Create an operation (201).

Body fields:

- `name` (string, required)
- `description, category` (string)
- `laborRate, machineRate` (number) — Hourly rates (internal operations).
- `estimatedTime` (number)
- `unit` (string) — Default hrs.
- `isExternal` (boolean) — true for outside processing by a vendor.
- `vendorId` (string) — Vendor doing the external work.
- `costPerPart` (number) — External cost per part.

Example request body:

```json
{
  "name": "Galvanizing",
  "isExternal": true,
  "vendorId": "v-7a1b...",
  "costPerPart": 4.5
}
```

Example response (201, abridged):

```json
{
  "id": "op-...",
  "name": "Galvanizing",
  "isExternal": true,
  "vendorName": "Coast Galvanizing"
}
```

### PATCH /api/v1/operations/:id

Update only the fields you send.

Body fields: same as create; all optional.

Example request body:

```json
{
  "laborRate": 48
}
```

Example response (200, abridged):

```json
{
  "id": "op-laser",
  "laborRate": 48
}
```

### GET /api/v1/work-centers

List work centers and the operations they run.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string) — ENABLED or DISABLED.
- `type` (string) — INTERNAL or EXTERNAL.
- `search` (string) — Case-insensitive text match on name and description.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "wc-...",
      "name": "Fab Cell 1",
      "type": "INTERNAL",
      "status": "ENABLED",
      "operations": [{ "operationId": "op-laser", "hoursPerDay": 8 }]
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/work-centers/:id

Get one work center.

Example response (200, abridged):

```json
{
  "id": "wc-...",
  "name": "Fab Cell 1",
  "operations": [ ... ]
}
```

### POST /api/v1/work-centers

Create a work center (201).

Body fields:

- `name` (string, required)
- `description` (string)
- `operations` (array) — Array of { operationId, hoursPerDay? (default 8) }.
- `loadTime, unloadTime` (number) — Setup minutes before / after a job.
- `type` ("INTERNAL" | "EXTERNAL") — Default INTERNAL.
- `vendorId` (string) — For external work centers.
- `status` ("ENABLED" | "DISABLED") — Default ENABLED.

Example request body:

```json
{
  "name": "Fab Cell 1",
  "operations": [
    {
      "operationId": "op-laser",
      "hoursPerDay": 8
    },
    {
      "operationId": "op-weld",
      "hoursPerDay": 16
    }
  ]
}
```

Example response (201, abridged):

```json
{
  "id": "wc-...",
  "name": "Fab Cell 1",
  "status": "ENABLED"
}
```

### PATCH /api/v1/work-centers/:id

Update only the fields you send ("operations" replaces the list).

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "DISABLED"
}
```

Example response (200, abridged):

```json
{
  "id": "wc-...",
  "status": "DISABLED"
}
```

## Manufacturing Work Orders

Production work orders. Creating one copies the product's routing and BOM and schedules it, like the Work Orders screen. Each routing step is then started, stopped and completed on the shop floor; the time between start and stop is logged against the step.

### GET /api/v1/work-orders

List work orders, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `productId` (string)
- `salesOrderId` (string)
- `priority` (string)
- `search` (string) — Case-insensitive text match on order number, title, product name and part number.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "wo-...",
      "orderNumber": "WO-411",
      "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
      "productName": "Steel Bracket",
      "quantity": 50,
      "status": "PLANNING",
      "dueDate": "2026-11-05"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/work-orders/:id

Get one work order with its routing steps and checklists.

Example response (200, abridged):

```json
{
  "id": "wo-...",
  "orderNumber": "WO-411",
  "quantity": 50,
  "status": "PLANNING",
  "routing": [ ... ],
  "checklists": [ ... ]
}
```

### POST /api/v1/work-orders

Create a work order (201).

Body fields:

- `productId` (string, required) — Product to build.
- `quantity` (number, required) — > 0
- `title, notes, description` (string)
- `status` (string) — Default PLANNING.
- `priority` ("LOW" | "NORMAL" | "HIGH" | "URGENT") — Default NORMAL.
- `startDate, dueDate, plannedStartDate, earliestStartDate` (date)
- `salesOrderId` (string)

Example request body:

```json
{
  "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80",
  "quantity": 50,
  "dueDate": "2026-11-05"
}
```

Example response (201, abridged):

```json
{
  "id": "wo-...",
  "orderNumber": "WO-412",
  "status": "PLANNING"
}
```

### PATCH /api/v1/work-orders/:id

Update only the fields you send (same fields as create). Setting status to FINISHED locks the product revision; status changes also apply to child work orders.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "IN_PROGRESS"
}
```

Example response (200, abridged):

```json
{
  "id": "wo-...",
  "status": "IN_PROGRESS"
}
```

### PUT /api/v1/work-orders/:id/bom

Replace the materials list of this work order only (the product BOM is not changed).

Body fields:

- `items` (array, required) — Array of { productId? or name, quantity, unit?, description? }. Quantity is the total for this order.

Example request body:

```json
{
  "items": [
    {
      "productId": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
      "quantity": 100
    }
  ]
}
```

Example response (200, abridged):

```json
{
  "id": "wo-...",
  "bom": [ ... ]
}
```

### PUT /api/v1/work-orders/:id/routing

Replace the routing of this work order only, and reschedule it. Only allowed before any step has started (409 otherwise); afterwards update single steps.

Body fields:

- `steps` (array, required) — Same step shape as PUT /products/:id/routing, plus plannedStart (date).

Example request body:

```json
{
  "steps": [
    {
      "operationId": "op-laser",
      "timeMinutes": 12
    },
    {
      "operationId": "op-weld",
      "timeMinutes": 25
    }
  ]
}
```

Example response (200, abridged):

```json
{
  "id": "wo-...",
  "routing": [ { "id": "step-...", "stepNumber": 1, "workcenter": "Laser Cutting", "status": "Pending" } ]
}
```

### PATCH /api/v1/work-orders/:id/routing/:stepId

Update a routing step's notes or planned start.

Body fields:

- `notes` (string)
- `plannedStart` (date)

Example request body:

```json
{
  "notes": "Deburr all edges"
}
```

Example response (200, abridged):

```json
{
  "id": "step-...",
  "notes": "Deburr all edges"
}
```

### POST /api/v1/work-orders/:id/routing/:stepId/start

Start a routing step: its status becomes "In Progress" and a time log starts. 422 if it is already running or finished.

Example response (200, abridged):

```json
{
  "id": "step-...",
  "status": "In Progress",
  "startTime": "2026-10-23T14:00:00.000Z"
}
```

### POST /api/v1/work-orders/:id/routing/:stepId/stop

Pause a running step: closes the time log and adds its hours to timeLogged. The step goes back to "Pending".

Example response (200, abridged):

```json
{
  "id": "step-...",
  "status": "Pending",
  "timeLogged": 1.5
}
```

### POST /api/v1/work-orders/:id/routing/:stepId/complete

Finish a step. A running timer is stopped first; the step's hours default to the logged total.

Body fields:

- `totalHours` (number) — Override the hours recorded for the step.

Example response (200, abridged):

```json
{
  "id": "step-...",
  "status": "Finished",
  "timeLogged": 1.5
}
```

### GET /api/v1/work-orders/:id/routing/:stepId/time-logs

Time logs of a routing step, newest first.

Example response (200, abridged):

```json
{
  "data": [ { "id": "log-...", "startTime": "2026-10-23T14:00:00.000Z", "endTime": "2026-10-23T15:30:00.000Z", "durationHours": 1.5 } ]
}
```

## Takeoffs & Costing

Blueprint takeoffs and their material / labour costing. Costs are calculated exactly like the takeoff preview: each line is quantity × rate plus its components (assemblies), with a percentage or per-unit markup and its tax rate. Groups marked "exclude from estimate" are reported separately.

### GET /api/v1/takeoffs

List takeoffs, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `clientId` (string)
- `search` (string) — Case-insensitive text match on name and notes.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "tk-...",
      "name": "Warehouse B framing",
      "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
      "status": "DRAFT",
      "linkedEstimateId": null
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/takeoffs/:id

Get a takeoff with its groups and full costing.

Example response (200, abridged):

```json
{
  "id": "tk-...",
  "name": "Warehouse B framing",
  "groups": [ { "id": "g1", "name": "Walls", "includeOnEstimate": true } ],
  "costs": { "subtotal": 12840, "tax": 642, "total": 13482, ... }
}
```

### GET /api/v1/takeoffs/:id/costs

Cost breakdown: totals, by cost type (Material, Labor…), by group, and every line with its components.

Example response (200, abridged):

```json
{
  "takeoffId": "tk-...",
  "subtotal": 12840,
  "tax": 642,
  "total": 13482,
  "excludedFromEstimate": 1500,
  "byCostType": [ { "costType": "Material", "amount": 9100 }, { "costType": "Labor", "amount": 3740 } ],
  "byGroup": [ { "groupId": "g1", "name": "Walls", "includeOnEstimate": true, "subtotal": 12840, "tax": 642 } ],
  "lines": [
    {
      "id": "...", "name": "Interior wall 2x6", "quantity": 320, "unit": "LF", "rate": 18.5,
      "costType": "Labor", "markupType": "percent", "markupValue": 10, "taxRate": 5,
      "total": 8140, "tax": 407, "includeOnEstimate": true,
      "components": [ { "name": "2x6 stud", "amount": 1280, "calculation": "per parent unit" } ]
    }
  ]
}
```

### POST /api/v1/takeoffs

Create a takeoff (201). Drawings and measurements are added in the app.

Body fields:

- `name` (string, required)
- `clientId` (string)
- `status` (string) — Default DRAFT.
- `notes` (string)

Example request body:

```json
{
  "name": "Warehouse B framing",
  "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708"
}
```

Example response (201, abridged):

```json
{
  "id": "tk-...",
  "name": "Warehouse B framing",
  "status": "DRAFT"
}
```

### PATCH /api/v1/takeoffs/:id

Update name, client, status or notes.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "COMPLETE"
}
```

Example response (200, abridged):

```json
{
  "id": "tk-...",
  "status": "COMPLETE"
}
```

### POST /api/v1/takeoffs/:id/items

Add a costed line (or a component of a line with parentId). Returns the updated cost breakdown (201).

Body fields:

- `name` (string, required)
- `quantity` (number, required)
- `rate` (number, required) — Unit cost.
- `unit` (string)
- `description` (string)
- `productId` (string) — Catalog item.
- `costType` (string) — e.g. Material, Labor, Equipment, Subcontract.
- `taxRate` (number) — %
- `markupType` ("percent" | "per unit")
- `markupValue` (number)
- `groupId` (string) — Group of this takeoff.
- `parentId` (string) — Makes it a component of that line.
- `calculation` ("per parent unit" | "once") — For components: multiply by the parent quantity, or count once.

Example request body:

```json
{
  "name": "Drywall 5/8\"",
  "quantity": 120,
  "unit": "SHT",
  "rate": 16.4,
  "costType": "Material",
  "taxRate": 5
}
```

Example response (201, abridged):

```json
{
  "subtotal": 14808,
  "tax": 740.4,
  "total": 15548.4,
  "lines": [ ... ]
}
```

## Equipment

Equipment and tools (stock records of products whose type is Equipment): custody (check-out / check-in), maintenance and inspection logs, and requirements with next-due dates. Logging maintenance against a requirement moves its last / next service dates forward, as in the equipment screen.

### GET /api/v1/equipment

List equipment (logs omitted; fetch one item for its history).

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string) — e.g. Available, Checked Out, In Repair.
- `condition` (string)
- `custodianId` (string)
- `warehouseId` (string)
- `productId` (string)
- `search` (string) — Case-insensitive text match on serial number, model, manufacturer and notes.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "inv-eq-...",
      "productName": "Mini Excavator",
      "serialNumber": "KX040-55821",
      "status": "Available",
      "condition": "Good",
      "nextService": "2026-11-15",
      "maintenanceRequirements": [ { "id": "r1", "name": "Oil change", "frequencyValue": 3, "frequencyUnit": "months", "nextServiceDate": "2026-11-15" } ]
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/equipment/maintenance-due

Maintenance and inspection requirements due within a number of days (overdue ones flagged), soonest first.

Query parameters:

- `withinDays` (integer) — 0-3650, default 30.

Example response (200, abridged):

```json
{
  "cutoffDate": "2026-11-05",
  "data": [
    { "equipmentId": "inv-eq-...", "productName": "Mini Excavator", "kind": "maintenance", "requirementId": "r1", "requirement": "Oil change", "nextServiceDate": "2026-10-30", "overdue": false }
  ]
}
```

### GET /api/v1/equipment/:id

Get one piece of equipment with custody, maintenance and inspection history.

Example response (200, abridged):

```json
{
  "id": "inv-eq-...",
  "productName": "Mini Excavator",
  "status": "Checked Out",
  "custodianId": "user-12",
  "maintenanceLogs": [ { "date": "2026-08-01", "description": "Oil change: 5W-30", "cost": 140 } ],
  "inspectionLogs": [],
  "custodyLogs": [ { "action": "Check Out", "custodianName": "Sam Lee", "checkoutDate": "2026-10-20", "checkinDate": null } ]
}
```

### PATCH /api/v1/equipment/:id

Update equipment details.

Body fields:

- `status, condition, notes` (string)
- `serialNumber, model, manufacturer` (string)
- `purchaseDate` (date)
- `purchasePrice` (number)
- `warrantyYears` (number)
- `lastService, nextService` (date) — General service dates.
- `serviceFreq` (string)
- `serviceVendorId` (string) — Vendor id.
- `serviceVendorName, serviceVendorPhone, serviceVendorAccount, serviceVendorNotes` (string)

Example request body:

```json
{
  "condition": "Needs repair",
  "status": "In Repair"
}
```

Example response (200, abridged):

```json
{
  "id": "inv-eq-...",
  "status": "In Repair"
}
```

### POST /api/v1/equipment/:id/maintenance-logs

Log maintenance work (201). With requirementId, that requirement's last / next service dates are updated.

Body fields:

- `date` (date, required)
- `description` (string, required)
- `requirementId` (string) — From maintenanceRequirements.
- `performedBy` (string) — Default Internal.
- `cost` (number)
- `notes` (string)

Example request body:

```json
{
  "date": "2026-10-22",
  "description": "5W-30, new filter",
  "requirementId": "r1",
  "cost": 140
}
```

Example response (201, abridged):

```json
{
  "entry": { "id": "...", "date": "2026-10-22", "description": "Oil change: 5W-30, new filter", "cost": 140 },
  "equipment": { "id": "inv-eq-...", "maintenanceRequirements": [ { "id": "r1", "lastServiceDate": "2026-10-22", "nextServiceDate": "2027-01-22" } ] }
}
```

### POST /api/v1/equipment/:id/inspection-logs

Log an inspection (201). With requirementId, that inspection requirement's dates are updated.

Body fields:

- `date` (date, required)
- `description` (string, required) — Findings.
- `requirementId` (string) — From inspectionRequirements.
- `performedBy` (string)
- `notes` (string)

Example request body:

```json
{
  "date": "2026-10-22",
  "description": "No defects found",
  "requirementId": "ins-1"
}
```

Example response (201, abridged):

```json
{
  "entry": { "id": "...", "description": "Daily walkaround: No defects found" },
  "equipment": { ... }
}
```

### POST /api/v1/equipment/:id/check-out

Check equipment out to a worker or a client (201). Refused (422) when it is already checked out.

Body fields:

- `custodianType` ("worker" | "client", required)
- `custodianId` (string, required) — Worker (mailbox id) or client id.
- `checkoutDate` (date) — Default today.
- `expectedReturnDate` (date)
- `notes` (string)

Example request body:

```json
{
  "custodianType": "worker",
  "custodianId": "user-12",
  "expectedReturnDate": "2026-10-30"
}
```

Example response (201, abridged):

```json
{
  "id": "inv-eq-...",
  "status": "Checked Out",
  "custodianId": "user-12"
}
```

### POST /api/v1/equipment/:id/check-in

Return checked-out equipment, optionally reporting damage.

Body fields:

- `checkinDate` (date) — Default today.
- `notes` (string)
- `hasDamage` (boolean)
- `damageDescription` (string)

Example request body:

```json
{
  "hasDamage": false,
  "notes": "Cleaned and fueled"
}
```

Example response (200, abridged):

```json
{
  "id": "inv-eq-...",
  "status": "Available"
}
```

## Tasks

Tasks and appointments, optionally linked to a project, service call or facility through sourceType / sourceId.

### GET /api/v1/tasks

List tasks, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `priority` (string)
- `assignedTo` (string)
- `sourceType` (string)
- `sourceId` (string) — e.g. a project id.
- `taskType` (string)
- `search` (string) — Case-insensitive text match on title, description and notes.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "task-...",
      "title": "Order anchor bolts",
      "status": "OPEN",
      "priority": "HIGH",
      "dueDate": "2026-10-25",
      "assignedTo": "user-12",
      "sourceType": "PROJECT",
      "sourceId": "pr0j-..."
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/tasks/:id

Get one task.

Example response (200, abridged):

```json
{
  "id": "task-...",
  "title": "Order anchor bolts",
  "status": "OPEN"
}
```

### POST /api/v1/tasks

Create a task (201).

Body fields:

- `title` (string, required)
- `description, notes` (string)
- `priority` ("LOW" | "MEDIUM" | "HIGH" | "URGENT") — Default MEDIUM.
- `status` (string) — Default OPEN.
- `dueDate` (date)
- `assignedTo` (string) — Mailbox (user) id.
- `sourceType, sourceId` (string) — Link to a record, e.g. PROJECT + project id.
- `taskType` (string) — Default TASK.

Example request body:

```json
{
  "title": "Order anchor bolts",
  "priority": "HIGH",
  "dueDate": "2026-10-25",
  "sourceType": "PROJECT",
  "sourceId": "pr0j-..."
}
```

Example response (201, abridged):

```json
{
  "id": "task-...",
  "title": "Order anchor bolts",
  "status": "OPEN"
}
```

### PATCH /api/v1/tasks/:id

Update only the fields you send. Setting a DONE / COMPLETED status stamps completedAt.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "COMPLETED"
}
```

Example response (200, abridged):

```json
{
  "id": "task-...",
  "status": "COMPLETED",
  "completedAt": "2026-10-22T18:00:00.000Z"
}
```

## Fulfillment

Outgoing shipments, pick lists, carriers, purchase-order receiving and material issues to work orders. Shipments and receipts are created from the app, where they move stock; the API reads them and updates shipment tracking details.

### GET /api/v1/shipments

List shipments, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `salesOrderId` (string)
- `clientId` (string)
- `carrier` (string)
- `search` (string) — Case-insensitive text match on tracking, BOL, carrier and notes.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "shp-...",
      "salesOrderId": "a9b8c7d6-...",
      "carrier": "DHL",
      "trackingNumber": "1Z999AA10123456784",
      "packageCount": 3,
      "createdAt": "2026-10-21T16:00:00.000Z"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/shipments/:id

Get one shipment with its shipped items.

Example response (200, abridged):

```json
{
  "id": "shp-...",
  "trackingNumber": "1Z999AA10123456784",
  "items": [ { "productId": "...", "quantity": 40 } ]
}
```

### PATCH /api/v1/shipments/:id

Update carrier and tracking details.

Body fields:

- `carrier, trackingNumber, bolNumber, carrierAccount, shippingAddress, notes` (string)
- `packageCount, totalWeight` (number)
- `freightType` ("Prepaid" | "Collect")

Example request body:

```json
{
  "trackingNumber": "1Z999AA10123456784",
  "packageCount": 3
}
```

Example response (200, abridged):

```json
{
  "id": "shp-...",
  "trackingNumber": "1Z999AA10123456784"
}
```

### GET /api/v1/pick-lists

Pick lists for sales orders and work orders.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `salesOrderId` (string)
- `workOrderId` (string)
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "pl-...",
      "salesOrderId": "a9b8c7d6-...",
      "status": "PICKING"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/carriers

Shipping carriers and account numbers.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `search` (string) — Case-insensitive text match on name.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "car-...",
      "name": "DHL",
      "accountNumber": "958123"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/receipts

Purchase order receipts (goods received), newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `purchaseOrderId` (string)
- `vendorId` (string)
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "rcv-...",
      "purchaseOrderId": "p0o9i8u7-...",
      "receivedAt": "2026-10-19T14:00:00.000Z",
      "receivedBy": "Sam Lee"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/receipts/:id

Get one receipt with the received lines (quantities, warehouse, batch).

Example response (200, abridged):

```json
{
  "id": "rcv-...",
  "items": [ { "productId": "...", "name": "2x4 Lumber", "quantity": 200, "warehouseId": "wh-main" } ]
}
```

### GET /api/v1/material-issues

Material issued from stock to work orders.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `workOrderId` (string)
- `pickListId` (string)
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "mi-...",
      "workOrderId": "wo-...",
      "issuedAt": "2026-10-20T09:00:00.000Z"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/material-issues/:id

Get one material issue with its lines.

Example response (200, abridged):

```json
{
  "id": "mi-...",
  "items": [ { "productId": "...", "quantity": 12, "batchNumber": null } ]
}
```

## Book Keeping

Read-only access to the books: ledger, journal entries, sales tax ledger, bank transactions, expenses and the chart of accounts. Postings are made in the app (and automatically by invoices and payments), so an integration can never alter the books.

### GET /api/v1/ledger

Ledger lines (income and expenses), newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `type` (string)
- `category` (string)
- `accountName` (string)
- `referenceId` (string) — e.g. an invoice or PO id.
- `classification` (string)
- `search` (string) — Case-insensitive text match on description, notes, vendor invoice and cheque numbers.
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "led-...",
      "date": "2026-10-20",
      "type": "EXPENSE",
      "category": "Materials",
      "description": "Steel Supply Co - INV 4471",
      "amount": 845.5,
      "taxAmount": 42.28,
      "accountName": "Operating Account"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/journal-entries

Double-entry journal entries, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `sourceType` (string) — e.g. INVOICE, PAYMENT.
- `sourceId` (string)
- `search` (string) — Case-insensitive text match on description and notes.
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "je-...",
      "entryNumber": 1203,
      "date": "2026-10-20",
      "description": "Invoice INV-2051",
      "totalAmount": 1050,
      "sourceType": "INVOICE"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/journal-entries/:id

Get one journal entry with its debit / credit lines.

Example response (200, abridged):

```json
{
  "id": "je-...",
  "lines": [
    { "accountCode": "1200", "accountName": "Accounts Receivable", "type": "DEBIT", "amount": 1050 },
    { "accountCode": "4000", "accountName": "Sales", "type": "CREDIT", "amount": 1000 },
    { "accountCode": "2200", "accountName": "GST Payable", "type": "CREDIT", "amount": 50 }
  ]
}
```

### GET /api/v1/tax-ledger

Sales tax collected and paid.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `type` (string)
- `taxName` (string) — e.g. GST.
- `invoiceId` (string)
- `search` (string) — Case-insensitive text match on payee, invoice number and notes.
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "tax-...",
      "date": "2026-10-20",
      "taxName": "GST",
      "taxRate": 5,
      "pretaxAmount": 1000,
      "taxAmount": 50,
      "invoiceNumber": "2051"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/bank-transactions

Imported bank transactions and their reconciliation status.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `reconciled` (boolean)
- `accountName` (string)
- `type` (string)
- `search` (string) — Case-insensitive text match on description, payee and cheque number.
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "bt-...",
      "date": "2026-10-21",
      "description": "EFT ACME CORP",
      "amount": 500,
      "reconciled": 1,
      "matchedLedgerId": "led-..."
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/expenses

All expenses (project and service call), newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `projectId` (string)
- `serviceCallId` (string)
- `category` (string)
- `vendorId` (string)
- `search` (string) — Case-insensitive text match on description, vendor and category.
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "ex-...",
      "projectId": "pr0j-...",
      "amount": 845.5,
      "category": "Materials"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/chart-of-accounts

The company chart of accounts (the standard chart until the company customises it).

Example response (200, abridged):

```json
{
  "data": [ { "code": "1020", "name": "Operating Bank Account", "type": "ASSET", "subType": "Bank" } ]
}
```

## Engineering

Engineering tickets for design changes, technical questions and revisions, linked to products, projects or work orders.

### GET /api/v1/engineering-tickets

List engineering tickets, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `severity` (string) — Low, Medium, High or Critical.
- `productId` (string)
- `projectId` (string)
- `workOrderId` (string)
- `search` (string) — Case-insensitive text match on title, description and notes.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "eng-...",
      "title": "Bracket hole spacing clash",
      "severity": "High",
      "status": "Open",
      "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/engineering-tickets/:id

Get one ticket.

Example response (200, abridged):

```json
{
  "id": "eng-...",
  "title": "Bracket hole spacing clash",
  "status": "Open"
}
```

### POST /api/v1/engineering-tickets

Open a ticket (201).

Body fields:

- `title` (string, required)
- `description, notes` (string)
- `severity` ("Low" | "Medium" | "High" | "Critical") — Default Medium.
- `status` (string) — Default Open.
- `productId, projectId, workOrderId` (string) — Links.
- `resolvedAt` (date)
- `resolvedBy` (string)

Example request body:

```json
{
  "title": "Bracket hole spacing clash",
  "severity": "High",
  "productId": "2f6e1c0a-9b8d-4c7e-a1f2-3b4c5d6e7f80"
}
```

Example response (201, abridged):

```json
{
  "id": "eng-...",
  "status": "Open"
}
```

### PATCH /api/v1/engineering-tickets/:id

Update only the fields you send.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "Resolved",
  "resolvedBy": "J. Patel",
  "resolvedAt": "2026-10-22"
}
```

Example response (200, abridged):

```json
{
  "id": "eng-...",
  "status": "Resolved"
}
```

## Safety & Compliance

Safety incidents, quality non-conformance reports (NCRs), checklist and form templates, filled forms, permits, worker certifications and vendor compliance documents.

### GET /api/v1/safety-incidents

List safety incidents.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `category` (string)
- `projectId` (string)
- `search` (string) — Case-insensitive text match on title, description and location.
- `dateFrom, dateTo` (date) — Range on the incident date.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "si-...",
      "title": "Slip on wet ramp",
      "dateHappened": "2026-10-18",
      "category": "Near Miss",
      "status": "Open",
      "projectId": "pr0j-..."
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/safety-incidents/:id

Get one incident.

Example response (200, abridged):

```json
{
  "id": "si-...",
  "title": "Slip on wet ramp",
  "safetyOfficerActions": "Added anti-slip tape"
}
```

### POST /api/v1/safety-incidents

Report an incident (201).

Body fields:

- `title` (string, required)
- `dateHappened` (date) — Default today.
- `description, location, category, involvedPeople, safetyOfficerActions, recordedBy` (string)
- `status` (string) — Default Open.
- `projectId` (string)

Example request body:

```json
{
  "title": "Slip on wet ramp",
  "dateHappened": "2026-10-18",
  "category": "Near Miss",
  "location": "Loading dock"
}
```

Example response (201, abridged):

```json
{
  "id": "si-...",
  "status": "Open"
}
```

### PATCH /api/v1/safety-incidents/:id

Update only the fields you send.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "Closed",
  "safetyOfficerActions": "Added anti-slip tape"
}
```

Example response (200, abridged):

```json
{
  "id": "si-...",
  "status": "Closed"
}
```

### GET /api/v1/ncrs

List non-conformance reports.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `issueCategory` (string)
- `productId` (string)
- `projectId` (string)
- `search` (string) — Case-insensitive text match on title, description and customer reference.
- `dateFrom, dateTo` (date) — Range on the reported date.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "ncr-...",
      "title": "Weld porosity on frame",
      "disposition": "Rework",
      "cost": 320,
      "status": "Open"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/ncrs/:id

Get one NCR.

Example response (200, abridged):

```json
{
  "id": "ncr-...",
  "rootCause": "Contaminated wire"
}
```

### POST /api/v1/ncrs

Raise an NCR (201).

Body fields:

- `title` (string, required)
- `description, sourceType, issueCategory, disposition, rootCause, reportedBy, customerReference, notes` (string)
- `cost` (number)
- `reportedDate` (date) — Default today.
- `status` (string) — Default Open.
- `productId` (string)

Example request body:

```json
{
  "title": "Weld porosity on frame",
  "issueCategory": "Welding",
  "disposition": "Rework",
  "cost": 320
}
```

Example response (201, abridged):

```json
{
  "id": "ncr-...",
  "status": "Open"
}
```

### PATCH /api/v1/ncrs/:id

Update only the fields you send.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "Closed",
  "rootCause": "Contaminated wire"
}
```

Example response (200, abridged):

```json
{
  "id": "ncr-...",
  "status": "Closed"
}
```

### GET /api/v1/checklist-templates

Checklist templates and their items.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `category` (string)
- `search` (string) — Case-insensitive text match on name and description.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "ct-...",
      "name": "Pre-pour inspection",
      "items": [ ... ]
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/form-templates

Form templates and their fields.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `category` (string)
- `search` (string) — Case-insensitive text match on name and description.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "ft-...",
      "name": "Toolbox talk",
      "fields": [ ... ]
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/form-instances

Filled forms (fields omitted; fetch one for the answers).

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `templateId` (string)
- `category` (string)
- `projectId` (string)
- `workOrderId` (string)
- `serviceCallId` (string)
- `search` (string) — Case-insensitive text match on name and filled by.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "fi-...",
      "name": "Toolbox talk",
      "projectId": "pr0j-...",
      "filledBy": "Sam Lee",
      "filledAt": "2026-10-21T07:05:00.000Z"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/form-instances/:id

Get one filled form with its answers.

Example response (200, abridged):

```json
{
  "id": "fi-...",
  "fields": [ { "label": "Topic", "value": "Ladder safety" } ]
}
```

### GET /api/v1/permits

Permits (metadata, soonest expiry first).

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `category` (string)
- `expiringBefore` (date) — Only permits expiring on/before this date.
- `search` (string) — Case-insensitive text match on file name and category.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "pm-...",
      "fileName": "Building permit 2026-118.pdf",
      "dateIssued": "2026-03-01",
      "expiryDate": "2027-03-01"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/certifications/templates

Certification types (e.g. First Aid, Fall Arrest) and their validity periods.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `search` (string) — Case-insensitive text match on name and issuing authority.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "cert-t-...",
      "name": "Fall Arrest",
      "validityPeriodMonths": 36
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/certifications/roles

Job roles and the certifications each requires.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `search` (string) — Case-insensitive text match on name and description.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "role-t-...",
      "name": "Site Supervisor",
      "certificationTemplateIds": [ "cert-t-..." ]
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/certifications/assignments

Roles assigned to workers.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `userId` (string)
- `roleTemplateId` (string)

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "role-i-...",
      "roleTemplateId": "role-t-...",
      "userId": "user-12",
      "assignedAt": "2026-02-01"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/certifications

Worker certificates with status and expiry, soonest expiry first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `roleInstanceId` (string) — Role assignment id.
- `certificationTemplateId` (string)
- `expiringBefore` (date)
- `search` (string) — Case-insensitive text match on name and licence number.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "cert-i-...",
      "name": "Fall Arrest",
      "status": "Active",
      "completedDate": "2024-01-10",
      "expiryDate": "2027-01-10",
      "licenseNumber": "FA-88213"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/vendor-compliance

Vendor compliance documents (insurance, WCB, W-9…) with expiry tracking.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `vendorId` (string)
- `status` (string)
- `templateId` (string)
- `expiringBefore` (date)
- `search` (string) — Case-insensitive text match on name and reference number.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "vc-...",
      "vendorId": "7b0d3f1e-2a4c-4e59-8d61-0c9a8b7e6f51",
      "name": "Liability insurance",
      "status": "Valid",
      "expiryDate": "2027-01-31"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

## Timesheet

Labour hours. Timesheet entries are daily logs with a worker and hours, optionally against a project and a reference (cost) code.

### GET /api/v1/timesheet/entries

Timesheet entries, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `workerId` (string)
- `projectId` (string)
- `referenceCodeId` (string)
- `search` (string) — Case-insensitive text match on log text.
- `dateFrom` (date) — Only records dated on/after this ISO date.
- `dateTo` (date) — Only records dated on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "dl-...",
      "date": "2026-10-14",
      "workerId": "user-12",
      "projectId": "pr0j-...",
      "hours": 8,
      "startTime": "07:00",
      "finishTime": "15:30",
      "referenceCodeId": "rc-..."
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### POST /api/v1/timesheet/entries

Record hours (201).

Body fields:

- `date` (date, required)
- `content` (string, required) — What was done.
- `hours` (number) — 0-24.
- `startTime, finishTime` (string) — e.g. 07:00.
- `workerId` (string) — Worker (mailbox id from /team-mail/mailboxes).
- `referenceCodeId` (string) — Timesheet reference code id.
- `projectId` (string) — Optional project.

Example request body:

```json
{
  "date": "2026-10-14",
  "content": "Formwork, grid A",
  "hours": 8,
  "workerId": "user-12",
  "projectId": "pr0j-..."
}
```

Example response (201, abridged):

```json
{
  "id": "dl-...",
  "hours": 8
}
```

### GET /api/v1/timesheet/reference-codes

Timesheet reference (cost) codes.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `projectId` (string)
- `isActive` (boolean) — true = active only, false = deactivated only.
- `search` (string) — Case-insensitive text match on code and description.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "rc-...",
      "code": "CONC-01",
      "description": "Concrete forming",
      "isActive": true
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

## Team Mail

Read and send the company's email. Every user is a mailbox; email is sent through that user's connected mail account (or the TrueScope system sender when none is connected), exactly as from the Team Mail screen. Sending is immediate and cannot be undone. An API key can read every mailbox in the company, so keep it as secret as an admin password.

### GET /api/v1/team-mail/mailboxes

Mailboxes (users) that can send and receive: id, name and email only.

Example response (200, abridged):

```json
{
  "data": [ { "id": "user-12", "name": "Sam Lee", "email": "sam@yourcompany.com" } ]
}
```

### GET /api/v1/team-mail/messages

List messages, newest first, with a short text preview.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `mailboxId` (string) — Only this user's mail.
- `unread` (boolean) — true = not yet read.
- `type` (string) — EMAIL or an internal message type.
- `direction` (string) — INBOUND or OUTBOUND.
- `status` (string)
- `search` (string) — Case-insensitive text match on subject, addresses, names and body.
- `dateFrom, dateTo` (date) — Range on the sent date.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "msg-...",
      "type": "EMAIL",
      "direction": "INBOUND",
      "status": "NEW",
      "from": { "userId": null, "name": "Jane Smith", "email": "jane@acme.com" },
      "to": { "userId": "user-12", "name": "Sam Lee", "email": "sam@yourcompany.com" },
      "cc": [],
      "subject": "Quote for Warehouse B",
      "dateSent": "2026-10-22T15:04:00.000Z",
      "preview": "Hi Sam, could you send pricing for…",
      "linkedItems": []
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/team-mail/messages/:id

Get one message with its full HTML body and attachment list.

Example response (200, abridged):

```json
{
  "id": "msg-...",
  "subject": "Quote for Warehouse B",
  "body": "<p>Hi Sam, could you send pricing for…</p>",
  "attachments": [ { "id": "att-...", "fileName": "drawings.pdf", "mimeType": "application/pdf", "fileSize": 482113 } ],
  "linkedItems": [ { "type": "client", "id": "c1a2b3c4-..." } ]
}
```

### POST /api/v1/team-mail/messages

Send an email from a company mailbox (201). linkedItems files the email on those records' Team Mail tabs.

Body fields:

- `fromMailboxId` (string, required) — Mailbox id from /team-mail/mailboxes.
- `to` (string[], required) — Recipient addresses (max 50).
- `cc` (string[])
- `subject` (string, required)
- `body` (string, required) — HTML (plain text also works).
- `linkedItems` (array) — Array of { type, id } with type client, vendor, project, estimate, salesOrder, purchaseOrder, invoice, serviceCall or workOrder.
- `attachments` (array) — Up to 20 files, 25 MB in total. Each is { documentFileId } to attach a file from the Documents library, or { fileName, mimeType, contentBase64 }.

Example request body:

```json
{
  "fromMailboxId": "user-12",
  "to": [
    "jane@acme.com"
  ],
  "subject": "Quote for Warehouse B",
  "body": "<p>Hi Jane, the drawings are attached.</p>",
  "linkedItems": [
    {
      "type": "client",
      "id": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708"
    }
  ],
  "attachments": [
    {
      "documentFileId": "file-3c9d..."
    }
  ]
}
```

Example response (201, abridged):

```json
{
  "id": "msg-...",
  "type": "EMAIL",
  "direction": "OUTBOUND",
  "status": "SENT",
  "to": { "email": "jane@acme.com" }
}
```

### POST /api/v1/team-mail/messages/:id/reply

Reply to a message (201). It goes to the original sender (or, for your own outbound email, to its recipients) with a "Re:" subject, and keeps the original's linked records. replyAll also copies the other recipients.

Body fields:

- `fromMailboxId` (string, required) — Mailbox sending the reply.
- `body` (string, required) — HTML (plain text also works).
- `replyAll` (boolean)
- `cc` (string[]) — Extra cc addresses.
- `subject` (string) — Override the "Re:" subject.
- `attachments` (array) — Same as when sending.

Example request body:

```json
{
  "fromMailboxId": "user-12",
  "body": "<p>Thanks Jane, we can deliver on the 5th.</p>",
  "replyAll": true
}
```

Example response (201, abridged):

```json
{
  "id": "msg-...",
  "direction": "OUTBOUND",
  "subject": "Re: Quote for Warehouse B",
  "to": { "email": "jane@acme.com" }
}
```

### GET /api/v1/team-mail/messages/:id/attachments/:attachmentId

Download an attachment of a message: a downloadUrl, or the bytes as contentBase64 for files stored inline.

Example response (200, abridged):

```json
{
  "id": "att-...",
  "fileName": "drawings.pdf",
  "mimeType": "application/pdf",
  "fileSize": 482113,
  "downloadUrl": "https://truescopeapps.com/api/public/media/9f1e.../raw"
}
```

### POST /api/v1/team-mail/messages/:id/mark-read

Mark a message as read.

Example response (200, abridged):

```json
{
  "id": "msg-...",
  "status": "READ"
}
```

### POST /api/v1/team-mail/messages/:id/mark-unread

Mark a message as unread.

Example response (200, abridged):

```json
{
  "id": "msg-...",
  "status": "UNREAD"
}
```

### GET /api/v1/team-mail/contacts

Saved email contacts.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `userId` (string) — Contacts of one mailbox.
- `search` (string) — Case-insensitive text match on name, email and organization.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "mc-...",
      "name": "Jane Smith",
      "email": "jane@acme.com",
      "organization": "Acme Corp"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

## Documents

The company document library: folders, written documents (rich-text pages such as SOPs and reports) and uploaded files. An API key sees the company folders and public user folders, never a person's private folders. The built-in root folders are company_public (where new items go by default), company_private and user_public. Creating top-level Company folders needs an administrator, so do that in the app.

### GET /api/v1/documents/folders

List folders, sorted by name.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `section` (string) — company or user.
- `parentId` (string) — Sub-folders of one folder (or a root id).
- `search` (string) — Case-insensitive text match on folder name.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "fld-...",
      "name": "Drawings",
      "parentId": null,
      "section": "company",
      "type": "company",
      "isPublic": 1
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/documents/folders/:id/contents

Everything in one folder (a folder id or a root id): sub-folders, documents (without their content) and files.

Example response (200, abridged):

```json
{
  "folderId": "fld-...",
  "folders": [ ... ],
  "documents": [ { "id": "doc-...", "title": "Welding SOP" } ],
  "files": [ { "id": "file-...", "name": "Plan rev B.pdf", "fileType": "pdf", "mimeType": "application/pdf", "size": 482113 } ]
}
```

### GET /api/v1/documents/web-docs

List written documents (title and dates; get one for its content).

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `folderId` (string) — Only this folder.
- `search` (string) — Case-insensitive text match on title.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "doc-...",
      "title": "Welding SOP",
      "folderId": "fld-...",
      "orientation": "portrait",
      "updatedAt": "2026-10-20T15:04:00.000Z"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/documents/web-docs/:id

Get one written document with its HTML content.

Example response (200, abridged):

```json
{
  "id": "doc-...",
  "title": "Welding SOP",
  "content": "<h1>Welding SOP</h1><p>...</p>"
}
```

### POST /api/v1/documents/web-docs

Create a written document (201).

Body fields:

- `title` (string, required)
- `content` (string) — HTML.
- `folderId` (string) — Default company_public.
- `orientation` ("portrait" | "landscape") — Default portrait.

Example request body:

```json
{
  "title": "Inspection report WO-411",
  "content": "<h1>Inspection</h1><p>All 50 parts passed.</p>",
  "folderId": "fld-..."
}
```

Example response (201, abridged):

```json
{
  "id": "doc-...",
  "title": "Inspection report WO-411"
}
```

### PATCH /api/v1/documents/web-docs/:id

Update the title, content or orientation.

Body fields: same as create; all optional.

Example request body:

```json
{
  "content": "<p>Updated</p>"
}
```

Example response (200, abridged):

```json
{
  "id": "doc-...",
  "content": "<p>Updated</p>"
}
```

### GET /api/v1/documents/files

List uploaded files, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `folderId` (string) — Only this folder.
- `fileType` (string) — image, pdf or file.
- `search` (string) — Case-insensitive text match on file name.
- `createdFrom` (date) — Only records created on/after this ISO date.
- `createdTo` (date) — Only records created on/before this ISO date (whole day included).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "file-...",
      "name": "Plan rev B.pdf",
      "originalName": "plan.pdf",
      "fileType": "pdf",
      "mimeType": "application/pdf",
      "size": 482113,
      "folderId": "fld-..."
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/documents/files/:id

Get a file: its details and a downloadUrl (or the bytes as contentBase64 for files stored inline).

Example response (200, abridged):

```json
{
  "id": "file-...",
  "name": "Plan rev B.pdf",
  "mimeType": "application/pdf",
  "size": 482113,
  "downloadUrl": "https://truescopeapps.com/api/public/media/9f1e.../raw"
}
```

### POST /api/v1/documents/files

Upload a file (201), max 25 MB.

Body fields:

- `fileName` (string, required) — e.g. "plan.pdf".
- `mimeType` (string, required) — e.g. "application/pdf".
- `contentBase64` (string, required) — The file bytes, base64-encoded.
- `folderId` (string) — Default company_public.
- `displayName` (string) — Name shown in the library (default fileName).

Example request body:

```json
{
  "fileName": "inspection.pdf",
  "mimeType": "application/pdf",
  "contentBase64": "JVBERi0xLjQK...",
  "folderId": "fld-..."
}
```

Example response (201, abridged):

```json
{
  "id": "file-...",
  "name": "inspection.pdf",
  "fileType": "pdf",
  "size": 18233
}
```

### PATCH /api/v1/documents/files/:id

Rename a file in the library.

Body fields:

- `displayName` (string, required)

Example request body:

```json
{
  "displayName": "Plan rev C.pdf"
}
```

Example response (200, abridged):

```json
{
  "id": "file-...",
  "name": "Plan rev C.pdf"
}
```

## Agreements

Contracts sent to clients and vendors for initials and signatures. Use these endpoints to follow up: each agreement reports its status, swim-lane phase, which required initials, signatures and fields are done or still missing for the current version, any custom terms the signer added, and who it is waiting on (waitingOn + nextAction). Agents can create agreements from templates, rename them, move them between phases, mark them sent, finalize and void them. Signing, initialing, countersigning and accepting a signer's custom terms are only done by people, so the API has no endpoints for them. Statuses: CREATED, SENT, VIEWED (signer opened the link), SIGNED (signer finished, waiting on the company), FINALIZED, DECLINED, VOID. After an addendum, currentRevision goes up and only the addendum's items are required again; earlier signatures stay on record per revision.

### GET /api/v1/agreements

List agreements with their follow-up state, newest first.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `search` (string) — Case-insensitive text match on title, client/vendor name and email, and agreement number.
- `status` (string) — CREATED, SENT, VIEWED, SIGNED, FINALIZED, DECLINED or VOID.
- `phase` (string) — Swim-lane phase id or name, e.g. "Out for Signature".
- `waitingOn` (string) — signer, company or nobody.
- `needsFollowUp` (boolean) — true = waiting on the signer or the company; false = finished, void or declined.
- `partyType` (string) — client or vendor.
- `partyId` (string) — Client or vendor id.
- `sentBefore` (date) — Only agreements sent on/before this ISO date (find ones going stale).

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "0fc8c898-4afa-4572-8276-3ca4197f9e56",
      "number": "AGR-1001",
      "title": "Service Agreement",
      "status": "VIEWED",
      "phase": { "id": "agr-phase-out", "name": "Out for Signature" },
      "party": { "type": "client", "id": "zlrx206ru", "name": "Acme Corp", "email": "jane@acme.com" },
      "currentRevision": 0,
      "progress": { "completed": 1, "required": 4, "isComplete": false },
      "missing": [
        { "key": "0:s2", "kind": "initials", "label": "Initial section 2 Payment Terms", "revision": 0 },
        { "key": "0:sig", "kind": "signature", "label": "Client Signature", "revision": 0 }
      ],
      "customTerms": { "count": 0, "unreviewed": 0 },
      "countersignature": { "required": true, "signed": false, "by": null, "at": null },
      "waitingOn": "signer",
      "nextAction": "remind_signer_to_sign",
      "readyToFinalize": false,
      "daysSinceSent": 3,
      "recipients": [{ "email": "jane@acme.com", "sentAt": "2026-10-22T17:02:11.000Z", "openedAt": "2026-10-23T09:15:40.000Z" }],
      "sentAt": "2026-10-22T17:02:11.000Z",
      "viewedAt": "2026-10-23T09:15:40.000Z",
      "signedAt": null,
      "finalizedAt": null
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/agreements/:id

One agreement with every requirement of every version (who completed it and when), signatures by version (name, time, address, IP), the signer's custom terms, the confirmed client details and the activity log. nextAction is one of send_to_signer, send_addendum, remind_signer_to_open, remind_signer_to_sign, review_custom_terms, countersign, finalize, contact_signer_about_decline or none.

Example response (200, abridged):

```json
{
  "id": "0fc8c898-...",
  "number": "AGR-1001",
  "status": "SIGNED",
  "phase": { "id": "agr-phase-review", "name": "Review & Countersign" },
  "waitingOn": "company",
  "nextAction": "review_custom_terms",
  "requirements": [
    { "key": "party", "kind": "party", "label": "Confirm your details", "revision": 0, "done": true, "completed": { "at": "2026-10-23T09:20:00.000Z", "by": "Jane Smith" } },
    { "key": "0:s1", "kind": "initials", "label": "Initial section 1 Scope", "revision": 0, "done": true, "completed": { "at": "2026-10-23T09:21:00.000Z", "by": "JS", "note": "Work must finish by Dec 1" } },
    { "key": "0:sig", "kind": "signature", "label": "Client Signature", "revision": 0, "done": true, "completed": { "at": "2026-10-23T09:22:00.000Z", "by": "Jane Smith", "address": "12 Main St", "ip": "203.0.113.7", "note": null } }
  ],
  "signaturesByRevision": [
    { "revision": 0, "title": "Original agreement", "signatures": [{ "name": "Jane Smith", "at": "2026-10-23T09:22:00.000Z", "address": "12 Main St", "ip": "203.0.113.7" }], "countersignature": null }
  ],
  "customTermsDetail": [{ "key": "0:s1", "kind": "initials", "revision": 0, "note": "Work must finish by Dec 1", "reviewed": false }],
  "events": [{ "type": "signed", "at": "2026-10-23T09:22:00.000Z", "by": "Jane Smith", "detail": "IP 203.0.113.7", "revision": 0 }]
}
```

### GET /api/v1/agreement-templates

Agreement templates to create agreements from, with the items each one asks the signer for.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "c6689368-4816-4ff6-bfb8-b4c57b6c267d",
      "name": "Service Agreement",
      "description": "",
      "kind": "doc",
      "requiresCountersign": true,
      "requiredItems": [{ "kind": "initials", "label": "Initial section 1 Scope" }, { "kind": "signature", "label": "Client Signature" }],
      "updatedAt": "2026-10-25T17:00:00.000Z"
    }
  ]
}
```

### POST /api/v1/agreements

Create an agreement from a template (status CREATED). With a partyId the client's or vendor's details are filled in; partyInfo overrides individual fields. Returns the agreement (201). It is not sent until you call /send.

Body fields:

- `templateId` (string, required) — From GET /api/v1/agreement-templates.
- `partyType` ("client" | "vendor") — Default client.
- `partyId` (string) — Client or vendor id. Required unless partyInfo.name is given.
- `partyInfo` (object) — { name, contactName, organization, email, phone, address } shown on the agreement.
- `title` (string) — Defaults to the template name.
- `phase` (string) — Swim-lane phase id or name. Defaults to the first lane.
- `requireCountersign` (boolean) — Company countersigns before finalizing. Defaults to the template setting.

Example request body:

```json
{
  "templateId": "c6689368-4816-4ff6-bfb8-b4c57b6c267d",
  "partyType": "client",
  "partyId": "zlrx206ru",
  "title": "Service Agreement - Warehouse B"
}
```

Example response (201, abridged):

```json
{
  "id": "587b3907-...",
  "number": "AGR-1004",
  "status": "CREATED",
  "phase": { "id": "agr-phase-draft", "name": "Drafting" },
  "waitingOn": "company",
  "nextAction": "send_to_signer",
  "recipients": []
}
```

### PATCH /api/v1/agreements/:id

Rename an agreement, move it to another phase, or (before it is sent) change the client or vendor details. Status cannot be set here: use /send, /finalize or /void.

Body fields:

- `title` (string)
- `phase` (string) — Phase id or name, e.g. "Review & Countersign".
- `partyInfo` (object) — Fields to change: name, contactName, organization, email, phone, address. Only before the agreement is sent.

Example request body:

```json
{
  "phase": "Out for Signature"
}
```

Example response (200, abridged):

```json
{
  "id": "587b3907-...",
  "phase": { "id": "agr-phase-out", "name": "Out for Signature" }
}
```

### POST /api/v1/agreements/:id/send

Mark the agreement (or its latest addendum) as sent so the signing link opens for the signer. This does not email anyone: create a personal link for each recipient with POST /api/v1/agreements/:id/links and email each person only their own link, for example with POST /api/v1/team-mail/messages. Calling it again on a SENT or VIEWED agreement records a reminder.

Body fields:

- `to` (string) — Optional note of who it was sent to, kept in the activity log.

Example request body:

```json
{
  "to": "jane@acme.com"
}
```

Example response (200, abridged):

```json
{
  "id": "587b3907-...",
  "status": "SENT",
  "phase": { "id": "agr-phase-out", "name": "Out for Signature" },
  "waitingOn": "signer",
  "nextAction": "remind_signer_to_open"
}
```

### POST /api/v1/agreements/:id/links

Create a signing link for one email address. Each link is personal: initials and signatures made through it are recorded against that email, so send each recipient only their own link. Once anyone signs, every link shows the finished agreement read-only.

Body fields:

- `email` (string) — The recipient the link will be emailed to.

Example request body:

```json
{
  "email": "jane@acme.com"
}
```

Example response (201, abridged):

```json
{
  "email": "jane@acme.com",
  "signingUrl": "https://truescopeapps.com/public/agreements/QEewsJ7GPRoV1cWsz5Di3Izf2kLm"
}
```

### POST /api/v1/agreements/:id/finalize

Finalize a SIGNED agreement. Fails with 409 while required items are missing or a required company countersignature has not been added in the app.

Example response (200, abridged):

```json
{
  "id": "587b3907-...",
  "status": "FINALIZED",
  "phase": { "id": "agr-phase-done", "name": "Completed" },
  "nextAction": "none"
}
```

### POST /api/v1/agreements/:id/void

Cancel an agreement that is not finalized; the signing link stops accepting signatures.

Body fields:

- `reason` (string) — Kept in the activity log.

Example request body:

```json
{
  "reason": "Client chose another supplier"
}
```

Example response (200, abridged):

```json
{
  "id": "587b3907-...",
  "status": "VOID",
  "waitingOn": "nobody",
  "nextAction": "none"
}
```

## Facilities

Facility locations and building sites, their records (issues, notes, work requests) and the equipment located there.

### GET /api/v1/facilities

List facilities, sorted by title.

Query parameters:

- `limit` (integer) — Page size, 1-200 (default 50).
- `offset` (integer) — Number of records to skip (default 0).
- `status` (string)
- `clientId` (string)
- `search` (string) — Case-insensitive text match on title and address.

Example response (200, abridged):

```json
{
  "data": [
    {
      "id": "fac-...",
      "title": "Riverside Plant",
      "address": "12 River Rd",
      "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708",
      "status": "Active"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}
```

### GET /api/v1/facilities/:id

Get one facility with its records.

Example response (200, abridged):

```json
{
  "id": "fac-...",
  "title": "Riverside Plant",
  "records": [ { "type": "ISSUE", "content": "Roof leak above bay 3", "status": "OPEN" } ]
}
```

### GET /api/v1/facilities/:id/equipment

Equipment located at the facility.

Example response (200, abridged):

```json
{
  "data": [ { "id": "inv-eq-...", "productName": "Rooftop unit RTU-2", "status": "Available" } ]
}
```

### POST /api/v1/facilities

Create a facility (201).

Body fields:

- `title` (string, required)
- `address` (string)
- `clientId` (string)
- `status` (string) — Default Active.

Example request body:

```json
{
  "title": "Riverside Plant",
  "address": "12 River Rd",
  "clientId": "c1a2b3c4-5d6e-7f80-91a2-b3c4d5e6f708"
}
```

Example response (201, abridged):

```json
{
  "id": "fac-...",
  "title": "Riverside Plant",
  "status": "Active"
}
```

### PATCH /api/v1/facilities/:id

Update title, address, client or status.

Body fields: same as create; all optional.

Example request body:

```json
{
  "status": "Inactive"
}
```

Example response (200, abridged):

```json
{
  "id": "fac-...",
  "status": "Inactive"
}
```
