> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Global API

> Global Stralos data and authentication API

The Global API owns shared Stralos records and the regional authentication routes that have not yet moved to the login service.

## Base prefix

```text theme={null}
/api/global
```

## Scope

* Tenant lookup, OTP authentication, the legacy OIDC exchange (no web client calls it since the shell retired `/auth/callback`), and regional sign-out-everywhere.
* Organisations and users.
* The Companies register — a unified record for clients, suppliers, and subcontractors.
* Contractor companies: company context, contractor link relays, and the copy and refresh orchestrator.
* Projects.
* Project contract profiles.
* Locations.
* Rate references (batch resolution for the Rates service).
* Rate categories.
* Setting list items.
* Departures and clarifications.
* The immutable unit catalogue.
* Disposal substances.
* Organisation settings.
* Company branding (brand colour and logo).
* Organisation setup status for the Projects setup lock.
* Per-person guided-tour progress.
* App configuration.

## Endpoint groups

| Group | Routes |
| - | - |
| Auth | `GET /auth/tenant/:slug`, `POST /auth/otp/request`, `POST /auth/otp/verify`, `POST /auth/oidc/exchange`, `POST /auth/sessions/revoke-all`; legacy `POST /auth/refresh`, `POST /auth/switch-org`, and `POST /auth/logout` during migration |
| Organisations | `GET /organisations`, `POST /organisations` |
| Org self-service | `GET /org/billing-seat-usage`, `GET /org/organisations`, `PUT /org/organisation`, `GET /org/users`, `POST /org/users`, `POST /org/users/:userId/resend-invite`, `PUT /org/users/:userId`, `DELETE /org/users/:userId` |
| Contractor companies | `GET /org/context`, `GET /org/client-companies`, `POST /org/client-companies`, `POST /org/client-companies/end-all`, `POST /org/client-companies/:linkId/end`, `POST /org/client-companies/:linkId/close`, `GET /org/client-companies/:linkId/people`, `POST /org/client-companies/:linkId/people`, `POST /org/client-companies/:linkId/people/:userId/remove`, `GET /org/contractors`, `POST /org/contractors/invites`, `POST /org/contractors/invites/:linkId/cancel`, `POST /org/contractors/:linkId/end`, `POST /org/contractors/:linkId/take-over`, `GET /org/contractor-invites/:token`, `POST /org/contractor-invites/:token/accept`, `POST /org/contractor-copy/preview`, `POST /org/contractor-refresh/preview`, `POST /org/contractor-copy-jobs`, `GET /org/contractor-copy-jobs/:jobId`, `POST /org/contractor-copy-jobs/:jobId/resume` |
| Users | `GET /users`; retired `POST /users` |
| Companies register | `GET /parties`, `GET /parties/:id`, `POST /parties`, `PUT /parties/:id`, `DELETE /parties/:id`, `GET /parties/:id/contacts`, `POST /parties/:id/contacts`, `PUT /party-contacts/:id`, `DELETE /party-contacts/:id`, `GET /parties/:id/locations`, `POST /parties/:id/locations`, `GET /parties/:id/logo`, `POST /parties/:id/logo-uploads`, `POST /parties/:id/logo-uploads/:uploadId/confirm`, `DELETE /parties/:id/logo` |
| Projects | `GET /projects`, `GET /projects/stats`, `GET /projects/statuses`, `GET /projects/:id`, `POST /projects`, `PUT /projects/:id`, `POST /projects/:id/transition`, `POST /projects/:id/quote-numbers`, `GET /projects/:id/office-location`, `GET /projects/:id/tender-details`, `PUT /projects/:id/search-radius`, `GET /projects/:id/location-routes`, `POST /projects/:id/location-routes/resolve`; retired `DELETE /projects/:id` (`410`) |
| Project access | `GET /project-directory`, `GET /project-access-inbox`, `GET /projects/access-scope`, `GET /projects/:id/team`, `PUT /projects/:id/team/:userId`, `DELETE /projects/:id/team/:userId`, `POST /projects/:id/self-join`, `GET /projects/:id/access-requests`, `POST /projects/:id/access-requests`, `PATCH /projects/:id/access-requests/:requestId`, `GET /projects/:id/access-history` |
| Self-assign locations | `GET /org/self-assign-locations`, `GET /org/users/:userId/self-assign-locations`, `PUT /org/users/:userId/self-assign-locations` |
| Default office | `GET /me/default-office`, `PUT /me/default-office`, `GET /organisation-users/default-offices`, `GET /organisation-users/:userId/default-office`, `PUT /organisation-users/:userId/default-office` |
| Tour progress | `GET /me/tour-progress`, `PUT /me/tour-progress/:tourId` |
| Contract profiles | `GET /projects/:id/contract-profile`, `PUT /projects/:id/contract-profile` |
| Locations | `GET /locations`, `GET /locations/shared-address`, `GET /locations/:id`, `POST /locations`, `PUT /locations/:id`, `DELETE /locations/:id` |
| Rate references | `POST /rate-references/resolve-batch` |
| Rate Categories | `GET /rate-categories`, `GET /rate-categories/:id`, `POST /rate-categories`, `PUT /rate-categories/:id`, `DELETE /rate-categories/:id` |
| Rate Group Settings | `GET /rate-group-settings`, `PUT /rate-group-settings/:rateGroup`, `PATCH /rate-group-settings/:rateGroup/colour` |
| Setting Lists | `GET /setting-list-items`, `GET /setting-list-items/:id`, `POST /setting-list-items`, `PUT /setting-list-items/:id`, `DELETE /setting-list-items/:id` |
| Departures and clarifications | `GET /departures-clarifications`, `GET /departures-clarifications/groups`, `GET /departures-clarifications/:id`, `POST /departures-clarifications`, `PATCH /departures-clarifications/:id`, `DELETE /departures-clarifications/:id`, `POST /departures-clarifications/:id/restore` |
| Unit catalogue | `GET /unit-catalogue` |
| Disposal substances | `GET /disposal-substances`, `POST /disposal-substances`, `PUT /disposal-substances/:id`, `DELETE /disposal-substances/:id` |
| Organisation settings | `GET /organisation-settings`, `PUT /organisation-settings`, `PATCH /organisation-settings/onboarding` |
| Company branding | `GET /organisation-branding`, `POST /organisation-branding/logo-uploads`, `POST /organisation-branding/logo-uploads/:uploadId/confirm`, `DELETE /organisation-branding/logo` |
| Organisation setup | `GET /organisation-setup` |
| App Config | `GET /app-config` |
| Version | `GET /version` |

The shell sends refresh, organisation-switch, and logout requests to the central login service at `/api/login/auth/*`. Do not use the Global API's legacy implementations for new browser integrations. See [Authentication](https://help.stralos.app/api/authentication.md#auth-endpoints).

## Tenant lookup

`GET /auth/tenant/:slug` resolves a projected workspace slug before sign-in so the login host can determine the organisation's home region.

```text theme={null}
GET /api/global/auth/tenant/:slug
```

The endpoint requires no bearer token. `slug` must contain between 1 and 63 characters. A best-effort in-memory limiter allows 60 requests per IP address per minute on each running API instance.

A successful response contains only the region key:

```json theme={null}
{ "regionKey": "au-nz" }
```

An unknown slug returns `404` with `{ "message": "Workspace not found." }`. This distinction is deliberate: workspace slugs are enumerable and are not treated as secrets. The response does not disclose the organisation's name, ID, or status.

## Organisations

`GET /organisations` is admin-only. `POST /organisations` returns `403` — organisation creation is frozen until org-security M6; the initial organisation is seeded directly.

### timezone field

Each organisation record includes a `timezone` field containing an IANA timezone identifier (e.g. `Pacific/Auckland`). This field defaults to `Pacific/Auckland`. The organisation time zone is changed through `PUT /org/organisation` (which relays the update to `stralos-admin-api`); the value reaches the regional projection through the admin event pipeline, so `GET /organisation-settings` may still report the previous value for a short time after an update.

The organisation time zone is the fallback for every project that has not set its own `timezone` override. Stralos uses the resolved effective time zone to interpret calendar-day boundaries for the project stats win-rate window and for date-only comparisons in estimates, rates, and the scheduler.

## Organisation user self-service

The `/org/users` routes let an organisation admin manage its own members directly from the shell. The Global API relays each request to `stralos-admin-api`, which owns the data and every rule behind it. Readable responses keep their upstream status and body. Unreadable or empty responses receive a safe replacement body; an unreadable successful response becomes `502`, while an unreadable error response keeps its upstream status.

A tenant user token is required. Platform tokens and service tokens without an organisation context return `401` or `403`.

Write operations (`POST /org/users`, `PUT /org/users/:userId`, `PUT /org/organisation`, `POST /org/users/:userId/resend-invite`) are subject to a billing entitlement check when billing enforcement is enabled in the deployment. Requests that do not satisfy the entitlement check are refused before reaching `stralos-admin-api`.

### GET /org/billing-seat-usage

Returns the caller's organisation's occupied seat count. The Global API relays the request to `stralos-admin-api`, which owns billing state.

```text theme={null}
GET /api/global/org/billing-seat-usage
```

This endpoint is available to organisation administrators only. It remains accessible after product access has been blocked due to a billing grace period expiry, so an administrator can check the seat count and adjust their subscription without needing full product access. The endpoint returns no user directory records.

| Status | Meaning |
| - | - |
| `200` | Occupied seat count for the caller's organisation. |
| `401` | No authentication token supplied. |
| `403` | The caller is not an organisation administrator. |
| `502` | The relay could not reach `stralos-admin-api`, or `stralos-admin-api` returned an unreadable successful response. Unreadable error responses keep their upstream status with a safe replacement body. |
| `502` | The relay could not reach `stralos-admin-api`, or `stralos-admin-api` returned an unreadable successful response. |
| `503` | The relay is not configured on this deployment. |

### GET /org/organisations

Returns the signed-in person's active organisations. The Global API relays the request to `stralos-admin-api`, which owns membership state. Readable responses from `stralos-admin-api` keep their status and body. Transport failures and unreadable successful responses become safe `502` responses.

```text theme={null}
GET /api/global/org/organisations
```

This endpoint is available to all authenticated tenant users, not just organisation admins. A successful response is `{ items: [...] }`; each entry contains `organisationId`, `organisationName`, `countryCode`, `timezone`, and `role`. Only active memberships in active organisations for the token's signing region are returned, ordered by organisation name and ID. This endpoint remains accessible after product access has been blocked due to a billing grace period expiry, so users can switch to another organisation if they have one.

| Status | Meaning |
| - | - |
| `200` | List of active organisations the caller belongs to. |
| `401` | No authentication token supplied. |
| `403` | The credential is not an authenticated tenant-user token. |
| `502` | The relay could not reach `stralos-admin-api`, or `stralos-admin-api` returned an unreadable successful response. Unreadable error responses keep their upstream status with a safe replacement body. |
| `503` | The relay is not configured on this deployment. |

### PUT /org/organisation

Updates the caller's own organisation. The Global API relays the request to `stralos-admin-api`, which owns the organisation record and enforces the rule: the caller must be an organisation administrator.

```text theme={null}
PUT /api/global/org/organisation
```

The request body accepts the following fields:

| Field | Type | Notes |
| - | - | - |
| `name` | string | New display name for the organisation. |
| `countryCode` | string | Two-letter supported country code for the organisation. |
| `timezone` | string | IANA time zone identifier (e.g. `Pacific/Auckland`). Sets the organisation's default time zone, which all projects inherit unless they set their own `timezone` override. Bare UTC offsets (e.g. `+12:00`) are refused; use canonical IANA names. |

Provide at least one of `name`, `countryCode`, or `timezone`. A successful response includes all three current values. Changes are applied immediately in `stralos-admin-api`; updated values reach this region's projection through an admin event, so `GET /organisation-settings` may still report the previous values for a short time afterwards.

| Status | Meaning |
| - | - |
| `200` | Organisation profile updated. |
| `400` | The request body failed `stralos-admin-api`'s schema validation. |
| `401` | No authentication token supplied. |
| `403` | The caller is not an organisation administrator. |
| `404` | The organisation was not found. |
| `409` | Another organisation already uses that name. |
| `502` | The relay could not reach `stralos-admin-api`, or `stralos-admin-api` returned an unreadable successful response. Unreadable error responses keep their upstream status with a safe replacement body. |
| `503` | The relay is not configured on this deployment. |

### GET /org/users

Returns the list of people in the caller's organisation.

```text theme={null}
GET /api/global/org/users
```

### POST /org/users

Invites a person to the caller's organisation. The request body is forwarded to `stralos-admin-api` unmodified — that service owns the invite schema and returns its own validation messages.

```text theme={null}
POST /api/global/org/users
```

| Status | Meaning |
| - | - |
| `201` | Person invited. |
| `400` | The invite body failed `stralos-admin-api`'s schema validation. |
| `403` | Caller is not an organisation admin. |
| `409` | The person is already a member, has an unverified account in a single-organisation constraint, or the membership would cross regions. |
| `422` | Unprocessable invite (e.g. address or role constraint). |
| `502` | The relay could not reach `stralos-admin-api`, or `stralos-admin-api` returned an unreadable successful response. Unreadable error responses keep their upstream status with a safe replacement body. |
| `503` | The relay is not configured on this deployment. |

### POST /org/users/:userId/resend-invite

Sends a fresh invitation to a person who has not yet accepted theirs. `stralos-admin-api` queues the credential work rather than sending inline, so `202` means the request was accepted, not that the email has been delivered.

```text theme={null}
POST /api/global/org/users/:userId/resend-invite
```

| Status | Meaning |
| - | - |
| `202` | Resend accepted and queued. |
| `403` | Caller is not an organisation admin. |
| `404` | User not found in the caller's organisation. |
| `409` | The person has already accepted their invitation. |
| `502` | The relay could not reach `stralos-admin-api`, or `stralos-admin-api` returned an unreadable successful response. Unreadable error responses keep their upstream status with a safe replacement body. |
| `503` | The relay is not configured on this deployment. |

### PUT /org/users/:userId

Changes a member's display name or role within the caller's organisation. The request body is forwarded to `stralos-admin-api` unmodified — that service owns the schema and enforces all membership rules. Email changes are not permitted through this route; changing an address is always the person's own act.

```text theme={null}
PUT /api/global/org/users/:userId
```

| Status | Meaning |
| - | - |
| `200` | Updated person record. |
| `400` | The body failed `stralos-admin-api`'s schema validation. |
| `403` | Caller is not an organisation admin. |
| `404` | User not found in the caller's organisation. |
| `422` | Unprocessable update. |
| `502` | The relay could not reach `stralos-admin-api`, or `stralos-admin-api` returned an unreadable successful response. Unreadable error responses keep their upstream status with a safe replacement body. |
| `503` | The relay is not configured on this deployment. |

### DELETE /org/users/:userId

Removes a member from the caller's organisation. The Global API relays the request to `stralos-admin-api`, which owns the membership record and enforces all removal rules.

```text theme={null}
DELETE /api/global/org/users/:userId
```

The caller must be an organisation administrator. A tenant user token is required; platform tokens and service tokens without an organisation context return `401` or `403`. The billing entitlement check applies when billing enforcement is enabled.

| Status | Meaning |
| - | - |
| `200` | Member removed. |
| `403` | Caller is not an organisation admin. |
| `404` | User not found in the caller's organisation. |
| `502` | The relay could not reach `stralos-admin-api`, or `stralos-admin-api` returned an unreadable successful response. Unreadable error responses keep their upstream status with a safe replacement body. |
| `503` | The relay is not configured on this deployment. |

## Self-assign locations

Self-assign locations are the organisation's own locations at which a person can join a project without being added to its team. When a project's office is one of the caller's self-assign locations, the project directory returns `canSelfJoin: true` and the caller can join it with `POST /projects/:id/self-join` (see [Project access](#project-access)).

These routes sit under `/org/users/*` but the Global API serves them itself; they are not relayed to `stralos-admin-api`. A tenant user token with an active organisation membership is required.

### GET /org/self-assign-locations

```text theme={null}
GET /api/global/org/self-assign-locations
```

Returns `{ "items": [{ "userId": "uuid", "locationIds": ["uuid"] }] }`. An organisation administrator receives every person's set; a member receives only their own. People with no live self-assign locations are absent from `items`.

### GET /org/users/:userId/self-assign-locations

```text theme={null}
GET /api/global/org/users/:userId/self-assign-locations
```

Returns `{ "userId": "uuid", "locationIds": ["uuid"] }`. An administrator can read anyone's set. A member who requests another person's set receives `403`.

### PUT /org/users/:userId/self-assign-locations

Replaces one person's set. Requires an organisation administrator.

```text theme={null}
PUT /api/global/org/users/:userId/self-assign-locations
```

```json theme={null}
{ "locationIds": ["uuid", "uuid"] }
```

`locationIds` accepts up to 200 UUIDs; duplicates are removed and an empty array clears the set. Every ID must be one of the organisation's own active locations (`partyId` is `null`). Links that leave the set are soft-deleted, so a location can be granted again later. The response is the saved `{ userId, locationIds }`.

| Status | Meaning |
| - | - |
| `200` | Set saved. |
| `403` | The caller is not an organisation administrator. |
| `404` | The person is not a member of the caller's organisation. |
| `422` | `location_invalid`: an ID is unknown, company-owned, inactive or belongs to another organisation. |

## Default office

Each person has a default office per organisation. Stralos resolves the effective default from three sources, in priority order:

| Source | Meaning |
| - | - |
| `chosen` | The person picked a location, and that location is still one of the organisation's own active sites. |
| `first-office` | No choice has been made, or the chosen location is no longer active. The effective default is the organisation's first-created own active location (ordered by `createdAt`, then `id` as the tie-break). |
| `none` | The organisation has no own active locations at all. |

A chosen location that is later retired is not cleared. It stops being effective while the location is inactive and becomes effective again if the location is restored.

Every response from these endpoints includes `locationId` (UUID or `null`) and `source` (`chosen`, `first-office`, or `none`).

### GET /me/default-office

Returns the calling user's effective default office within their current organisation.

```text theme={null}
GET /api/global/me/default-office
```

A tenant user token with an active organisation membership is required.

| Status | Meaning |
| - | - |
| `200` | Effective default office for the caller. |
| `401` | No authentication token supplied. |
| `403` | The token does not carry an organisation context or user identity. |

### PUT /me/default-office

Sets or clears the calling user's chosen default office.

```text theme={null}
PUT /api/global/me/default-office
```

```json theme={null}
{ "locationId": "uuid" }
```

Send `null` for `locationId` to clear the stored choice; the effective default then falls back to `first-office` or `none`. When a non-null ID is provided, it must be one of the organisation's own active locations (`partyId` is `null`); any other value returns `422 invalid_default_office`.

| Status | Meaning |
| - | - |
| `200` | Effective default office after the update. |
| `401` | No authentication token supplied. |
| `403` | The token does not carry an organisation context or user identity. |
| `404` | The caller is not a member of the organisation. |
| `422` | `invalid_default_office`: the location ID is not one of the organisation's own active locations. |

### GET /organisation-users/default-offices

Returns a paginated list of every invited or active member's effective default office. Intended for the Settings → Users panel.

```text theme={null}
GET /api/global/organisation-users/default-offices
```

Requires an organisation administrator token.

| Query parameter | Type | Default | Notes |
| - | - | - | - |
| `start` | integer | `0` | Zero-based offset. |
| `length` | integer | `50` | Number of records to return; clamped to 1–200. |

The response follows the standard list envelope:

```json theme={null}
{
  "items": [
    { "userId": "uuid", "locationId": "uuid", "source": "chosen" },
    { "userId": "uuid", "locationId": "uuid", "source": "first-office" }
  ],
  "totalCount": 42,
  "start": 0,
  "length": 50
}
```

Each item contains `userId`, `locationId` (UUID or `null`), and `source`. Members are ordered by user ID.

| Status | Meaning |
| - | - |
| `200` | Paginated list of effective default offices. |
| `401` | No authentication token supplied. |
| `403` | The caller is not an organisation administrator. |

### GET /organisation-users/:userId/default-office

Returns the effective default office for one member of the caller's organisation. The person must be an invited or active member; a past member returns `404`.

```text theme={null}
GET /api/global/organisation-users/:userId/default-office
```

Requires an organisation administrator token.

| Status | Meaning |
| - | - |
| `200` | Effective default office for the specified user. |
| `401` | No authentication token supplied. |
| `403` | The caller is not an organisation administrator. |
| `404` | The person is not a member of the caller's organisation. |

### PUT /organisation-users/:userId/default-office

Sets or clears the default office for one member of the caller's organisation.

```text theme={null}
PUT /api/global/organisation-users/:userId/default-office
```

```json theme={null}
{ "locationId": "uuid" }
```

Accepts the same body as `PUT /me/default-office`. The same location validation applies: the ID must be one of the organisation's own active locations, or `null` to clear the choice.

| Status | Meaning |
| - | - |
| `200` | Effective default office after the update. |
| `401` | No authentication token supplied. |
| `403` | The caller is not an organisation administrator. |
| `404` | The person is not a member of the caller's organisation. |
| `422` | `invalid_default_office`: the location ID is not one of the organisation's own active locations. |

## Tour progress

Stralos stores each person's guided-tour progress per organisation (migration 0087). Each tour is identified by a `tourId` and records the tour `version` the person last completed or dismissed. The client decides whether to show a tour by comparing the stored version with the tour's own version. The server stores what it is sent; the last write wins and there is no delete route.

Progress is personal. Every read and write uses the caller's user ID and organisation from the token, never from the request, so nobody can read or change another person's progress. A person in two organisations has separate progress in each. A tour with no entry has never been completed or dismissed in that organisation.

Both endpoints require a tenant user token with the `admin` or `member` role. Missing authentication returns `401`; platform and service tokens return `403`.

### GET /me/tour-progress

```text theme={null}
GET /api/global/me/tour-progress
```

Returns the caller's progress in their current organisation as a `tours` object keyed by `tourId`. A person with no progress receives `{ "tours": {} }`.

```json theme={null}
{
  "tours": {
    "rates.library": {
      "version": 1,
      "status": "completed",
      "updatedAt": "2026-10-06T12:34:56.000Z"
    }
  }
}
```

| Field | Type | Notes |
| - | - | - |
| `version` | integer | The tour version last completed or dismissed. |
| `status` | `"completed"` \| `"dismissed"` | How the person finished that version. |
| `updatedAt` | ISO 8601 string | When the entry was last written. |

### PUT /me/tour-progress/:tourId

```text theme={null}
PUT /api/global/me/tour-progress/:tourId
```

Creates or replaces the caller's entry for one tour and returns the saved entry in the same shape as one value of `tours`.

`tourId` is up to 100 characters of lowercase letters and digits, optionally split into parts by single `.` or `-` characters (for example `rates.library` or `a1.b-2`). Any other format returns `400`. An ID longer than 100 characters is refused with `414` before validation.

```json theme={null}
{
  "version": 1,
  "status": "completed"
}
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `version` | integer | Yes | Between `1` and `2147483647`. |
| `status` | `"completed"` \| `"dismissed"` | Yes | Any other value returns `400`. |

The body is strict: unknown keys, such as `userId` or `updatedAt`, return `400`.

| Status | Meaning |
| - | - |
| `200` | Progress saved. The response is the saved entry. |
| `400` | Invalid `tourId` or request body. |
| `401` | No authentication token supplied. |
| `403` | The token is not a tenant user token with an organisation. |
| `404` | The caller is no longer a member of the organisation in their token. Nothing is saved. |
| `414` | The `tourId` is longer than 100 characters. |

## Contractor companies

A contractor company works inside other companies (its client companies) through a company link. Its people join a client company as contractor people: memberships that record the link they came through. The Global API serves the company context and runs the copy and refresh orchestrator. Every link, people and invitation write is relayed to `stralos-admin-api`, which owns those records and every rule behind them.

See [Contractor companies](https://help.stralos.app/internal/org-security.md#contractor-companies) for the data model and [Contractor copy and refresh](https://help.stralos.app/internal/contractor-copy.md) for how copy jobs run.

### GET /org/context

Returns what kind of company the caller is in and who the caller is there, so the shell and the assistant can adapt without a round trip to `stralos-admin-api`.

```text theme={null}
GET /api/global/org/context
```

Requires a signed-in user's token with an organisation. A service token returns `403 user_token_required`.

| Field | Type | Notes |
| - | - | - |
| `companyId` | UUID | The caller's company. |
| `companyName` | string | The company's name. |
| `plan` | `"standard"` \| `"contractor"` | Read from the billing entitlements table. If that read fails, the token's `plan` claim is used, then `"standard"`. |
| `viewer.kind` | `"own"` \| `"contractor"` | `"own"` for the company's own people; `"contractor"` for a contractor person. |
| `viewer.contractorOrgId` | UUID | Only when `viewer.kind` is `"contractor"`: the contractor company the caller came through. |
| `viewer.contractorName` | string | Only when `viewer.kind` is `"contractor"`: that contractor company's name. |
| `contractors` | array | The company's active contractor links (`contractorOrgId`, `contractorName`). Always empty for a contractor person, so two contractors in one company never see each other. |
| `createdByContractorName` | string \| null | The contractor company that created this company, or `null` for a company that signed up itself. Always `null` for a contractor person. |

### Contractor link relays

These routes relay to `stralos-admin-api` over SigV4, the same relay as [organisation user self-service](#organisation-user-self-service), with the same status handling. `stralos-admin-api` decides who may call each one: the client-company routes need an admin of a contractor company acting in their own right, and the contractor routes need one of the company's own admins. See [Admin console: contractor companies](https://help.stralos.app/internal/admin-console.md#contractor-companies).

| Route | Action |
| - | - |
| `GET /org/client-companies` | List the companies the caller's contractor company works in. |
| `POST /org/client-companies` | Create a company for a client. |
| `POST /org/client-companies/end-all` | End every live link the contractor company has. Used when it changes back to a standard company. |
| `POST /org/client-companies/:linkId/end` | End one link from the contractor's side. |
| `POST /org/client-companies/:linkId/close` | Close a free company the contractor created and still runs, ending its link. |
| `GET /org/client-companies/:linkId/people` | List the contractor's own people and whether each works in that company. |
| `POST /org/client-companies/:linkId/people` | Add one of the contractor's people to that company. |
| `POST /org/client-companies/:linkId/people/:userId/remove` | Remove one of the contractor's people from that company. |
| `GET /org/contractors` | List the contractor companies linked to the caller's company. |
| `POST /org/contractors/invites` | Send a contractor company a link by email. |
| `POST /org/contractors/invites/:linkId/cancel` | Cancel a link that has not been accepted. |
| `POST /org/contractors/:linkId/end` | End a link from the company's side. |
| `POST /org/contractors/:linkId/take-over` | Take over a company the contractor created, once it has its own billing. |
| `GET /org/contractor-invites/:token` | Read an invitation for the accept page. Any signed-in user. |
| `POST /org/contractor-invites/:token/accept` | Accept an invitation as an admin of the invited contractor company. |

### Contractor copy and refresh

A contractor admin can copy chosen parts of their home (contractor) company into a client company, then later refresh it one way, from home to the client company. These routes run in the client company's session. The caller must be a contractor person whose token came through an active link from `sourceOrgId`, and an admin of that contractor company.

| Status | Code | Meaning |
| - | - | - |
| `403` | `contractor_copy_not_allowed` | The caller is not a contractor person from `sourceOrgId`. |
| `403` | `contractor_admin_required` | The caller is not an admin of the contractor company. |
| `403` | `contractor_access_ended` | The link is no longer active. |

#### POST /org/contractor-copy/preview

Counts what a copy would bring, without writing anything. The body is the same as a copy job's, without `kind` and `apply`. The response carries `groups` (`total` and `coming` per group), `locations` (`ratesComing` per home location), `skipped` counts, and `options` (the suppliers, rate categories and activities that can be chosen individually).

#### POST /org/contractor-refresh/preview

Lists what changed at home since the copy, without writing anything. The body is `{ sourceOrgId, groups }`. The response carries `groups`, each with `changed` rows (the home value beside the client company's value) and `new` rows, and `locationRates`:

| Field | Notes |
| - | - |
| `locationRates.source` | `"copy"` when the newest successful copy's location choices apply; `"none"` when the company was never copied into (for example, it was linked by invitation), so prices set at the contractor's own locations are not offered. |
| `locationRates.message` | A sentence for the refresh page explaining which location prices come across. |

Rows the client company created itself never appear.

#### POST /org/contractor-copy-jobs

Starts a copy or refresh job and runs its first slice. Returns `202` with `{ jobId, status }`.

| Field | Type | Notes |
| - | - | - |
| `kind` | `"copy"` \| `"refresh"` | An initial copy, or a one-way refresh. |
| `sourceOrgId` | UUID | The contractor company to copy from. |
| `groups` | object | Booleans for `suppliers`, `rates`, `rateCategories`, `companySettings` and `activities`. Choosing `activities` also turns on `rates`. |
| `supplierIds`, `rateCategoryIds`, `activityIds` | UUID\[] | Optional subsets. |
| `locationMap` | array | Copy only: `{ sourceLocationId, rateSourceLocationId }` pairs choosing which home location's prices each copied location takes (`null` for none). A refresh ignores any map it is sent and reuses the newest successful copy's. |
| `apply` | array | Refresh only: the ticked rows, each `{ group, sourceId, action }` with `action` `"update"` or `"create"`. `group` may also be `"locations"` for home locations added since the copy. |

Only one queued or running job is allowed per client company. Another start returns `409 copy_job_in_progress`.

#### GET /org/contractor-copy-jobs/:jobId

Returns the job: `jobId`, `kind`, `sourceOrgId`, `status`, `steps` (each with `step`, `status` and `counts`), `summary` and `error`. Polling a queued or running job advances it by one slice. Only the person who started the job, or one of the company's own admins, may poll; anyone else receives `403 copy_job_not_yours`. An unknown job returns `404`.

#### POST /org/contractor-copy-jobs/:jobId/resume

Resumes a `failed` job from its failed step, runs a slice and returns `202` with the job. A job in any other state is left as it is. Uses the same copy authority as starting a job.

## Companies register

The Companies register unifies clients, suppliers, and subcontractors into one record. A company holds one to three roles as boolean flags (`isClient`, `isSupplier`, `isSubcontractor`). A business can hold any combination — for example, a company can be both a supplier and a subcontractor.

The legacy `/clients`, `/suppliers`, and `/contractors` API routes were removed by migration 0049. Use `/parties` with the appropriate `role` filter.

### Access control

Reads (`GET /parties`, `GET /parties/:id`, `GET /parties/:id/contacts`, `GET /parties/:id/locations`) require any authenticated organisation member token (user or service).

Write operations (`POST /parties`, `PUT /parties/:id`, `DELETE /parties/:id`, `POST /parties/:id/contacts`, `PUT /party-contacts/:id`, `DELETE /party-contacts/:id`, `POST /parties/:id/locations`) require a **user token** with organisation admin role. Service tokens are not accepted for register writes, even when they carry an organisation context. An admin role is required; member tokens return `403`.

### Company fields

Every company response includes:

| Field | Type | Notes |
| - | - | - |
| `id` | UUID | Server-assigned. |
| `name` | string | Display name, maximum 200 characters. |
| `tradingName` | string \| null | Optional trading name, maximum 200 characters. |
| `businessNumber` | string \| null | NZBN — exactly 13 digits. Optional; must be unique within the organisation when set. |
| `isClient` | boolean | Whether the company acts as a client. |
| `isSupplier` | boolean | Whether the company acts as a supplier. |
| `isSubcontractor` | boolean | Whether the company acts as a subcontractor. |
| `approvalStatus` | string | Subcontractor prequalification status: `not_applicable`, `pending`, `approved`, or `suspended`. Defaults to `not_applicable`. Advisory only — approval status does not block pricing or project assignment. |
| `approvedUntil` | string \| null | Prequalification expiry date in `YYYY-MM-DD` format. Only valid when `approvalStatus` is `approved`. |
| `phone` | string \| null | Optional phone number, maximum 50 characters. |
| `email` | string \| null | Optional email address, maximum 254 characters. |
| `colour` | string \| null | Optional hex colour in `#RRGGBB` format. Inherited by projects and schedules when not overridden. |
| `status` | string | `active` or `inactive`. |
| `rateSource` | — | Removed. This field is no longer present in any company response. Use `isSupplier && status === "active"` to determine whether a company can be priced from. |
| `createdAt` | ISO 8601 | |
| `updatedAt` | ISO 8601 | |

List responses from `GET /parties` include two additional fields per row:

| Field | Type | Notes |
| - | - | - |
| `locationCount` | integer | Number of effectively active locations owned by this company. Matches the count shown in the company record's Locations panel (`GET /parties/:id/locations?status=active`). A retired company always returns `0` because its locations are inactive by association. |
| `contactCount` | integer | Number of active contacts for this company. Deleted (inactive) contacts are not counted. |

Each count matches what the company record's own panel lists: `locationCount` counts effectively active locations only (own-status active on a live company, zero on a retired one), and `contactCount` counts active contacts only because `GET /parties/:id/contacts` hides inactive ones.

### GET /parties

Returns a paginated list of companies for the caller's organisation. Defaults to active companies. Each row includes `locationCount` and `contactCount` in addition to the standard company fields.

| Query parameter | Type | Default | Notes |
| - | - | - | - |
| `role` | `client` \| `supplier` \| `subcontractor` | — | Filters to companies holding that role. |
| `status` | `active` \| `inactive` | `active` | Pass `inactive` to list retired companies. |
| `search` | string | — | Case-insensitive partial match on `name` and `businessNumber`. |
| `start` | integer | `0` | Zero-based offset. |
| `length` | integer | `50` | Number of records to return; clamped to 1–200. |

### POST /parties

Creates a company. Returns `201` with the created record.

| Field | Type | Required | Notes |
| - | - | - | - |
| `name` | string | Yes | Maximum 200 characters. Must be unique within the organisation among active companies. |
| `tradingName` | string \| null | No | Maximum 200 characters. |
| `businessNumber` | string \| null | No | Exactly 13 digits (NZBN). Must be unique within the organisation among active companies. |
| `isClient` | boolean | No | Defaults to `false`. |
| `isSupplier` | boolean | No | Defaults to `false`. When `true`, the company can be selected as a pricing source in the Rates picker. |
| `isSubcontractor` | boolean | No | Defaults to `false`. |
| `approvalStatus` | string | No | One of `not_applicable`, `pending`, `approved`, or `suspended`. Defaults to `not_applicable`. |
| `approvedUntil` | string \| null | No | Optional expiry date. When set, `approvalStatus` must be `approved`. |
| `phone` | string \| null | No | |
| `email` | string \| null | No | |
| `colour` | string \| null | No | Hex colour in `#RRGGBB` format. |

At least one of `isClient`, `isSupplier`, or `isSubcontractor` must be `true`. Sending all three as `false` returns `409` with code `party_role_required`.

`POST /parties` also accepts two optional objects, so a new client can be created with its main contact and first address in one transaction. `PUT /parties/:id` does not accept them; edit contacts and locations through their own routes.

* `mainContact`: `{ name, phone, email, role }`, with the same rules as `POST /parties/:id/contacts` (see [Company contacts](#company-contacts)) apart from `locationId`. It must have a phone or an email, otherwise the create returns `422 contact_reach_required`.
* `address`: `{ name, kind, addressLine1, city, region, countryCode, latitude, longitude, phone, email, gstNumber }`. `name` defaults to `Office` and `kind` to `office`. The address must be a complete picked one, with an address line and coordinates, otherwise the create returns `422 location_address_required`. The location is created as a site of the new company.

The `201` response carries `mainContact` (the created contact, or `null`) and `location` (the created location with `sharedAddressWith`, or `null`) beside the company fields. See [Shared address warning](#shared-address-warning).

The `website` field is not part of the company contract. A body that still sends `website` has it stripped rather than refused, and company responses no longer include it.

### PUT /parties/:id

Replaces a company's fields. Accepts the same body as `POST /parties`.

Removing a role that is still in use returns `409` with code `role_in_use`:

* Removing `isClient` is blocked when the company is assigned to one or more projects.

This check is advisory — it is not transactional across concurrent requests. A role is a filter label, not a permission: a project bound to a company that lost `isClient` still prices and exports correctly but no longer appears in the Clients filter.

Removing `isSupplier` does not affect historic rates. Existing prices stored against this company continue to resolve because the company's id and row are permanent.

### DELETE /parties/:id

Retires the company by setting `status` to `inactive`. Returns `204`. The record is retained so historic estimates continue to render.

Retiring a company writes nothing onto its locations and contacts. They stop being usable by association: while the company is retired, each of its locations reads an effective `status` of `inactive` (see [Location fields](#location-fields)), and supplier routing skips its places. Reinstating a company is a future PRD: there is no path to do it yet. Because retiring writes nothing onto the children, a future reinstate needs no child writes: every child whose own status is `active` would be usable again, and a location or contact retired on its own would stay retired.

External usage blocks retirement:

* Any project that has this company as its client returns `409` with code `party_in_use`.

### Uniqueness

Within an organisation, company names must be unique among active records. Retiring a company frees its name and NZBN for a replacement record.

### Supplier pricing

When `isSupplier` is `true` and `status` is `active`, the company is available as a pricing source in the Rates picker. `isSupplier` is the sole supplier-role fact; current selection also requires the company to be active. There is no separate managed rate source object, and the company id is used directly as the supplier reference in rate options.

## Company contacts

Each company can have multiple contacts. Contacts are a child collection and are returned unpaginated.

### GET /parties/:id/contacts

Returns the company's active contacts, ordered by name. A deleted contact is `inactive` and is left out. The company's own status is not applied: a retired company still lists its active contacts, and the company's retired status is what says not to use them.

Each contact record contains:

| Field | Type | Notes |
| - | - | - |
| `id` | UUID | Server-assigned. |
| `partyId` | UUID | The company this contact belongs to. |
| `name` | string | Display name, maximum 200 characters. |
| `email` | string \| null | Optional. Must be a plausible email address when present. |
| `phone` | string \| null | Optional. Must be a plausible phone number when present. |
| `role` | string \| null | The contact's role or title at the company, maximum 120 characters. |
| `locationId` | UUID \| null | The company location this contact is based at, or `null` for company-level contacts. |
| `status` | string | `active` or `inactive`. |
| `createdAt` | ISO 8601 | |
| `updatedAt` | ISO 8601 | |

### POST /parties/:id/contacts

Creates a contact for the company. Returns `201`.

| Field | Type | Required | Notes |
| - | - | - | - |
| `name` | string | Yes | Maximum 200 characters. |
| `email` | string \| null | No | When present, must be a plausible email address (for example, `user@example.com`). Sending an empty string or `null` stores `null`. |
| `phone` | string \| null | No | When present, must be a plausible phone number containing between 6 and 20 digits, optionally formatted with spaces, hyphens, parentheses, dots, or a leading `+` (for example, `021 123 4567` or `+64 21 123 4567`). Sending an empty string or `null` stores `null`. |
| `role` | string \| null | No | Maximum 120 characters. |
| `locationId` | UUID \| null | No | Must be a location owned by this company. A location belonging to a different company returns `409`. |

Blank (or null) values for `email` and `phone` are always accepted. The same format rules apply to `PUT /party-contacts/:id`.

### PUT /party-contacts/:id

Replaces a contact. Accepts the same fields as `POST /parties/:id/contacts`, including the email and phone format constraints.

### DELETE /party-contacts/:id

Deletes the contact and returns `204`. The row is kept for history with `status: inactive`, and it leaves `GET /parties/:id/contacts` and the register's `contactCount`. Deleting a contact that is already inactive also returns `204`.

## Company locations

`GET /parties/:id/locations` lists the locations owned by a company, ordered by name. This is a child collection and is returned unpaginated.

The organisation's own sites carry a null `partyId`; company-owned locations carry the company's id as `partyId`. `GET /locations` with `owner=all` (the default) includes both kinds. Pass `owner=theirs` to retrieve only company-owned locations. This child endpoint narrows the list to one specific company regardless of the `owner` parameter.

Each location in the response follows the standard location shape. See [Locations](#locations) for the full field list. Each row's `status` is the effective status and `ownStatus` is the location's own.

| Query parameter | Type | Default | Notes |
| - | - | - | - |
| `status` | `active` \| `inactive` | — | Filters on the effective status. `active` omits retired locations and every location of a retired company. Omit it to list every status. |

The company record's Locations panel passes `?status=active`. Omitting the parameter lists every status, which is the historical behaviour used by other callers.

### POST /parties/:id/locations

Creates a location owned by the specified company. Returns `201` with the created location record.

The company id comes from the URL path, not the request body. `POST /locations` creates sites belonging to the organisation itself — it has no way to assign a location to a company. Use this route when adding branches, quarries, offices, or other sites that belong to a specific company.

When the location is created, it is immediately available in the Rates picker as a location for that company.

| Field | Type | Required | Notes |
| - | - | - | - |
| `name` | string | Yes | Maximum 200 characters. |
| `kind` | string | Yes | One of `office`, `yard`, `depot`, `branch`, `quarry`, or `other`. |
| `addressLine1` | string | Yes | Maximum 300 characters. Must not be blank. |
| `city` | string \| null | No | |
| `region` | string \| null | No | |
| `countryCode` | string | No | Two-character ISO country code. Defaults to `NZ`. |
| `latitude` | number | Yes | Must be supplied together with `longitude`. |
| `longitude` | number | Yes | Must be supplied together with `latitude`. |
| `status` | string | No | `active` or `inactive`. Defaults to `active`. |

| Status | Meaning |
| - | - |
| `201` | Location created. Returns the created location record. |
| `400` | Validation error — for example, only one of `latitude` or `longitude` was supplied. |
| `422` | `location_address_required`: `addressLine1` is blank, or the coordinates are missing. The same rule applies to every location write; see [Locations](#locations). |
| `401` | No authentication token supplied. |
| `403` | The authenticated token has no organisation and cannot modify tenant data. |
| `404` | Company not found, or belongs to a different organisation. |

The new location does not appear in `GET /locations?owner=ours`. Use `GET /parties/:id/locations` or `GET /locations?owner=theirs` to retrieve company-owned locations.

## Client logos

A company in the register can have one active logo. A Tender download PDF prints it beside the client's name when that company is the project's client. The flow and file rules match the [company branding](#company-branding) logo: PNG or JPEG up to 10 MB, a resized rendition (longest side at most 1,200 px, at most 512 KiB), and a soft remove.

| Method | Path | Caller | Purpose |
| - | - | - | - |
| `GET` | `/parties/:id/logo` | Any organisation caller | `{ logo }`: the active logo with a five-minute download URL, or `null` |
| `POST` | `/parties/:id/logo-uploads` | Any organisation user | Issue a presigned upload. Same body and response as `POST /organisation-branding/logo-uploads` |
| `POST` | `/parties/:id/logo-uploads/:uploadId/confirm` | Any organisation user | Validate the upload and make it the active logo. Returns `{ logo }` |
| `DELETE` | `/parties/:id/logo` | Any organisation user | Soft-remove the active logo. Returns `{ "logo": null }` |

Admins and members can both write client logos, following the register's own write rule; a tenant service token is refused. A retired company's logo cannot be changed: the write routes return `409 party_retired`. An unknown company returns `404`. Upload errors use the codes in [Branding errors](#branding-errors). `GET /parties/:id` also carries the active `logo`.

## Project fields

Every project response includes the following fields alongside standard metadata:

| Field | Type | Notes |
| - | - | - |
| `clientId` | UUID | The company this project belongs to. The write verifies the company belongs to the caller's organisation; it does not re-check `isClient`, so existing project links survive later role changes. |
| `name` | string | Project name. |
| `reference` | string \| null | The client's reference for this project. Optional — `null` when no reference has been set. Unique per organisation when set. |
| `quoteNumber` | string \| null | The project's initial quote number, allocated by the server inside the create transaction. Each estimate alternative and saved version has its own quote number, held by api-estimate. Unique per organisation. |
| `jobNumber` | string \| null | The organisation's internal job number. Allocated by the server when an estimate on the project is marked Won and the project moves to `awarded`; `null` until then. Unique per organisation when set. |
| `jobCreatedAt` | string \| null | ISO 8601 time the job number was allocated. `null` while the project has no job. |
| `status` | string | Server-managed lifecycle stage. |
| `startDate` | string \| undefined | Optional start date in `YYYY-MM-DD` format; omitted when unset. |
| `targetSubmissionDate` | string \| undefined | Optional target submission date in `YYYY-MM-DD` format; omitted when unset. |
| `endDate` | string \| null | Optional planned end date in `YYYY-MM-DD` format. |
| `colour` | string \| null | Optional hex colour in `#RRGGBB` format. |
| `timezone` | string \| null | The project's IANA time zone override. `null` means the project follows the organisation's time zone. |
| `effectiveTimezone` | string | The resolved time zone for this project: the project's own `timezone` if set, otherwise the organisation's `timezone`, otherwise `Pacific/Auckland`. Always present and never `null`. |
| `officeLocationId` | string \| null | The organisation's own location the project is run from. `null` for projects created before office locations existed. |
| `officeLocation` | object \| null | `{ id, name }` for the office, or `null` when no office is set. |
| `preferResourcesFromOffice` | boolean | The stored "prefer resources from this office" setting. Defaults to `true`. It is recorded and displayed only; no scheduling or resourcing logic reads it yet. |
| `searchRadiusKm` | number \| null | The project's Search distance in kilometres, or `null` when none has been chosen. Pricing's default tier picks the cheapest measured supplier site within this distance; with `null` it steps through 50, 100, then 150 km. Set it with `PUT /projects/:id/search-radius`; see [Project search distance](#project-search-distance). |
| `daysPerWeek` | number \| null | The project's working days per week, used to convert weekly rates. `null` means the project follows the organisation's `defaultDaysPerWeek`. |
| `daysPerMonth` | number \| null | The project's working days per month, used to convert monthly rates. `null` means the project follows the organisation's `defaultDaysPerMonth`. |
| `effectiveDaysPerWeek` | number | Present only on `GET /projects/:id`. The project's `daysPerWeek` if set, otherwise the organisation's `defaultDaysPerWeek`, otherwise `5`. |
| `effectiveDaysPerMonth` | number | Present only on `GET /projects/:id`. The project's `daysPerMonth` if set, otherwise the organisation's `defaultDaysPerMonth`, otherwise `20`. |
| `clientContactId` | UUID \| null | The client's main contact for this project. `null` for a project without one. |
| `clientContact` | object \| null | Present on `GET /projects/:id` and on the `POST /projects` and `PUT /projects/:id` responses. `{ id, name, email, phone, role, status }`, or `null` when no contact is set. A contact deleted since it was chosen reads `status: "inactive"`. |
| `lostReason` | object \| null | Present only on `GET /projects/:id`. `null` unless the project is currently `lost`. For a lost project, `{ category, label, note, at }` from the latest committed move to `lost`: `category` is the stored reason key (for example `price`), or `null` when none was recorded; `label` is the reason as the **Mark lost** dialog words it (for example `"Price"`), or `"Not recorded"`; `note` is the optional note, or `null`; `at` is when the project was lost (ISO 8601 UTC). |
| `capabilities` | object | Present only on `GET /projects/:id`. Describes what the calling user may do on this project. See [Project capabilities](#project-capabilities). |

### Project capabilities

`GET /projects/:id` includes a `capabilities` object that tells the caller — and the UI — which actions are available to them on this project. The API still enforces each action itself; `capabilities` is informational and uses the same predicate as the action's own gate, so the two cannot drift.

| Field | Type | Notes |
| - | - | - |
| `markWon` | boolean | `true` when the caller may submit `mark_won` for this project. True for an active org member who is an org admin or holds the lead role on this project. Always `false` for a service token without a forwarded user. |

`capabilities` is not included in list responses (`GET /projects`, `GET /project-directory`).

### Project create body

`POST /projects` accepts these fields:

| Field | Type | Required | Notes |
| - | - | - | - |
| `clientId` | UUID | Yes | The company to assign the project to. Must reference a company in the caller's organisation; the API does not re-check its current role flags. |
| `name` | string | Yes | Project name, maximum 200 characters. |
| `reference` | string \| null | No | Client reference, maximum 80 characters. Omitting, sending `null`, or sending an empty string all result in `null` being stored. Duplicate references within an organisation return `409`. |
| `generateJobNumber` | boolean | No | Deprecated and ignored. A create always allocates a quote number and never allocates a job number. |
| `startDate` | string | No | |
| `targetSubmissionDate` | string | No | |
| `endDate` | string \| null | No | Merge-patch semantics; see [Project window and colour](#project-window-and-colour). |
| `colour` | string \| null | No | Merge-patch semantics; see [Project window and colour](#project-window-and-colour). |
| `timezone` | string \| null | No | Merge-patch semantics. An IANA time zone name (e.g. `Pacific/Auckland`) overrides the organisation's default for this project. Sending `null` clears the override so the project follows the organisation again. Bare UTC offsets (e.g. `+12:00`) are refused. Omitting the key preserves the stored value. |
| `officeLocationId` | UUID | Yes | The project office. Must be one of the organisation's own active locations (`partyId` is `null`); anything else returns `422 office_location_invalid`. |
| `preferResourcesFromOffice` | boolean | No | Defaults to `true`. |
| `clientContactId` | UUID | Yes | The client's main contact. Must be an active contact of `clientId`. A missing value returns `422 client_contact_required`; any other contact returns `422 client_contact_invalid`. |
| `daysPerWeek` | number \| null | No | Working days per week for this project. Greater than `0` and at most `7`, with up to two decimal places. Sending `null` clears the override so the project follows the organisation default. Omitting the key preserves the stored value. |
| `daysPerMonth` | number \| null | No | Working days per month for this project. Greater than `0` and at most `31`, with up to two decimal places. Sending `null` clears the override so the project follows the organisation default. Omitting the key preserves the stored value. |

The project body does not carry the Search distance. Set it with `PUT /projects/:id/search-radius`; see [Project search distance](#project-search-distance).

`quoteNumber` and `jobNumber` are server-allocated and immutable. Neither can be set or changed through `PUT /projects/:id`. Carrying `generateJobNumber` on a `PUT` body is silently ignored.

On `PUT /projects/:id`, `officeLocationId` and `preferResourcesFromOffice` are optional and omitting either preserves the stored value. A changed office must pass the same check (`422 office_location_invalid`). An office cannot be cleared once set.

On `PUT /projects/:id`, `clientContactId` is optional. Omitting it preserves the stored contact, except that changing `clientId` without sending one clears it, because a contact belongs to one client. Sending `null` clears it. Only a changed contact, or one sent with a changed client, is checked (`422 client_contact_invalid`), so a contact deleted since it was chosen does not block an unrelated edit.

### Quote and job number format

Quote numbers and job numbers come from two independent counters in organisation settings. Each number is the prefix followed by the counter zero-padded to four digits. Numbers with more than four digits are not truncated.

* **Quote numbers** use `quoteNumberPrefix` (default `Q-`) and `nextQuoteNumber`: counter `3` yields `Q-0003`. `POST /projects` allocates the project's quote number in the create transaction, so a failed create rolls the counter back.
* **Job numbers** use `jobNumberPrefix` and `nextProjectNumber`. When `jobNumberPrefix` is `null`, the prefix `JOB-` is used: counter `3` yields `JOB-0003`. A job number is allocated once, in the transaction that moves the project to `awarded` when an estimate is marked Won, and `jobCreatedAt` is stamped at the same time.

### Number allocation errors

| Status | Code | Meaning |
| - | - | - |
| `409` | `quote_number_taken` | The next quote number is already allocated in the organisation. This happens when an admin has rewound `nextQuoteNumber` in organisation settings. Correct the counter and retry. |

Job numbers are unique per organisation when set. Rewinding `nextProjectNumber` to a value already issued makes the next award collide with the existing number.

The project list search includes `quoteNumber` and `jobNumber`. Typing either number into the search box returns matching projects.

### Allocate a quote number

```text theme={null}
POST /api/global/projects/:id/quote-numbers
```

Allocates a quote number for an estimate alternative, saved version or tender revision of the project. api-estimate calls it; it never creates a job. The body is `{ "identityId": "<uuid>", "correlationId": "<uuid>" }`, where `correlationId` is optional. `identityId` is a stable UUID for the thing being numbered: a repeat call with the same `identityId` returns the same number without advancing the counter. A project's own number is held under the project id, so a call with `identityId` equal to the project id returns the project's `quoteNumber`.

The caller must be a user on the project, or a lifecycle service token bound to this project, operation and payload. Any other service token returns `403 forbidden`. The response is `200 { "quoteNumber": "Q-0004" }`.

| Status | Code | Meaning |
| - | - | - |
| `400` | | Invalid body. |
| `403` | `forbidden` | A service token without a lifecycle identity for this project. |
| `404` | | The project does not exist or is not visible to the caller. |
| `409` | `quote_identity_conflict` | The `identityId` already numbers something in a different project. |

### Deleting a project (retired)

Projects are not deleted. `DELETE /projects/:id` always returns `410 Gone` with code `project_delete_retired`. To retire a project, archive it with `POST /projects/:id/transition` and `{ "action": "archive" }`. `unarchive` reverses it.

## Server-owned project status

Project `status` is server-owned. Status can only change through `POST /projects/:id/transition`.

* `POST /projects` always creates a project with `status: "lead"`. A supplied `status` key is ignored for compatibility.
* `PUT /projects/:id` may include the project's unchanged current status for client compatibility; the API removes that field before writing.
* A different or invalid status value on `PUT /projects/:id` returns `400 Bad Request` with code `status_via_transition` and does not update the project.

## Project status transitions

`POST /projects/:id/transition` is the only endpoint that changes project status. User-facing actions require a user bearer token. The estimate service invokes `submit_tender` and `revise_tender` with a `lifecycle:transition`-scoped service token and a verified forwarded user.

### Request body

```json theme={null}
{
  "action": "begin_estimating",
  "expectedStatus": "lead",
  "correlationId": "uuid"
}
```

| Field | Type | Notes |
| - | - | - |
| `action` | string | See the action table below. |
| `expectedStatus` | string | Required for user-facing actions. The status the project must currently hold; used for optimistic concurrency. Omitted for S2S inbound actions. |
| `correlationId` | UUID | Idempotency key. An identical replay does not re-execute the transition and returns the project's current record. |
| `targetStatus` | string | Required for `admin_set_status` only. |
| `reason` | string | Required for `admin_set_status` (max 2,000 characters). |
| `lostReasonCategory` | enum | Required for `mark_lost`: `price`, `timing`, `scope`, `relationship`, `project_cancelled`, `re_bid`, `client_not_awarded`, or `other`. |
| `estimateId` | UUID | Optional. `mark_won` only. Names the specific submitted tender estimate to accept as the award baseline. When omitted, the estimate service infers the project's single live tender, or returns `409 estimate_id_required` if more than one exists. Supplying `estimateId` on any other action returns `400 estimate_id_not_allowed`. |

### Available actions

| Action | From | To | Role required |
| - | - | - | - |
| `begin_estimating` | `lead` | `estimating` | Authenticated user |
| `revert_to_lead` | `estimating` | `lead` | Authenticated user |
| `start_project` | `awarded` | `active` | Authenticated user |
| `complete` | `active` | `completed` | Authenticated user |
| `submit_tender` | `estimating` | `submitted` | S2S lifecycle service |
| `revise_tender` | `submitted` | `estimating` | S2S lifecycle service |
| `mark_won` | `submitted` | `awarded` | Org admin or project lead |
| `mark_lost` | `estimating`, `submitted` | `lost` | Authenticated user |
| `reopen` | `lost` | `estimating` | Authenticated user |
| `archive` | `lead`, `estimating`, `submitted`, `lost`, `completed` | `archived` | Authenticated user |
| `unarchive` | `archived` | Stored prior status | Authenticated user |
| `admin_set_status` | any | `targetStatus` | Admin |

The coupled user-facing actions (`mark_won`, `mark_lost`, `reopen`, `archive`, `unarchive`, and `admin_set_status`) transition the linked estimate under the same `correlationId`. An award action creates or binds the active contract baseline to a committed tender revision. `admin_set_status` can target `awarded`, but it runs the same award guard as `mark_won`.

### Responses

| Status | Code | Meaning |
| - | - | - |
| `200` | — | Transition succeeded. Returns the updated project object. |
| `400` | `status_via_transition` | A status change was attempted via `PUT /projects/:id`. |
| `400` | `target_status_required` | `admin_set_status` called without `targetStatus`. |
| `400` | `reason_required` | `admin_set_status` called without a `reason`. |
| `400` | `expected_status_required` | A user-facing action was called without `expectedStatus`. |
| `400` | `lost_reason_required` | `mark_lost` was called without `lostReasonCategory`. |
| `400` | `estimate_id_not_allowed` | `estimateId` was supplied on an action other than `mark_won`. |
| `401` | `auth_required` | No authentication token supplied. |
| `401` | `actor_required` | The action was called without the required user identity. |
| `403` | `forbidden_transition` | `mark_won` was attempted by a caller who is neither an org admin nor a lead of this project. `admin_set_status` was attempted by a non-admin. |
| `404` | `estimate_not_found` | `reopen` requires a linked estimate, but none exists. |
| `409` | `illegal_transition` | `expectedStatus` does not match the declared `from` for the action. |
| `409` | `stale_status` | The project's current status no longer matches `expectedStatus`. Response body includes `currentStatus`. |
| `409` | `correlation_conflict` | `correlationId` has already been used for a different project or a different request. |
| `409` | `transition_in_flight` | Another coupled transition is already pending for the project. |
| `409` | `estimate_id_required` | `mark_won` was called without `estimateId` and the project has more than one live tender estimate. Supply the specific `estimateId` to proceed. |
| `409` | `baseline_required` | An award was requested without a tendered position. |
| `409` | `baseline_conflict` | The active baseline conflicts with the accepted tender revision and must be superseded explicitly. |
| `502` | `transition_pending` | The estimate peer result was ambiguous; the background sweeper will complete or roll back the transition. |

### Idempotency

Supply a new UUID for `correlationId` on each distinct transition attempt. If a request is retried after a network failure, reuse the original `correlationId`. A committed replay returns the project's current record, a pending replay returns `502 transition_pending`, and an aborted replay returns the recorded peer rejection. Reusing a `correlationId` for another project or a different request fingerprint returns `409 correlation_conflict`.

### Status history

Every transition records action, prior and target status, actor, correlation ID, state, and timestamps. Standalone and S2S inbound actions commit their history alongside the status change. Coupled actions first reserve one `pending` row, then commit or abort it after the estimate peer responds. Ambiguous failures remain pending for the lifecycle sweeper. The lifecycle backfill also adds one `imported` row for each pre-existing project; those system rows have no actor or prior status.

Runtime roles can select and insert history rows. Their update permission is limited to the orchestration fields (`state`, `peer`, `detail`, and `finalized_at`); they cannot rewrite the transition identity or delete individual rows. Deleting the parent project is a structural exception and cascades to its history.

## Project stats

`GET /projects/stats` returns a funnel count by status and a win rate for the
requested time window. The endpoint requires authentication.

### Query parameters

| Parameter | Type | Default | Notes |
| - | - | - | - |
| `window` | `30d` \| `90d` \| `12m` | `90d` | Relative window. Ignored when `from` and `to` are supplied. |
| `from` | `YYYY-MM-DD` | — | Start of a custom date range. Must be supplied together with `to`. |
| `to` | `YYYY-MM-DD` | — | End of a custom date range. Must be supplied together with `from`. `from` must be on or before `to`. |

When `from` and `to` are both present they take precedence over `window`. Window boundaries are calendar days in the organisation's timezone (IANA tz, defaults to `Pacific/Auckland`).

### Response

```json theme={null}
{
  "countsByStatus": {
    "lead": 12,
    "estimating": 4,
    "submitted": 2,
    "awarded": 1
  },
  "winRate": {
    "window": "90d",
    "won": 3,
    "lost": 1,
    "outcomes": [
      {
        "projectId": "uuid",
        "status": "awarded",
        "decidedAt": "2026-08-01T03:00:00.000Z"
      }
    ],
    "lostReasons": [
      { "category": "price", "label": "Price", "count": 1 }
    ]
  },
  "portfolio": {
    "localToday": "2026-09-15",
    "deadlineThrough": "2026-09-21",
    "projects": [
      { "id": "uuid", "status": "lead" }
    ],
    "closingSoon": [
      {
        "id": "uuid",
        "name": "City Road Upgrade",
        "status": "estimating",
        "targetSubmissionDate": "2026-09-17"
      }
    ],
    "closingSoonCount": 3
  }
}
```

| Field | Notes |
| - | - |
| `countsByStatus` | Current project counts by status. Statuses with zero projects are omitted — treat a missing key as `0`. `archived` projects are excluded. |
| `winRate.window` | Echoes the relative window (`30d`, `90d`, or `12m`), or `"custom"` when explicit `from`/`to` dates were used. |
| `winRate.won` | Number of projects whose latest committed outcome event within the window was `awarded`. |
| `winRate.lost` | Number of projects whose latest committed outcome event within the window was `lost`. |
| `winRate.outcomes` | Array of individual outcome identities within the window. Each entry contains `projectId` (UUID), `status` (`awarded` or `lost`), and `decidedAt` (ISO 8601 UTC timestamp of the event). One entry per project — each project appears at most once, using its latest committed outcome event. |
| `winRate.lostReasons` | The window's lost outcomes grouped by reason, most common first, then by label. Each entry contains `category` (the stored reason key, or `not_recorded` when a lost project has no recorded reason), `label` (for example `"Price"` or `"Not recorded"`) and `count`. Reasons with no lost outcomes are omitted. |
| `portfolio.localToday` | Today's date in the organisation's timezone (`YYYY-MM-DD`). |
| `portfolio.deadlineThrough` | The last date of the seven-day deadline window: `localToday` plus six days (`YYYY-MM-DD`). |
| `portfolio.projects` | All non-archived projects in the organisation. Each entry contains `id` and `status`. |
| `portfolio.closingSoon` | Projects in `lead` or `estimating` status whose `targetSubmissionDate` falls within the inclusive seven-day window (`localToday` through `deadlineThrough`). Sorted by submission date ascending, then name, then id. Capped at ten entries — use `closingSoonCount` for the full count. Each entry contains `id`, `name`, `status`, and `targetSubmissionDate`. |
| `portfolio.closingSoonCount` | Total number of projects matching the closing-soon criteria, before the ten-entry cap. |

Each project is counted at most once in the win-rate calculation based on its most recent committed `awarded` or `lost` event within the window. Events with action `imported` (system seed rows) never count. Currently archived projects are excluded.

### Error responses

| Status | Code | Meaning |
| - | - | - |
| `400` | — | `from` and `to` must be supplied together, or `from` is after `to`. |
| `401` | `auth_required` | No authentication token. |
| `403` | `org_required` | Token is a platform or service token without an associated organisation. |

## Project window and colour

Projects carry two optional fields added in crew-scheduling M3.

### endDate

`endDate` is a nullable `YYYY-MM-DD` calendar date that marks the planned end of the project window. It follows merge-patch semantics:

* Omitting `endDate` on `POST /projects` or `PUT /projects/:id` leaves any existing value unchanged.
* Sending `endDate: null` clears the stored value.
* Sending a date string sets it.

When both `startDate` and `endDate` are present, `endDate` must be on or after `startDate`. Violating this returns `422` with code `end_date_before_start_date`.

`endDate` is always returned as `string | null` in GET responses (never omitted).

### colour

`colour` is a nullable hex colour string in `#RRGGBB` format. It follows the same merge-patch semantics as `endDate`. The API validates the format and rejects values that do not match `^#[0-9A-Fa-f]{6}$`.

Clients also accept a `colour` field with the same rules.

## Batch project statuses

`GET /projects/statuses` is a service-to-service endpoint used by the crew scheduler to read a set of project statuses in one request.

### Authentication

This endpoint requires a service token with scope `projects:read` (audience `stralos-api-global`) **plus** a forwarded user identity header. A user token or a service token with any other scope returns `403`.

### Query parameters

| Parameter | Type | Notes |
| - | - | - |
| `ids` | Comma-separated UUIDs | Required. At least one ID, maximum 500. Blank entries are ignored. |

### Response

```json theme={null}
{
  "statuses": {
    "uuid-1": "awarded",
    "uuid-2": "lead"
  },
  "effectiveTimezones": {
    "uuid-1": "Pacific/Auckland",
    "uuid-2": "Australia/Sydney"
  }
}
```

`statuses` is a map of project UUID to status string. Project IDs that do not exist or belong to a different organisation are silently absent from the map — the caller must treat a missing key as an unknown project.

`effectiveTimezones` is a parallel map of project UUID to resolved IANA time zone string. The resolved zone follows the same rule as `effectiveTimezone` on a project GET: the project's own `timezone` override if set, otherwise the organisation's `timezone`, otherwise `Pacific/Auckland`. Only project IDs present in `statuses` appear here. Callers that read only `statuses` are unaffected by the addition of this field.

### Error responses

| Status | Code | Meaning |
| - | - | - |
| `400` | — | `ids` is missing, empty, contains a non-UUID value, or exceeds 500 entries. |
| `401` | `actor_required` | No forwarded user header supplied. |
| `403` | — | Token is not a `projects:read` service token, or the forwarded user belongs to a different organisation. |

## Project access

Project access controls which organisation members can enter each project. Access is explicit — a user can only enter a project when they hold a project membership. Two roles exist within a project:

| Role | Description |
| - | - |
| `member` | Can read and work within the project. |
| `lead` | Can manage the project team and decide access requests. |

Organisation administrators can manage any project's team regardless of whether they hold an explicit project membership.

### Access rules

A user can enter a project when they are an active organisation member and hold an explicit `project_members` row for that project. Organisation administrators can manage every project team, but must add themselves as a member before entering a project's contents.

Organisation administrators can see all projects in their organisation on `GET /projects` (the project list), including projects whose team they are not on. Opening a project — `GET /projects/:id`, the contract profile, project stats, project statuses, and location routes — requires an explicit project membership. An administrator who is not on a project's team must add themselves before accessing any of its detail routes.

A user who is not yet a member of a project can request access. Project leads and organisation administrators can approve or decline requests. Requests that have been pending for five weekdays without a decision are escalated to organisation administrators.

The last project lead cannot be removed without appointing a replacement first.

### GET /project-directory

Returns the caller's project directory. The directory is the source of record for the project list and picker.

```text theme={null}
GET /api/global/project-directory
```

| Query parameter | Type | Default | Notes |
| - | - | - | - |
| `scope` | `mine` \| `all` | `mine` | `mine` returns only projects the caller is a member of. `all` returns all projects in the organisation. |
| `clientId` | UUID | — | Filters to projects for this client. |
| `region` | string | — | Filters to projects in this region. |
| `officeLocationId` | UUID | — | Filters to projects run from this office location. |
| `status` | string | — | Filters to projects with this status. |
| `leadId` | UUID | — | Filters to projects led by this user. |
| `ids` | Comma-separated UUIDs | — | Filters to exactly these projects. Maximum 50 IDs. |
| `search` | string | None | Case-insensitive partial match on project name, client name, quote number, and job number. |
| `start` | integer | `0` | Zero-based offset. |
| `length` | integer | `50` | Number of records to return; clamped to 1–200. |

Each item in the response contains:

| Field | Type | Notes |
| - | - | - |
| `id` | UUID | Project ID. |
| `name` | string | Project name. |
| `clientId` | UUID | Client company ID. |
| `clientName` | string | Client display name. |
| `region` | string \| null | Project region. |
| `status` | string | Project lifecycle status. |
| `targetSubmissionDate` | string \| null | Submission date in `YYYY-MM-DD` format. |
| `startDate` | string \| null | Start date in `YYYY-MM-DD` format. |
| `leads` | array | Each entry contains `id` and `name` for a project lead. |
| `canEnter` | boolean | Whether the caller can enter this project. |
| `canManage` | boolean | Whether the caller can manage the project team. |
| `roles` | string\[] | The caller's roles in this project (e.g. `["member"]`, `["lead"]`). Empty when the caller is not a member. |
| `quoteNumber` | string \| null | The project's quote number. Included only when `canEnter` is `true`. |
| `jobNumber` | string \| null | The project's job number, `null` until an estimate is marked Won. Included only when `canEnter` is `true`. |
| `officeLocationId` | string \| null | The project's office location, or `null` when none is set. |
| `officeLocationName` | string \| null | The office location's name, or `null` when none is set. |
| `canSelfJoin` | boolean | `true` when the caller is not a member and the project's office is one of their [self-assign locations](#self-assign-locations). |

The response envelope also contains `totalCount`, `start`, `length`, `organisationCount` (projects in the caller's directory), `membershipCount` (projects the caller is a member of), and `canSeeAllProjects`. `canSeeAllProjects` is `false` for a contractor person in a company their contractor does not manage. Their directory holds only their own projects, so `scope=all` returns the same list as `scope=mine`. See [Contractor project visibility](https://help.stralos.app/internal/org-security.md#contractor-project-visibility).

### GET /project-access-inbox

Returns access requests relevant to the caller. The inbox includes:

* Requests the caller has submitted (any state).
* Pending requests on projects the caller leads.
* Pending escalated requests on any project, when the caller is an organisation administrator.
* Pending requests on projects with no active lead, when the caller is an organisation administrator.

```text theme={null}
GET /api/global/project-access-inbox
```

Fetching the inbox also runs the escalation sweep, which marks requests that have been pending for five weekdays as escalated.

Each item contains:

| Field | Type | Notes |
| - | - | - |
| `id` | UUID | Access request ID. |
| `projectId` | UUID | Project the request is for. |
| `projectName` | string | Project display name. |
| `userId` | UUID | Requesting user. |
| `userName` | string | Requesting user's name. |
| `reason` | string | Reason supplied by the requester. |
| `state` | string | `pending`, `approved`, `declined`, or `withdrawn`. |
| `createdAt` | ISO 8601 | When the request was created. |
| `escalatedAt` | ISO 8601 \| null | When the request was escalated to administrators, or `null`. |
| `canDecide` | boolean | Whether the caller can approve or decline this request. |

| Status | Meaning |
| - | - |
| `200` | Inbox items for the caller. |
| `403` | No active organisation membership. |

### GET /projects/access-scope

Service-to-service endpoint used by peer services to resolve the caller's current project membership set. Returns the project IDs the caller is a member of, plus the IDs of projects in non-blocking statuses (`lost`, `archived`).

```text theme={null}
GET /api/global/projects/access-scope
```

```json theme={null}
{
  "organisationId": "uuid",
  "projectIds": ["uuid", "uuid"],
  "nonBlockingProjectIds": ["uuid"]
}
```

`projectIds` contains the projects the caller explicitly belongs to. `nonBlockingProjectIds` contains projects in `lost` or `archived` status, regardless of membership; these are exposed so shared-resource conflict checks can exclude non-blocking projects without granting access to their data.

| Status | Meaning |
| - | - |
| `200` | Caller's project membership set. |
| `403` | No active organisation membership. |

### GET /projects/:id/team

Returns the project team and the list of organisation users who could be added. Requires project lead or organisation admin access.

```text theme={null}
GET /api/global/projects/:id/team
```

The response contains:

* `items` — current team members. Each entry contains `id` (membership ID), `userId`, `name`, `email`, `roles` (string array), `grantedAt`, `grantedBy`, and `provenance` (`creator` for the user who created a new project, `assigned` for members added through the API, or `migration` for rollout memberships).
* `candidates` — active organisation users not yet on the team. Each entry contains `id`, `name`, and `email`.

| Status | Meaning |
| - | - |
| `200` | Team and candidates. |
| `403` | The caller is not a project lead or organisation administrator, including when no manageable project is found for that ID. |

### PUT /projects/:id/team/:userId

Assigns or updates a team member's role.

```text theme={null}
PUT /api/global/projects/:id/team/:userId
```

Request body:

| Field | Type | Required | Notes |
| - | - | - | - |
| `roles` | string\[] | Yes | Exactly one element: `"member"` or `"lead"`. |
| `replacementUserId` | UUID | No | When demoting an existing lead, optionally promote this user to lead in the same operation. |

| Status | Meaning |
| - | - |
| `204` | Team updated. |
| `400` | Role is not `member` or `lead`; or the target is not an active organisation member. |
| `403` | The caller is not a project lead or organisation administrator. |
| `409` | `project_last_lead` — the target is the last active lead and no replacement was provided. |

### DELETE /projects/:id/team/:userId

Removes a team member from the project.

```text theme={null}
DELETE /api/global/projects/:id/team/:userId
```

| Query parameter | Type | Notes |
| - | - | - |
| `replacementUserId` | UUID | When removing a lead, optionally promote this user to lead in the same operation. |

| Status | Meaning |
| - | - |
| `204` | Member removed. |
| `403` | The caller is not a project lead or organisation administrator. |
| `409` | `project_last_lead` — the target is the last active lead and no replacement was provided. |

### GET /projects/:id/access-requests

Returns access requests for the project. Project leads and organisation administrators see all requests (up to the last 100, newest first). Other authenticated members see only their own requests.

```text theme={null}
GET /api/global/projects/:id/access-requests
```

Each item contains `id`, `userId`, `reason`, `state`, `createdAt`, `decidedAt`, and `escalatedAt`.

### POST /projects/:id/access-requests

Submits a request for access to the project. The caller must be an active organisation member and must not already be a project member.

```text theme={null}
POST /api/global/projects/:id/access-requests
```

Request body:

| Field | Type | Required | Notes |
| - | - | - | - |
| `reason` | string | Yes | Reason for requesting access. 1–1,000 characters. |

Returns `201` with `{ id, state: "pending" }`.

| Status | Meaning |
| - | - |
| `201` | Request created. |
| `400` | Reason is empty or too long; or the caller is already a member. |
| `403` | No active organisation membership, or the project does not belong to the caller's organisation. |

### PATCH /projects/:id/access-requests/:requestId

Approves, declines, or withdraws an access request.

```text theme={null}
PATCH /api/global/projects/:id/access-requests/:requestId
```

Request body:

| Field | Type | Required | Notes |
| - | - | - | - |
| `state` | string | Yes | `approved`, `declined`, or `withdrawn`. |

* `approved` and `declined` require project lead or organisation admin access. Approving a request also adds the requester as a project member.
* `withdrawn` can only be sent by the original requester.

| Status | Meaning |
| - | - |
| `204` | Request decision applied. |
| `400` | Request is not in `pending` state; or `state` value is invalid. |
| `403` | Caller is not authorised to make this decision. |

### GET /projects/:id/access-history

Returns the access audit log for the project (up to the last 100 events, newest first). Requires project lead or organisation admin access.

```text theme={null}
GET /api/global/projects/:id/access-history
```

Each item contains `id`, `actorId`, `subjectId`, `action`, `detail`, and `createdAt`. Common `action` values are `assigned`, `removed`, `requested`, `approved`, `declined`, `withdrawn`, `escalated`, and `self_joined`.

| Status | Meaning |
| - | - |
| `200` | Access history. |
| `403` | The caller is not a project lead or organisation administrator. |

### POST /projects/:id/self-join

Adds the caller to the project as a `member` when the project's office is one of their [self-assign locations](#self-assign-locations).

```text theme={null}
POST /api/global/projects/:id/self-join
```

The call is idempotent: an existing member receives their current roles back unchanged, so a lead stays a lead. A successful join is audited as `self_joined` in the access history. The response contains `projectId`, `userId`, and `roles` (for a new join, `["member"]`).

| Status | Meaning |
| - | - |
| `200` | The caller is now, or already was, a project member. |
| `403` | `self_join_not_permitted`: the project has no office, or its office is not one of the caller's self-assign locations. |
| `403` | `project_access_required`: the project is not available to the caller, or the caller has no active organisation membership. |

## Contract profile

Each project has one contract profile that records the contract basis used for AI-assisted variation assessments.

### GET /projects/:id/contract-profile

Returns the project's contract profile. If no profile has been saved yet, the response has `exists: false` and all fields are `null` or empty — the endpoint never returns `404`.

```json theme={null}
{
  "projectId": "uuid",
  "exists": false,
  "contractForm": null,
  "contractFormLabel": null,
  "onsiteOhPct": null,
  "offsiteOhpPct": null,
  "specialConditionsNote": null,
  "contractDocumentIds": [],
  "createdAt": null,
  "updatedAt": null
}
```

### PUT /projects/:id/contract-profile

Creates or replaces the contract profile. `contractForm` is required; all other fields are optional.

```json theme={null}
{
  "contractForm": "nzs3910_2023",
  "contractFormLabel": null,
  "onsiteOhPct": 12.5,
  "offsiteOhpPct": 7.0,
  "specialConditionsNote": "Refer to appendix B.",
  "contractDocumentIds": ["uuid", "uuid"]
}
```

| Field | Type | Notes |
| - | - | - |
| `contractForm` | string (enum) | Required. One of the supported values below. |
| `contractFormLabel` | string \| null | Required for `custom` (max 120 characters); ignored and stored as `null` for other forms. |
| `onsiteOhPct` | number \| null | On-site overhead percentage, 0–100 with up to 3 decimal places. |
| `offsiteOhpPct` | number \| null | Off-site overhead and profit percentage, 0–100 with up to 3 decimal places. |
| `specialConditionsNote` | string \| null | Special-conditions note (max 4,000 characters). |
| `contractDocumentIds` | UUID\[] | Executed contract documents (max 200 entries). Duplicates are removed before storage. |

**Accepted `contractForm` values:**

| Value | Description |
| - | - |
| `nzs3910_2013` | NZS 3910:2013 |
| `nzs3910_2023` | NZS 3910:2023 |
| `nzs3915` | NZS 3915 |
| `nzs3916` | NZS 3916 |
| `nzs3917` | NZS 3917 |
| `nzs3902` | NZS 3902 |
| `nzia_scc` | NZIA SCC |
| `nzia_nbc` | NZIA New Build Contract |
| `mb_residential` | Master Build residential contract |
| `cb_residential` | Certified Builders residential contract |
| `nec3` | NEC3 |
| `nec4` | NEC4 |
| `fidic_red` | FIDIC Red Book |
| `fidic_yellow` | FIDIC Yellow Book |
| `fidic_silver` | FIDIC Silver Book |
| `custom` | Custom or bespoke contract |

**`custom` contract form:** when `contractForm` is `custom`, `contractFormLabel` is required and `contractDocumentIds` must include at least one document with status `processed`. The API returns `422` if either condition is not met.

### Contract corpus validation

When `contractDocumentIds` is non-empty, the `PUT` endpoint validates the supplied IDs against the Documents service before saving:

* Every ID must exist and belong to the same project. IDs that are missing or belong to another project are rejected with `422`, listing the offending IDs.
* For `custom` forms, at least one document must have status `processed`.
* The caller's `Authorization` header is forwarded to the Documents service for the status lookup.
* The service never stores unvalidated IDs. If the Documents service is unreachable or returns a non-`2xx` response, the `PUT` returns `502` and the profile is not saved.

**Overhead percentages:** `onsiteOhPct` and `offsiteOhpPct` accept values between `0` and `100` with at most three decimal places.

The contract-form vocabulary is append-only: stored values must continue to read back if new forms are added later. The variation assessment falls back to general guidance for an unrecognised historical value.

## Project search distance

`PUT /projects/:id/search-radius` sets or clears the project's Search distance: how far from the project pricing looks for suppliers. It is a separate write from `PUT /projects/:id` so the step that follows creating a project can save the distance alone.

```text theme={null}
PUT /api/global/projects/:id/search-radius
```

Any user who can see the project may call it. A project in another organisation, or one the caller cannot see, returns `404`.

### Request body

```json theme={null}
{ "searchRadiusKm": 75 }
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `searchRadiusKm` | number \| null | Yes | Distance in kilometres. Greater than `0` and at most `2000`, with at most one decimal place. Send `null` to clear it so pricing steps through 50, 100, then 150 km. |

### Response

```json theme={null}
{ "projectId": "uuid", "searchRadiusKm": 75 }
```

The response echoes the project ID and the stored distance (`null` when cleared). Every project response also carries `searchRadiusKm`.

### Error responses

| Status | Code | Meaning |
| - | - | - |
| `400` | | The body failed validation, for example a value out of range or with more than one decimal place. |
| `401` | `auth_required` | The bearer token is missing or invalid. |
| `403` | | The authenticated caller has no organisation context. |
| `404` | | The project does not exist, belongs to another organisation, or is not visible to the caller. |

### GET /projects/:id/office-location

Returns only what rate pricing reads about where a project prices from:

```json theme={null}
{
  "projectId": "uuid",
  "officeLocationId": "uuid",
  "searchRadiusKm": null,
  "daysPerWeek": 5,
  "daysPerMonth": 20
}
```

`daysPerWeek` and `daysPerMonth` here are the resolved values (the project's override, otherwise the organisation default, otherwise `5` and `20`), not the nullable project fields. Visibility follows the project read, but a project the caller cannot see returns `404`, never the `403` directory response. The estimate service's background re-price token may call this endpoint even though it cannot read `GET /projects/:id`.

## Tender details

### GET /projects/:id/tender-details

Returns what a Tender download prints about who the tender is from and who it is for, in one read. api-estimate calls it with the caller's token when it builds a Tender download PDF or Excel file.

```json theme={null}
{
  "projectId": "uuid",
  "company": {
    "name": "Acme Construction Ltd",
    "office": {
      "locationId": "uuid",
      "name": "Christchurch office",
      "addressLine1": "12 Main Road",
      "city": "Christchurch",
      "region": "Canterbury",
      "countryCode": "NZ"
    },
    "phone": "03 123 4567",
    "email": "tenders@acme.co.nz",
    "gstNumber": "123-456-789",
    "logo": null
  },
  "client": {
    "id": "uuid",
    "name": "City Council",
    "tradingName": null,
    "status": "active",
    "address": {
      "addressLine1": "1 Council Place",
      "city": "Wellington",
      "region": "Wellington",
      "countryCode": "NZ"
    },
    "logo": null
  },
  "clientContact": {
    "id": "uuid",
    "name": "Jane Smith",
    "email": "jane@council.example",
    "phone": "04 456 7890",
    "role": "Contracts manager",
    "status": "active"
  }
}
```

`company.office` is the project office's address. `company.phone`, `company.email`, and `company.gstNumber` are resolved per field: the project office's value when set, otherwise the organisation settings value. Missing parts are `null`, never an empty string. `company.logo` and `client.logo` are the company branding logo and the client's logo, each with a five-minute download URL, or `null`. `clientContact` is the project's main contact, or `null`.

Visibility follows the project read. A project the caller cannot see, or another organisation's, returns `404`, never the `403` directory response. The response carries `Cache-Control: private, no-store`.

## Project location routes

Project location routes store the driving distance and travel time from a project's site to each requested location, whether it is one of the organisation's own sites or a company's site. Two endpoints serve this data: a cache-read endpoint and a resolve endpoint that calls the routing service for missing or stale results.

The consumers of these endpoints are internal services (`api-rates` and `api-estimate`). Authenticated user tokens with organisation context may also call them.

### Routing service configuration

The routing service is the Google Maps Platform Routes API (driving, traffic-unaware). There is no fallback provider.

| Variable | Notes |
| - | - |
| `GOOGLE_MAPS_SERVER_API_KEY` | Literal key for local development. Takes precedence over the SSM parameter. |
| `GOOGLE_MAPS_SERVER_API_KEY_SSM_PARAMETER` | Name of the SSM SecureString holding the key. Terraform creates this parameter (default `/<project>/<environment>/google-maps/server-api-key`) with a placeholder value and never overwrites it; put the real key out of band. |
| `GOOGLE_MAPS_REFERER` | App origin sent as the `Referer` header on every Routes call. Deployed environments use an HTTP-referrer-restricted key, which rejects calls without it (the endpoint then returns `502`). Terraform derives it from the origin of `oidc_redirect_uri`. |

When no key is configured, or the placeholder has not been replaced, route resolution returns `502`.

### GET /projects/:id/location-routes

Returns cached route rows for the requested locations. Locations with no cached entry are silently absent from the response.

```text theme={null}
GET /api/global/projects/:id/location-routes?locationIds=<csv>
```

#### Query parameters

| Parameter | Type | Required | Notes |
| - | - | - | - |
| `locationIds` | Comma-separated UUIDs | No | Up to 200 location IDs. Duplicate IDs are collapsed before the lookup. |
| `partyIds` | Comma-separated UUIDs | No | Up to 200 company IDs. Adds every effectively active site of each company to the lookup and lists those sites in `sites`. |
| `staleReason` | `true` | No | Adds a `staleReason` field to each route. Opt-in, so callers that parse the response strictly are unaffected. |

Send at least one location ID or company ID.

#### Response

```json theme={null}
{
  "routes": [
    {
      "locationId": "uuid",
      "distanceKm": 18.4,
      "durationMin": 24.1,
      "fetchedAt": "2026-09-01T03:00:00.000Z",
      "stale": false
    }
  ],
  "sites": [
    { "locationId": "uuid", "partyId": "uuid", "name": "Northern Yard" }
  ]
}
```

Each entry in `routes` contains:

| Field | Type | Notes |
| - | - | - |
| `locationId` | UUID | The location this leg applies to. |
| `distanceKm` | number | Road distance in kilometres. Always `≥ 0`. |
| `durationMin` | number | Estimated travel time in minutes. Always `≥ 0`. |
| `fetchedAt` | ISO 8601 string | When the route was last resolved from the routing service. |
| `stale` | boolean | `true` when the stored coordinates no longer match the project or location's current position, or when the cached result is older than 90 days. |
| `staleReason` | `"age"` \| `"moved"` \| null | Present only when `staleReason=true` is sent. `"age"`: older than 90 days. `"moved"`: the project or the location has moved, or lost its coordinates, since the route was fetched. `null` when the row is not stale. |

`sites` is present only when `partyIds` is sent. Each entry has the site's `locationId`, the owning company's `partyId`, and the site `name`.

Location IDs that have no cached row are omitted from the array. A `stale: true` result can be refreshed by calling `POST /projects/:id/location-routes/resolve`.

#### Error responses

| Status | Meaning |
| - | - |
| `400` | No location or company ID was sent, a value is not a UUID, a list has more than 200 entries, or the project ID path segment is invalid. |
| `401` | No authentication token supplied. |
| `404` | The project does not exist or belongs to a different organisation. |

### POST /projects/:id/location-routes/resolve

Resolves driving distance and travel time from the project's site to each requested location. Results are cached per project–location coordinate pair and reused on subsequent calls. The routing service is only called when a result is absent or either endpoint's coordinates have changed since the last fetch.

```text theme={null}
POST /api/global/projects/:id/location-routes/resolve
```

The caller must be authenticated with organisation context, either through a user token or a tenant service token. The project and all supplied location IDs must belong to the caller's organisation.

#### Request body

```json theme={null}
{
  "locationIds": ["uuid-1", "uuid-2"],
  "partyIds": ["uuid-party"],
  "partial": true,
  "straightLineKm": 150,
  "alsoNearest": 3
}
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `locationIds` | UUID\[] | No | Up to 200 location IDs. Defaults to `[]`. Duplicates are removed before resolution. |
| `partyIds` | UUID\[] | No | Up to 200 company IDs. Adds every effectively active site of each company to the batch, for prices that apply at all of a supplier's locations. The expanded sites are listed in `sites`. Requires `partial: true`. |
| `partial` | boolean | No | Partial-result mode. When `true`, sites that cannot be routed are named in `unroutable` instead of failing the whole batch. Defaults to `false`. |
| `straightLineKm` | number | No | Straight-line pre-filter. Sites further than this in a straight line are not routed and are reported as `beyond_range`. Greater than `0` and at most `2000`. Requires `partial: true`. |
| `alsoNearest` | integer | No | With `straightLineKm`, always routes this many of the nearest sites by straight line, whatever their distance. `0` to `10`; defaults to `0`. Requires `partial: true`. |

Send at least one location ID or company ID. `partyIds`, `straightLineKm`, and `alsoNearest` are refused with `400` unless `partial` is `true`.

#### Response

```json theme={null}
{
  "routes": [
    {
      "locationId": "uuid-1",
      "distanceKm": 18.4,
      "durationMin": 24.1,
      "fetchedAt": "2026-09-01T03:00:00.000Z"
    }
  ],
  "unroutable": [
    { "locationId": "uuid-2", "reason": "beyond_range", "straightLineKm": 312.7 }
  ],
  "sites": [
    { "locationId": "uuid-3", "partyId": "uuid-party", "name": "Northern Yard" }
  ]
}
```

The `routes` array holds the resolved legs, ordered to match the deduplicated requested locations. Each entry contains:

| Field | Type | Notes |
| - | - | - |
| `locationId` | UUID | The location this leg applies to. |
| `distanceKm` | number | Road distance in kilometres. Always `≥ 0`. |
| `durationMin` | number | Estimated travel time in minutes. Always `≥ 0`. |
| `fetchedAt` | ISO 8601 string | When the route was last resolved from the routing service. Cached hits return the original fetch time. |

`unroutable` is present only when `partial: true` is sent. Each entry has the `locationId` and a `reason`:

| Reason | Meaning |
| - | - |
| `not_found` | The location is not in the caller's organisation. |
| `no_coordinates` | The location has no stored coordinates. |
| `no_route` | The routing service returned no usable route. |
| `routing_unavailable` | The routing service call failed. Retry later. |
| `beyond_range` | The site is further than `straightLineKm` in a straight line and was not among the `alsoNearest` sites. The entry also carries `straightLineKm`, the site's straight-line distance to 0.1 km. |

`sites` is present only when `partyIds` is sent. Each entry has the site's `locationId`, the owning company's `partyId`, and the site `name`.

Without `partial`, location IDs whose route could not be resolved are absent from `routes`. The response does not include a `stale` field; use `GET /projects/:id/location-routes` to inspect freshness for already-cached rows.

#### Caching behaviour

A cached result is returned when the stored project coordinates and location coordinates both match the current values. If either endpoint has moved since the cache was populated, the routing service is called, the new result is stored, and it is returned. The cache update is per location — unchanged pairs are served from the cache even when other locations in the same request are refreshed. Results older than 90 days are considered stale and trigger a fresh routing call.

#### Error responses

| Status | Code | Meaning |
| - | - | - |
| `400` | — | The project ID path segment, request body, or location ID values are invalid. |
| `401` | `auth_required` | The bearer token is missing or invalid. |
| `403` | — | The authenticated caller has no organisation context. |
| `404` | — | The project does not exist or belongs to a different organisation. |
| `404` | — | One or more location IDs were not found in the caller's organisation, or a location does not have coordinates. Not returned when `partial` is `true`; those sites are named in `unroutable`. |
| `502` | — | The routing service is not configured on this deployment, was unreachable, or returned an error status. With `partial: true`, these failures are named in `unroutable` as `routing_unavailable` instead. |

## Disposal substances

Disposal substances are organisation-owned master data used to calculate earthworks disposal volumes and cartage costs. Each substance carries a loose density, a bulking factor, an optional compaction factor, and a reinstatement flag.

When a new organisation is provisioned, Stralos seeds its disposal substances from the `seed_template.provisioned` event. The substances seeded depend on what the admin seed template carries:

* **Template includes a `disposalSubstances` array**: those rows are seeded (admin-managed defaults for the organisation's business type).
* **Template includes an empty `disposalSubstances` array (`[]`)**: no substances are seeded.
* **Template omits `disposalSubstances`**: Stralos seeds its 12 built-in defaults, which are Topsoil, General soil, Sand, Gravel / granular soil, Clay, Peat / organic soil, Weathered rock, Hard rock, Asphalt pavement, Existing basecourse, Concrete – plain, and Concrete – reinforced.

Seeding only occurs while the organisation has no substance rows. A redelivered `seed_template.provisioned` event leaves existing rows untouched.

### Access control

All write operations require an organisation admin token. Reads are available to any authenticated organisation member.

### GET /disposal-substances

Returns the organisation's disposal substances, ordered by `sortOrder` ascending then name alphabetically.

| Query parameter | Type | Default | Notes |
| - | - | - | - |
| `status` | `active` \| `all` | `active` | Pass `all` to include inactive substances. |
| `search` | string | — | Case-insensitive partial match on `name`. |
| `start` | integer | `0` | Zero-based offset for pagination. |
| `length` | integer | `50` | Number of records to return, clamped to 1–200. |

The response follows the standard list envelope: `{ items, totalCount, start, length }`.

### POST /disposal-substances

Creates a new disposal substance. Returns `201` with the created record.

| Field | Type | Required | Notes |
| - | - | - | - |
| `name` | string | Yes | Maximum 120 characters. Must be unique within the organisation (case-insensitive, whitespace-trimmed). |
| `looseDensity` | number | Yes | Density in t/m³. Positive, maximum 10, up to 3 decimal places. |
| `bulkingPct` | number | Yes | Bulking percentage. 0–200, up to 3 decimal places. |
| `compactionPct` | number \| null | No | Compaction percentage. 0–100, up to 3 decimal places. Omitting or sending `null` stores no value. Only valid when `canUseForReinstatement` is `true` or omitted. |
| `canUseForReinstatement` | boolean | No | Whether the substance is offered as a reinstatement fill material. When omitted on create, the server sets it to `true` if `compactionPct` is supplied, otherwise `false`. When omitted on update, the stored value is preserved. Sending `false` together with a non-null `compactionPct` returns `400`. |
| `sortOrder` | integer | No | Display order, 0 or above. When omitted, the server appends the substance after the current highest sort order. |
| `status` | `active` \| `inactive` | No | Defaults to `active`. |

### PUT /disposal-substances/:id

Replaces a disposal substance. Requires an organisation admin. The substance must exist and belong to the caller's organisation; otherwise returns `404`.

Accepts the same fields as `POST /disposal-substances`. `sortOrder` is optional on `PUT`; omitting it leaves the stored sort order unchanged. Sending `canUseForReinstatement: false` together with a non-null `compactionPct` returns `400`.

### DELETE /disposal-substances/:id

Deactivates the substance by setting its `status` to `inactive`. Returns `204` on success. The record is retained; pass `status=all` to `GET /disposal-substances` to retrieve inactive substances.

### Response shape

Each disposal substance record contains:

| Field | Type | Notes |
| - | - | - |
| `id` | UUID | Server-assigned. |
| `name` | string | Display name. |
| `looseDensity` | number | Density in t/m³. |
| `bulkingPct` | number | Bulking percentage. |
| `compactionPct` | number \| null | Compaction percentage, or `null` when not applicable. |
| `canUseForReinstatement` | boolean | Whether this substance is offered as a reinstatement fill material. Only substances where this is `true` may carry a `compactionPct`. |
| `sortOrder` | integer | Display order. |
| `status` | `active` \| `inactive` | Current status. |
| `createdAt` | ISO 8601 string | |
| `updatedAt` | ISO 8601 string | |

## Organisation settings

Organisation settings hold operational defaults owned by each organisation.

### GET /organisation-settings

Returns the caller's organisation settings. If no row exists, the API returns
the defaults without creating a record:

```json theme={null}
{
  "organisationName": "Acme Construction Ltd",
  "countryCode": "NZ",
  "timezone": "Pacific/Auckland",
  "defaultMarkupPercent": 15,
  "pricingBasis": "markup",
  "defaultHoursPerDay": 8,
  "defaultDaysPerWeek": 5,
  "defaultDaysPerMonth": 20,
  "defaultLocationId": null,
  "nextQuoteNumber": 1,
  "quoteNumberPrefix": "Q-",
  "nextProjectNumber": 1,
  "jobNumberPrefix": null,
  "seededAt": null,
  "onboarding": {
    "reviewedSteps": [],
    "dismissedAt": null,
    "completedAt": null
  },
  "brandColour": null,
  "logo": null,
  "phone": null,
  "email": null,
  "gstNumber": null,
  "tendersNeedApproval": false
}
```

Any authenticated organisation member can read the value. A platform token
without an organisation receives `403`.

`organisationName` is a read-only projection of the organisation's display name. It is included here because this endpoint is already fetched by the shell and estimate screens, giving every screen a reliable source for the name without an extra request. `null` means the organisation has not yet been projected into this region — an `organisation.created` event may still be in transit. To rename the organisation, use `PUT /org/organisation`.

`timezone` is the organisation's IANA time zone identifier. It is always a valid zone string — never `null`. An organisation that has not configured a time zone reads back `Pacific/Auckland`. This is the value every project inherits unless it sets its own `timezone` override. To change it, use `PUT /org/organisation` with a `timezone` field.

`countryCode` is a read-only two-letter ISO 3166-1 alpha-2 country code stored from the organisation's signup. It is `null` for organisations created before this field was added, or when the country has not yet been projected into this region. Consumers can use this value to bias address search and map views towards the organisation's country.

`defaultLocationId` is **deprecated** and always `null`. The organisation default location has been replaced by per-user default offices. Each person now has their own default office (`GET /me/default-office`), and a project carries its own office. The key stays in the response so an older client keeps parsing.

`pricingBasis` controls how `defaultMarkupPercent` is read when pricing work items. A value of `"margin"` means the percentage is treated as a margin on sell; `"markup"` means it is treated as a markup on cost. Organisations that predated this field read back `"markup"` because that is what their stored percentage already meant. Both basis values use the same `defaultMarkupPercent` column — the basis decides how the number is read, it does not add a second one.

`seededAt` records when the organisation's rate data was last seeded by the tenant seed script. `null` means the organisation has never been seeded. Once set, the seed script refuses to run again so that manual rate edits cannot be overwritten by a re-run.

`defaultDaysPerWeek` and `defaultDaysPerMonth` are the working days used to convert weekly and monthly rates for any project without its own `daysPerWeek` or `daysPerMonth`. An organisation with no saved value reads back `5` and `20`.

`onboarding` records the organisation's onboarding checklist progress. Before any admin records progress, the field reads as the empty shape: `{ reviewedSteps: [], dismissedAt: null, completedAt: null }`. Use `PATCH /organisation-settings/onboarding` to update it.

`brandColour` is the company's brand colour for estimate spreadsheet exports, as lower-case `#rrggbb`, or `null` when none is set (exports then use the Stralos colours). `logo` is the active logo's metadata (`id`, `contentType`, `byteSize`, `width`, `height`, `sha256`, `updatedAt`) or `null`. It never carries a download URL; use [`GET /organisation-branding`](#get-organisation-branding) for that.

`phone`, `email`, and `gstNumber` are the company's contact details for Tender downloads, or `null` when not set. A project office's own value takes precedence for each field; see [Tender details](#tender-details).

`tendersNeedApproval` is the company's **Tenders need approval** setting. It is `false` until an admin turns it on, including for an organisation with no saved settings. It only shows or hides the approval UI: the Estimate API never reads it, and approval never blocks a submit.

### PUT /organisation-settings

Creates or replaces the caller organisation's settings. The caller must be an
organisation admin. The response uses the same shape as `GET /organisation-settings`, including the read-only `organisationName` and `countryCode` fields.

```json theme={null}
{
  "defaultMarkupPercent": 17.5,
  "pricingBasis": "margin",
  "defaultHoursPerDay": 8,
  "defaultDaysPerWeek": 5,
  "defaultDaysPerMonth": 20,
  "quoteNumberPrefix": "Q-",
  "nextQuoteNumber": 1,
  "jobNumberPrefix": "JOB-",
  "nextProjectNumber": 1
}
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `defaultMarkupPercent` | number | Yes | Finite, between `0` and `1000` on either API basis. The margin UI caps values at `95`; estimate creation omits an out-of-range organisation margin from its frozen ladder. |
| `pricingBasis` | `"margin"` \| `"markup"` | No | Whether the organisation prices using a margin on sell (`"margin"`) or a markup on cost (`"markup"`). Omitting the key preserves the stored value. Defaults to `"markup"` for organisations that have no saved setting. |
| `defaultHoursPerDay` | number | No | Working hours per day inherited by new estimates for duration-driven pricing. Must be greater than `0` and at most `24`, with up to two decimal places. Defaults to `8`. Omitting the key preserves the stored value. |
| `defaultDaysPerWeek` | number | No | Working days per week for projects without their own value. Greater than `0` and at most `7`, with up to two decimal places. Defaults to `5`. Omitting the key preserves the stored value. |
| `defaultDaysPerMonth` | number | No | Working days per month for projects without their own value. Greater than `0` and at most `31`, with up to two decimal places. Defaults to `20`. Omitting the key preserves the stored value. |
| `defaultLocationId` | UUID \| null | No | **Deprecated.** Still accepted, so an older client that echoes it does not receive `400`, but ignored: nothing is stored and the response always carries `null`. |
| `quoteNumberPrefix` | string | No | Prefix for every quote number (e.g. `Q-`). 1 to 20 characters after trimming; it cannot be cleared. Defaults to `Q-`. Omitting the key preserves the stored value. |
| `nextQuoteNumber` | integer | No | The counter the next quote number will use. Must be between `1` and `999999999`. Omitting the key preserves the stored counter. Setting it backwards is allowed but may collide with an issued number, returning `409 quote_number_taken` on the next project create. |
| `jobNumberPrefix` | string \| null | No | Prefix for every job number (e.g. `JOB-`). Maximum 20 characters. Omitting the key preserves the stored value; sending `null` clears it, and job numbers then use `JOB-`. |
| `nextProjectNumber` | integer | No | The counter the next job number will use. Must be between `1` and `999999999`. Omitting the key preserves the stored counter. Setting it backwards is allowed but may collide with an issued job number on the next award. |
| `brandColour` | string \| null | No | The brand colour for estimate spreadsheet exports, as `#rrggbb` (surrounding spaces are trimmed and the value is stored lower-case). Sending `null` clears it; omitting the key preserves the stored value. Any other format returns `422 invalid_brand_colour`. |
| `phone` | string \| null | No | The company's phone number for tenders. Omitting the key preserves the stored value; `null` or an empty string clears it. A value that does not look like a phone number returns `422 company_phone_invalid`. |
| `email` | string \| null | No | The company's email address for tenders. Same omit, clear, and trim rules. An invalid address returns `422 company_email_invalid`. |
| `gstNumber` | string \| null | No | The company's GST number: 8 or 9 digits, optionally written with spaces or dashes. Same omit and clear rules. Any other value returns `422 gst_number_invalid`. |
| `tendersNeedApproval` | boolean | No | Whether tenders offer **Send for approval**. Omitting the key preserves the stored value. A non-boolean returns `400`. |
| `seededAt` | ISO 8601 string \| null | No | The timestamp recorded when the tenant seed last ran for this organisation. Sending an ISO 8601 datetime string sets it; sending `null` clears it; omitting the key preserves the stored value. This field is written by the seed script and is not intended for use by regular clients. |

Members and platform tokens without an organisation receive `403`. Schema-invalid values receive `400`.

### PATCH /organisation-settings/onboarding

Organisation admins record checklist progress at `PATCH /api/global/organisation-settings/onboarding`. The strict body requires at least one of these fields; unknown keys return `400`:

| Field | Behaviour |
| - | - |
| `markReviewed` | Add step IDs to the reviewed set. |
| `unmarkReviewed` | Remove step IDs from the reviewed set. |
| `dismissed` | `true` stamps `dismissedAt` once; `false` clears it. |
| `completed` | `true` stamps `completedAt` once; `false` clears it. |

Each step-ID array accepts at most 50 strings matching `^[a-z0-9-]{1,64}$`. The client owns the step catalogue: the API accepts any well-formed ID. Adds and removals are atomic; an ID in both arrays ends up removed. A missing organisation-settings row is created.

The `200` response contains `{ onboarding: { reviewedSteps, dismissedAt, completedAt } }`. Reviewed IDs are sorted alphabetically; timestamps are ISO 8601 strings or `null`. Members receive `403`; missing authentication receives `401`; invalid bodies receive `400`.

## Company branding

A company's brand colour and logo are used by estimate spreadsheet exports. The colour is written through [`PUT /organisation-settings`](#put-organisation-settings) (`brandColour`); the logo has its own routes. Every route acts on the organisation in the caller's token, so a contractor working in a client company reads, and as an admin there edits, that company's branding. See [Company branding](https://help.stralos.app/internal/company-branding.md) for the upload flow, storage and validation.

| Method | Path | Caller | Purpose |
| - | - | - | - |
| `GET` | `/organisation-branding` | Any organisation member | Brand colour and the active logo with a download URL |
| `POST` | `/organisation-branding/logo-uploads` | Organisation admin | Issue a presigned upload for a new logo |
| `POST` | `/organisation-branding/logo-uploads/:uploadId/confirm` | Organisation admin | Validate the uploaded file and make it the active logo |
| `DELETE` | `/organisation-branding/logo` | Organisation admin | Remove the active logo |

### GET /organisation-branding

```json theme={null}
{
  "brandColour": "#1a2b3c",
  "logo": {
    "id": "6f1c2b9e-1d7a-4f0e-9b9c-2a4d8e5f7a10",
    "contentType": "image/png",
    "byteSize": 18432,
    "width": 600,
    "height": 120,
    "sha256": "<64 lower-case hex characters>",
    "updatedAt": "2026-10-09T03:00:00.000Z",
    "url": "https://<presigned download URL>",
    "urlExpiresAt": "2026-10-09T03:05:00.000Z"
  }
}
```

`logo` is `null` when no logo is set. The `url` is a presigned download that expires after five minutes, and the response carries `Cache-Control: private, no-store`. When the service has no branding bucket configured (a local stack without one), `logo` reads `null`.

### POST /organisation-branding/logo-uploads

The strict body is `{ "contentType": "image/png", "byteSize": 18432 }`. `contentType` must be `image/png` or `image/jpeg`, and `byteSize` a whole number from `1` to `10485760` (10 MiB). The response is `{ uploadId, upload: { url, fields }, expiresAt }`: a presigned S3 POST that expires after five minutes. Post the file to `upload.url` as multipart form data with every entry in `upload.fields`, then confirm.

### POST /organisation-branding/logo-uploads/:uploadId/confirm

Reads the uploaded bytes back, checks them, keeps the upload as the original, and makes a resized rendition the company's only active logo. The `logo` metadata and download URL always describe the rendition: longest side at most 1,200 px and at most 512 KiB, in the same format as the upload. The previous logo is soft-removed. Confirming an image identical to the active logo keeps the existing record. The response is `{ logo }`, in the same shape as `GET /organisation-branding`.

### DELETE /organisation-branding/logo

Soft-removes the active logo and returns `{ "logo": null }`. Removing when no logo is set is not an error.

### Branding errors

| Status | Code | Meaning |
| - | - | - |
| `422` | `invalid_brand_colour` | `brandColour` on `PUT /organisation-settings` is not `#rrggbb` |
| `422` | `logo_unsupported_type` | Not PNG or JPEG, or the bytes do not match the declared type |
| `422` | `logo_too_large` | Larger than 10 MiB |
| `422` | `logo_unreadable` | The image's dimensions cannot be read (for example, a truncated PNG), or `byteSize` is not a positive whole number |
| `422` | `logo_dimensions_out_of_range` | A side is under 16 or over 10,000 pixels, or the image is over 50 megapixels |
| `422` | `logo_too_long_and_thin` | The long side is more than 250 times the short side |
| `422` | `logo_upload_missing` | No upload exists for this `uploadId` |
| `409` | `logo_changed_concurrently` | Another admin changed the logo at the same moment; refresh and retry |
| `503` | `logo_storage_unavailable` | The branding bucket is not available |

Each `422` carries a sentence the settings card shows as-is. Members receive `403` on the three admin routes.

## Organisation setup

`GET /organisation-setup` reports whether the caller's organisation has the three records needed to unlock the Projects area: an office, a client company and at least one rate. The shell's Projects setup lock, the Dashboard setup prompt and the onboarding welcome dialog read it. Each surface sits behind the `onboarding-setup-lock` feature flag and treats any failed or malformed response as complete.

```text theme={null}
GET /api/global/organisation-setup
```

Any authenticated organisation user, admin or member, may call it. Missing authentication returns `401` with code `auth_required`. A platform or service token without an organisation returns `403` with code `org_required`. The response always carries `Cache-Control: no-store`.

```json theme={null}
{
  "complete": false,
  "exempt": false,
  "steps": { "office": true, "client": false, "rate": false },
  "nextStep": "client"
}
```

| Field | Type | Notes |
| - | - | - |
| `complete` | boolean | `true` when `nextStep` is `null`: every step is done, or the organisation is exempt. |
| `exempt` | boolean | `true` when the organisation has at least one project that is not archived. |
| `steps` | object | `office`, `client` and `rate` booleans. Always present, including for exempt organisations. |
| `nextStep` | `"office"` \| `"client"` \| `"rate"` \| `null` | The first incomplete step in that order, or `null` when complete or exempt. |

Each step reads live records, not the onboarding checklist state:

| Step | Passes when |
| - | - |
| `office` | The organisation has an active location of its own (`partyId` is `null`). |
| `client` | The organisation has an active company with `isClient: true`. |
| `rate` | The Rates service reports at least one live item in the organisation's Rates Library, read with the caller's own token. Inactive items count; deleted items do not. If the Rates service fails or times out, the step is treated as done so a rates outage cannot lock anyone out of their projects. |

`exempt` is an organisation-wide answer, whoever asks. It uses the `organisation_has_live_project()` database function (migration 0089), which checks the organisation's projects directly rather than through the caller's project access. An admin or member who is not on any project still receives `exempt: true` when the organisation has a live project. Archived projects do not count; a project in any other status does.

## Locations

Locations are named physical places belonging to an organisation. They can identify where supplier or organisation pricing applies.

### Endpoint groups

| Group | Routes |
| - | - |
| Locations | `GET /locations`, `GET /locations/shared-address`, `GET /locations/:id`, `POST /locations`, `PUT /locations/:id`, `DELETE /locations/:id` |

### Location fields

Every location response includes:

| Field | Type | Notes |
| - | - | - |
| `id` | UUID | Server-assigned. |
| `partyId` | UUID \| null | The company that owns this location, or `null` when the location belongs to the organisation itself. `POST /locations` creates an organisation-owned location; `POST /parties/:id/locations` creates a location owned by that company. |
| `name` | string | Display name, maximum 200 characters. |
| `kind` | string | One of `office`, `yard`, `depot`, `branch`, `quarry`, or `other`. |
| `status` | string | Effective status: `active` only when the location itself is active and it is either one of the organisation's own sites or its company is active. A retired company's locations read `inactive`. |
| `ownStatus` | string | The location's own status (`active` or `inactive`), independent of its company. This is what the location's edit form changes. |
| `geoTag` | object | Address and optional coordinates. See below. |
| `phone` | string \| null | The site's phone number for Tender downloads, or `null`. |
| `email` | string \| null | The site's email address for Tender downloads, or `null`. |
| `gstNumber` | string \| null | The site's GST number for Tender downloads, or `null`. |
| `createdAt` | ISO 8601 | |
| `updatedAt` | ISO 8601 | |

The `geoTag` object contains:

| Field | Type | Notes |
| - | - | - |
| `addressLine1` | string | Optional. |
| `city` | string | Optional. |
| `region` | string | Optional. |
| `countryCode` | string | Two-character ISO country code. Defaults to `NZ`. |
| `latitude` | number | Optional. Must be supplied together with `longitude`. |
| `longitude` | number | Optional. Must be supplied together with `latitude`. |

### GET /locations query parameters

| Query parameter | Type | Default | Notes |
| - | - | - | - |
| `owner` | `ours` \| `theirs` \| `all` | `all` | Filters by site ownership. `ours` returns only the organisation's own sites (no linked company). `theirs` returns only company-owned locations. `all` returns both. An unrecognised value returns `400`. |
| `status` | `active` \| `inactive` | — | Filters on the effective status. `active` leaves out retired locations and every location of a retired company. Omit it to list every status. |
| `search` | string | — | Case-insensitive partial match on `name`, `addressLine1`, `city`, and `region`. |
| `start` | integer | `0` | Zero-based offset. |
| `length` | integer | `50` | Number of records to return; clamped to 1–200. |

Callers that mean "my own organisation's sites" should pass `owner=ours` explicitly. The default `all` is a compatibility choice — callers that predated the companies register send no `owner` parameter and expect the full set.

### Create and update body

`POST /locations` and `PUT /locations/:id` accept the same shape:

| Field | Type | Required | Notes |
| - | - | - | - |
| `name` | string | Yes | Maximum 200 characters. |
| `kind` | string | Yes | One of `office`, `yard`, `depot`, `branch`, `quarry`, or `other`. |
| `addressLine1` | string \| null | No | Maximum 300 characters. |
| `city` | string \| null | No | |
| `region` | string \| null | No | |
| `countryCode` | string | No | Defaults to `NZ`. |
| `latitude` | number \| null | No | Must be supplied with `longitude`. |
| `longitude` | number \| null | No | Must be supplied with `latitude`. |
| `status` | string | No | `active` or `inactive`. Defaults to `active`. |
| `phone` | string \| null | No | Omitting the key preserves the stored value; `null` or an empty string clears it. An invalid value returns `422 location_phone_invalid`. |
| `email` | string \| null | No | Same omit and clear rules. An invalid value returns `422 location_email_invalid`. |
| `gstNumber` | string \| null | No | 8 or 9 digits, optionally written with spaces or dashes. Same omit and clear rules. Any other value returns `422 location_gst_number_invalid`. |

Supplying only one of `latitude` or `longitude` returns `400`.

The phone, email, and GST number matter on the organisation's own offices: a Tender download uses the project office's value for each field when it is set, otherwise the organisation settings value. See [Tender details](#tender-details).

The schema keeps `addressLine1`, `latitude`, and `longitude` optional, but every location write needs a non-empty `addressLine1` and both coordinates, because a site without them cannot be measured from a project. This applies to `POST /locations`, `PUT /locations/:id`, and `POST /parties/:id/locations`:

| Status | Code | Message |
| - | - | - |
| `422` | `location_address_required` | "Please add the site's address so we can work out distances." (no `addressLine1`) |
| `422` | `location_address_required` | "Please choose the address from the list so we can find it on the map." (address present, coordinates missing) |

Existing locations without an address still read back unchanged, but cannot be saved again until they have one.

While a location's company is retired, `PUT /locations/:id` leaves the location's own status unchanged, whatever `status` the body sends, so saving the edit form cannot stamp it retired. Asking for `"status": "active"` returns `409` with code `location_owner_retired` and the message "This location's company is retired, so the location cannot be restored." There is no way to restore it while its company is retired; reinstating a company is a future PRD.

`GET /locations/:id` is never filtered by status. A retired company's location still returns `200`, with `status: "inactive"` and its own `ownStatus`.

### Delete behaviour

`DELETE /locations/:id` sets the location's `status` to `inactive` rather than removing the row. A `204` response is returned on success.

### Access control

Reads require an authenticated organisation member. Writes require an organisation admin.

### Uniqueness

When `addressLine1` is present, the combination of `(name, addressLine1)` must be unique among the active locations of the same owner: one company, or the organisation's own sites. A duplicate returns `409 location_duplicate` with `details: { field: "name", existingId }` and a message naming the clash, for example "You already have a location called Yard at 12 Main Road. Please use that one, or give this one a different name." The same rule applies to `POST /parties/:id/locations`. PostgreSQL treats a missing address as `NULL`, so this constraint does not prevent two same-named locations whose addresses are both absent.

Different owners can share an address. See [Shared address warning](#shared-address-warning).

A location retired on its own (its `ownStatus` is `inactive`) does not hold its name and address combination against new locations. You can create a new active location with the same name and address as a retired one. The index reads the location's own status, not its company's: a location that reads `inactive` only because its company is retired still holds its name and address. Restoring the retired location to `active` while a live location already holds that name and address returns `409`.

### Shared address warning

When a saved location shares its address with another owner's active location, the save still succeeds and the response says so. Location create and update responses (`POST /locations`, `PUT /locations/:id`, `POST /parties/:id/locations`, and the `location` on a combined `POST /parties`) carry `sharedAddressWith`: an array of `{ partyId, companyName, locationId, locationName }` (at most 20), empty when nobody else is there. Address line and city are compared trimmed and case-insensitively. `partyId` is `null` for one of the organisation's own sites.

`GET /locations/shared-address` runs the same check before a save. Any signed-in organisation user can call it.

| Query parameter | Type | Required | Notes |
| - | - | - | - |
| `addressLine1` | string | Yes | The address line to check. |
| `city` | string | No | Matched with the address line. An omitted city matches only sites with no city. |
| `partyId` | UUID \| `ours` | No | The owner being saved, whose own sites are left out. Defaults to `ours`. |
| `excludeLocationId` | UUID | No | The location being edited, so it is never reported against itself. |

The response is `{ sharedWith: [...] }`, with the same item shape as `sharedAddressWith`.

## Rate references

`POST /rate-references/resolve-batch` is an organisation-scoped resolution endpoint used by the Rates service to look up the display names and current state of companies and locations referenced by stored rate options. Rates delegates the initiating caller's bearer token.

The request body is:

```json theme={null}
{
  "locationIds": ["uuid", "uuid"],
  "supplierIds": ["uuid", "uuid"]
}
```

Both arrays are optional and default to empty. Each list accepts at most 200 ids. Ids that do not exist or belong to a different organisation are silently absent from the response. The response contains two independent arrays:

```json theme={null}
{
  "locations": [ /* location objects */ ],
  "suppliers": [
    { "id": "uuid", "name": "Fulton Hogan", "isSupplier": true, "status": "active" }
  ]
}
```

Each `suppliers` entry contains:

| Field | Type | Notes |
| - | - | - |
| `id` | UUID | The company id. |
| `name` | string | Display name. |
| `isSupplier` | boolean | Whether the company still holds the supplier role. |
| `status` | `"active"` \| `"inactive"` | Company status. Retired companies are returned so that historic rates can still display their label. |

Each `locations` entry follows the standard [location shape](#location-fields). Missing ids are simply absent from the response; a caller that needs all ids to resolve must compare counts. The endpoint returns `200` regardless of whether any ids resolved.

### Access control

`POST /rate-references/resolve-batch` requires an authenticated organisation member token. A platform or service token without an organisation context receives `404 Rate references not found`.

## Rate categories

Rate categories are tenant-owned. Every rate category belongs to exactly one organisation.

Every category carries a `parentGroup` — one of the six rate-group tokens (`labour`, `plant_vehicles`, `quarry_materials`, `other_materials`, `subcontractors`, `disposal`) — and an `allowedUnitCodes` array with at least one unit. Rate items reference a category directly by id.

The optional `isCartageVehicle` flag is valid only under `plant_vehicles`. An incompatible flag returns `422`. Every allowed unit code must exist in the unit catalogue; unknown codes return `422`. A category's allowed units are not constrained by its parent group — any unit that exists in the catalogue is valid under any parent group.

### Access control

| Operation | Required role |
| - | - |
| `GET /rate-categories`, `GET /rate-categories/:id` | Any authenticated org member |
| `POST /rate-categories` | Org admin |
| `PUT /rate-categories/:id` | Org admin |
| `DELETE /rate-categories/:id` | Org admin |

Platform tokens return `403` on all write routes, and unauthenticated callers
receive `401`.

### Isolation

Authenticated list and detail reads return only the caller's organisation's rate
categories. Requesting a rate category that belongs to another organisation
returns `404`.

### Uniqueness

Rate category names must be unique among active categories within an organisation. The same name may exist in different organisations without conflict. A deleted category releases its name, so a new active category can reuse it. A duplicate returns `409`.

### parentGroup field

Every rate category has a `parentGroup` field that classifies it into one of the six fixed rate-group wire tokens: `labour`, `plant_vehicles`, `quarry_materials`, `other_materials`, `subcontractors`, or `disposal`. This field is the authoritative source of classification for each category.

`parentGroup` is required on both `POST /rate-categories` and `PUT /rate-categories/:id`. Omitting it or sending `null` returns `400`. Sending an unrecognised token returns `400`.

The `disposal` group is for earthworks disposal categories. Current create and update requests must supply `allowedUnitCodes` explicitly. Legacy seed-snapshot ingest falls back to `cubic_metre` and `tonne` only when an old category payload omitted its unit list.

### Create and update body

`POST /rate-categories` and `PUT /rate-categories/:id` use the same full body:

| Field | Type | Required | Notes |
| - | - | - | - |
| `name` | string | Yes | Category name. |
| `parentGroup` | string | Yes | One of the six fixed rate-group tokens. |
| `allowedUnitCodes` | string\[] | Yes | At least one unit code that exists in the unit catalogue. Not restricted by parent group. Duplicate codes are removed. |
| `isCartageVehicle` | boolean | No | Valid only under `plant_vehicles`; defaults to `false`. |
| `marginPercent` | number \| null | No | The category's percentage for the margin ladder — the most specific rung. Valid range depends on the organisation's pricing basis: `0`–`95` (margin) or `-100`–`999.999` (markup), each with at most three decimal places. A value legal on the other basis but not the current one returns `400` with code `margin_percent_out_of_basis_band`. Omitting the key on `PUT` preserves the stored value; sending `null` clears it, meaning the category inherits from its rate group or organisation default. |

The retired `isOutgoing` field is not accepted. Both schemas are strict, so sending it returns `400 Bad Request`.

### Delete behaviour

`DELETE /rate-categories/:id` soft-deletes the category by setting its `status` to `deleted`. It returns `409 Conflict` while any non-deleted rate item still references the category, including project-scoped rate items. Reclassify or delete those items first before deleting the category.

The endpoint returns `204` after a successful delete and also for a repeated delete. Deleted categories are omitted from catalogue lists, cannot be edited, and remain readable by id so historical references keep their category identity. A new active category may reuse a deleted category's name.

## Rate group settings

Rate group settings store a per-group percentage and an optional display colour for each of the six parent rate groups. New estimates snapshot the percentages alongside category and organisation defaults. Changing them does not update an existing estimate's frozen ladder. The API accepts these settings on either pricing basis. The valid range depends on the basis: `0`–`95` for margin or `-100`–`999.999` for markup. The Settings UI labels the panel and fields for the organisation's basis.

```text theme={null}
GET /api/global/rate-group-settings
PUT /api/global/rate-group-settings/:rateGroup
PATCH /api/global/rate-group-settings/:rateGroup/colour
```

### Access control

| Operation | Required role |
| - | - |
| `GET /rate-group-settings` | Authenticated organisation member |
| `PUT /rate-group-settings/:rateGroup` | Organisation admin |
| `PATCH /rate-group-settings/:rateGroup/colour` | Organisation admin |

### GET /rate-group-settings

Returns the margin setting and display colour for all six rate groups, always in the canonical group order: `labour`, `plant_vehicles`, `quarry_materials`, `other_materials`, `subcontractors`, `disposal`. Groups the organisation has not configured return `marginPercent: null` and `colour: null`.

```json theme={null}
{
  "items": [
    { "rateGroup": "labour", "marginPercent": 18.5, "colour": "#3d6db3" },
    { "rateGroup": "plant_vehicles", "marginPercent": null, "colour": null },
    { "rateGroup": "quarry_materials", "marginPercent": null, "colour": null },
    { "rateGroup": "other_materials", "marginPercent": null, "colour": null },
    { "rateGroup": "subcontractors", "marginPercent": null, "colour": null },
    { "rateGroup": "disposal", "marginPercent": null, "colour": null }
  ]
}
```

Each item contains:

| Field | Type | Notes |
| - | - | - |
| `rateGroup` | string | One of the six fixed tokens. |
| `marginPercent` | number \| null | The stored percentage, or `null` if not set. |
| `colour` | string \| null | A six-digit hex colour string including the `#` prefix (e.g. `#3d6db3`), or `null` if not set. |

### PUT /rate-group-settings/:rateGroup

Sets or clears the margin for one rate group. `:rateGroup` must be one of the six valid tokens; an unknown token returns `400`.

```json theme={null}
{ "marginPercent": 18.5 }
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `marginPercent` | number \| null | Yes | The percentage for this rate group. Valid range depends on the organisation's pricing basis: `0`–`95` (margin) or `-100`–`999.999` (markup), each with at most three decimal places. Send `null` to clear the group setting, meaning estimates fall back to the organisation default. |

Returns `200` with the saved `{ rateGroup, marginPercent, colour }` record. A value outside the organisation's basis band returns `400` with code `margin_percent_out_of_basis_band`. An unauthenticated request returns `401`; a non-admin caller returns `403`.

The `PUT` body carries only `marginPercent`. It never overwrites the stored `colour` — the two fields are updated independently to prevent a concurrent colour change from being lost.

### PATCH /rate-group-settings/:rateGroup/colour

Sets or clears the display colour for one rate group. `:rateGroup` must be one of the six valid tokens; an unknown token returns `400`.

```json theme={null}
{ "colour": "#1a73e8" }
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `colour` | string \| null | Yes | Hex colour in `#RRGGBB` format. Send `null` to clear the custom colour and revert to the theme default for that group. |

Returns `200` with the updated rate group settings row `{ rateGroup, marginPercent, colour }`. A malformed hex value returns `400`. An unauthenticated request returns `401`; a non-admin caller returns `403`.

When `colour` is `null`, the group uses a colour from the shared theme palette. The palette assigns one of six distinct colours per group and respects the viewer's light or dark mode preference. Custom colours override the palette and appear in estimate and activity build-ups regardless of mode.

### The margin ladder

Organisation defaults supply these rungs for a new estimate, from most specific to least specific:

1. Category margin (`marginPercent` on the rate category).
2. Rate-group margin (`PUT /rate-group-settings/:rateGroup`).
3. Organisation default (`defaultMarkupPercent` in organisation settings, interpreted according to `pricingBasis`).

At pricing time, components can also have their own overrides, and the tender can override category/group values or supply a default before the frozen organisation default. See [Estimate margin ladder](https://help.stralos.app/api/estimate.md#margin-ladder). A `null` means inherit from the next less specific rung. A category margin of `0` is a real and distinct answer from `null`; zero margin suppresses any group or organisation default rather than inheriting it.

## Setting list items

Setting list items are partitioned into two classes based on their `listKey`:

* **Org-scoped vocabulary keys** — `takeoff_categories`, `rate_group_labels`, and `takeoff_span_tags`. These are tenant-owned; each row belongs to an organisation.
* **Suite-global, platform-managed keys** — all other registered keys (e.g. `project_statuses`). No organisation owns these rows. These values are code-coupled enums or platform-managed vocabulary.

The following keys are retired. All write operations return `410` with code `retired_setting_list`:

| Key | Successor |
| - | - |
| `company_locations` | Use structured locations instead. |
| `labour_rate_multipliers` | Use a rate option with a multiplier basis instead. |
| `allowances_assumptions` | Use `/departures-clarifications` instead. |
| `tags_clarifications` | Use `/departures-clarifications` instead. |

The `allowances_assumptions` and `tags_clarifications` keys merged into the single departures and clarifications list in migration 0080. Their rows were copied across with their original ids, so existing tender clause links remain valid. Read requests with `listKey=allowances_assumptions` or `listKey=tags_clarifications` are still answered from the new table, in the setting-list-item shape, for backwards compatibility until callers move to `/departures-clarifications`.

`rate_group_labels` stores optional per-organisation display overrides for the six fixed parent groups. `value` must be one of `labour`, `plant_vehicles`, `quarry_materials`, `other_materials`, `subcontractors`, or `disposal`; `label` is the custom text. Only overrides are stored, so an absent row uses the domain default. Changing a label never changes the wire token or classification behaviour. Clearing a label deletes its override row (a soft delete, like any list value), so the group falls back to its default name; setting a label again adds a new row.

### Default lists

When a new organisation is first projected into the Global service, Stralos seeds the `rate_group_labels` and `takeoff_categories` lists with default values if the organisation has no rows for those keys yet. The defaults are:

`rate_group_labels` — the six fixed parent groups with their standard display names:

| `value` | Default `label` |
| - | - |
| `labour` | Labour |
| `plant_vehicles` | Plant & Vehicles |
| `quarry_materials` | Bulk Materials |
| `other_materials` | Materials |
| `subcontractors` | Subcontractors |
| `disposal` | Disposal |

`takeoff_categories` — the standard takeoff panel categories (including the show-all entry):

`All`, `Drainage`, `Stormwater`, `Water`, `Roading`, `Services`, `Other`.

A list that already has at least one row is never overwritten. Deleting all rows from a list does not cause it to be re-seeded on the next organisation update.

`allowances_assumptions` and `tags_clarifications` are retired and no longer appear in this list. Use `/departures-clarifications` to manage that vocabulary.

### Setting list sort order validation

The `sortOrder` field on `POST /setting-list-items` and `PUT /setting-list-items/:id` must be a whole number of 0 or more. Sending a non-integer or negative value returns `400` with the message `Position in list must be a whole number of 0 or more.`

The legacy `rate_units` and `work_item_units` keys are retired. Work-item,
activity, and rate editors read the immutable unit catalogue from
`GET /unit-catalogue`; those units are not editable setting-list rows.

### Access control

| Operation | Org-scoped key | Suite-global key |
| - | - | - |
| `GET /setting-list-items`, `GET /setting-list-items/:id` | Authenticated org caller sees globals + own org | Same |
| `POST /setting-list-items` | Org admin only | Retired — returns `410` |
| `PUT /setting-list-items/:id` | Org admin (own-org rows only) | Retired — returns `410` |
| `DELETE /setting-list-items/:id` | Org admin (own-org rows only) | Retired — returns `410` |

Suite-global mutations are now owned by `stralos-admin-api`. A platform token
attempting a suite-global write receives `410` with code `admin_owned`. An
organisation admin attempting to create a suite-global item receives `403`;
update and delete attempts follow the isolation and partition rules below.
Members receive `403` on all writes.

### Isolation

Authenticated reads return global rows plus the caller's own organisation's
vocabulary rows. Another organisation's rows are not returned to an
authenticated organisation caller.

An organisation admin attempting to update or delete a global or foreign-org row
receives `404`. The platform-token writer path is retired and returns `410
admin_owned` after its authentication and authority gates, regardless of the
stored row's partition.

### Partition integrity

A `PUT` that would move a row from one partition to the other (changing `listKey` to a value in the other class) returns `422` with code `list_key_partition_change`.

### Delete behaviour

`DELETE /setting-list-items/:id` soft-deletes the row. The record is retained with `deletedAt` stamped; it does not appear in `GET /setting-list-items` list responses, but `GET /setting-list-items/:id` still returns it with `deletedAt` populated.

Deleting a value that is already deleted returns `204` and keeps the original timestamp unchanged.

`PUT /setting-list-items/:id` on a deleted value returns `409` with code `deleted`.

### Uniqueness

* Global rows: `(listKey, value)` must be unique among live (non-deleted) global rows.
* Org-scoped rows: `(organisationId, listKey, value)` must be unique among live (non-deleted) rows within an organisation. The same value may exist for the same key in different organisations.

Deleted rows do not participate in these constraints, so a value that was previously deleted can be added again.

A conflict on a live row returns `409`.

### Query parameters

| Parameter | Type | Notes |
| - | - | - |
| `listKey` | string | Optional. Filters results to a specific list key. |
| `start` | integer | Optional. Zero-based offset; defaults to `0`. |
| `length` | integer | Optional. Number of records to return; defaults to `50` and is clamped from `1` to `200`. |
| `search` | string | Optional. Case-insensitive partial match across `listKey`, `label`, and `value`. |

## Unit catalogue

`GET /unit-catalogue` returns the 27 canonical units shared by work-item,
activity, and rate editors. These rows are immutable platform reference data,
not setting-list values. There are no create, update, delete, organisation
settings, or admin-console routes for the catalogue.

```text theme={null}
GET /api/global/unit-catalogue
```

Each row contains `id`, `code`, `name`, `symbol`, `category`, `usage`, and
`sortOrder`. Categories are Length, Area, Volume, Weight, Count, and Time.
The endpoint supports the standard `start`, `length`, and `search` parameters;
search matches `code`, `name`, `symbol`, `category`, and `usage`. Results are
ordered by `sortOrder`, then `name`.

Unit symbols follow a two-rule convention: SI symbols keep their standard form
(e.g. `m`, `m²`, `m³`, `kg`, `t`, `L`, `kL`, `ML`) and initialisms keep
their upper case (e.g. `LS`, `PS`). All other symbols are lower case
(e.g. `mm`, `km`, `ha`, `ea`, `hr`, `day`, `no.`, `set`, `pair`, `lot`,
`item`, `len`, `roll`, `sheet`).

The `length` unit (`code: "length"`, symbol `len`, category `Length`) represents
a supplied stock length — for example, a 6 m length of pipe or timber sold as
one unit. It is available on rate categories under the `other_materials` parent
group that already allow metres. It is distinct from the `metre` unit: use
`metre` when estimating a measured linear quantity, and `length` when pricing
by the number of whole supplied lengths purchased.

Stock sold by the roll or the sheet has three more units. `roll_length`
(Roll Length, symbol `roll`, category `Length`) is priced by the roll's length.
`sheet` (Sheet, symbol `sheet`, category `Area`) and `roll_area` (Roll Area,
symbol `roll`, category `Area`) are priced by area. The two Roll units share a
symbol, so the code and category tell them apart. Every `other_materials` rate
category that allows metres also allows `roll_length`, and every one that allows
square metres also allows `sheet` and `roll_area`.

The `provisional_sum` unit (`code: "provisional_sum"`, symbol `PS`, category `Count`) represents
an allowance for work not yet fully defined. It sits beside `lump_sum` in sort order and is
the correct unit for provisional-sum work items. Like `lump_sum`, it is a Count-category unit
and is not a measured quantity.

## Retired runtime writers

The runtime no longer owns user provisioning or suite-global administration.
After authentication and authority checks, callers that reach these retired
writers receive:

| Route or operation | Current behaviour |
| - | - |
| `POST /users` | `410 Gone`, code `admin_owned` |
| Suite-global `POST`, `PUT`, or `DELETE /setting-list-items` using a platform token | `410 Gone`, code `admin_owned` |

Use `stralos-admin-api` for supported platform administration. Organisation-owned
vocabulary writes by an organisation admin are unaffected.

The `PUT /app-config/:key` tombstone, which returned `410 Gone` with code
`admin_owned`, was removed in October 2026. A request to it now returns `404`.
Use the admin API's `PUT /app-config/:key`.

## App configuration

`GET /app-config` returns the remaining suite-global application configuration.
The Global API has no write route for it. `default_markup_percent` moved to
organisation settings, while `pricing_average_speed_kmh` is estimate-owned.

## Search and filters

* Projects can filter by client and status.
* Companies (`GET /parties`) accept a `role` filter (`client`, `supplier`, or `subcontractor`) and a `status` filter (`active` or `inactive`; defaults to `active`). The list search matches `name` and `businessNumber`.
* Users can filter by organisation and search by name or email.

## Version endpoint

`GET /api/global/version` returns the running service's configured version and deployed commit. This endpoint is **unauthenticated** and is used by the release-model coordinator's minimum-version gate.

### Response

```json theme={null}
{ "version": "1.2.3", "commit": "0123456789abcdef0123456789abcdef01234567" }
```

The response always contains exactly two fields. The endpoint always sets `Cache-Control: no-store` to prevent a cached version from defeating the version gate.

| Field | Source | Notes |
| - | - | - |
| `version` | `SERVICE_VERSION` environment variable, falling back to `package.json` | Semver string used by the release-model coordinator's minimum-version gate. |
| `commit` | `SERVICE_COMMIT` environment variable | 40-character commit SHA stamped by Terraform on every apply. Reports `"unknown"` when the variable is absent (for example, in local development). |

Terraform stamps `SERVICE_COMMIT` on every deployment, including dev deployments that carry no stable semver. Release tooling uses the returned identity to verify that the edge is serving the expected source revision.

## OpenAPI document

```text theme={null}
GET /api/global/openapi.json
```

Returns the OpenAPI 3.1 document generated at service boot from the Global API's route-descriptor registry.

* **Unauthenticated**: no bearer token is required.
* **Cached bytes**: the document is serialised once at boot and returned unchanged on every request. `Cache-Control: no-store` is set so intermediate caches do not hold stale copies.
* **Contract version**: `info.version` is the descriptor-contract version (`1.0.0`), not the deployed service version.

### Response bodies and phasing

Request schemas come from the same Zod objects used by handlers. Response coverage is being added in phases:

* A route with `x-stralos-response-status: undocumented` in its response object does not yet document its success body. The response object is present and describes the record type; the body schema will be added in a later pass.
* List routes document the shared `ListEnvelope` (`{ items, totalCount, start, length }`) but leave the item element schema undocumented until row schemas are lifted.
* The shared `ErrorEnvelope` (`{ message, issues?, code? }`) is referenced by every error response.

## Unhandled error responses

Errors that are not classified by a route handler are answered with a safe fixed response rather than raw error text. Two shapes are possible:

**Saturated database** — when the API cannot obtain a database connection because the pool is exhausted, it returns:

```http theme={null}
HTTP/1.1 503 Service Unavailable
Retry-After: 5
```

```json theme={null}
{
  "code": "service_unavailable",
  "reference": "<request-id>"
}
```

Retry after the number of seconds given in the `Retry-After` header.

**Other unclassified errors** — any other error that escapes a route handler returns:

```http theme={null}
HTTP/1.1 500 Internal Server Error
x-request-id: <request-id>
```

```json theme={null}
{
  "code": "internal_error",
  "reference": "<request-id>"
}
```

The `reference` value matches the `x-request-id` response header. Include it when reporting an issue so the server-side log entry can be located.

### Security

Protected operations carry the document-level `bearerAuth` requirement and a `401` response. The
five auth and login routes (`POST /auth/otp/request`, `POST /auth/otp/verify`,
`POST /auth/refresh`, `POST /auth/switch-org`, `POST /auth/logout`) are the only public
descriptor-backed operations: they carry `security: []` and do not document a `401`. Auth-exempt
health, version, OpenAPI, JWKS, and local-mint infrastructure remains outside the document.

## Backend contract tests

`stralos-api-global` has a dedicated contract test suite (`pnpm test:contract`) that pins the request validation and response shape for endpoints consumed by other backend services. CI runs these under the step named **Backend API contracts (receiving requests and responses)**.

### Receiving side

The covered endpoints and their assertions are documented in `BACKEND_CONTRACTS.md` at the root of the repository. The suite currently covers:

| Contract | Caller | Endpoints |
| - | - | - |
| BC-03 | `stralos-api-assistant` | `GET /projects`, `GET /projects/:id`, `POST /projects`, `POST /parties` |
| BC-05 | `stralos-api-rates` | `GET /projects/:id`, `GET /projects/:id/location-routes`, `POST /projects/:id/location-routes/resolve` |
| BC-09 | `stralos-api-estimate` | `GET /projects/:id/location-routes`, `GET /organisation-settings` |
| BC-11 | `stralos-api-scheduler` | `POST /internal/service-tokens`, `GET /projects/statuses` |
| BC-12 | `stralos-api-documents` | `GET /projects/:id` |
| BC-14 | `stralos-api-estimate` | `POST /internal/service-tokens`, `POST /projects/:projectId/transition` |

The receiver unit suite is in `test/requests.backendContract.unit.test.ts`. Only the endpoints explicitly listed in `BACKEND_CONTRACTS.md` are covered; other endpoints rely on the existing integration and security suites.

### Sending side

The outgoing requests that Global sends to peer services are documented in `SENDING_CONTRACTS.md` at the root of the repository:

| Contract | Receiver | Endpoint |
| - | - | - |
| BC-10 | `stralos-api-rates` | `GET /api/rates/rate-items` |
| BC-13 | `stralos-api-estimate` | `POST /api/estimate/projects/:projectId/estimate/transition` |

Each contract ID is shared with the corresponding sending or receiving notes in the peer repository to make both sides discoverable.

## Storage

The service uses Postgres for relational shared data. Runtime code uses DynamoDB for OTP challenges and the retained regional refresh-token sessions. The shell's current session mutations use the central login-service session store.

Terraform defines a regional REST API Gateway v1 stage plus SES domain and DKIM resources for OTP delivery. Configure signing keys and session-table wiring, validate the SES domain, and confirm account readiness before enabling OTP email delivery. See [Deployment](https://help.stralos.app/internal/deployment.md).

## Local development

Local setup uses Postgres plus DynamoDB Local:

```bash theme={null}
pnpm install
cp .env.example .env
pnpm local:dynamodb
pnpm local:dynamodb:init
pnpm db:migrate
pnpm seed:auth
pnpm dev
```

## Departures and clarifications

This organisation-owned list replaces the retired `allowances_assumptions` and `tags_clarifications` keys. Migrated entries retain their IDs and legacy list key, so existing tender references still resolve.

Each item contains `id`, `organisationId`, `group` (string or `null`), `text`, `sortOrder`, `legacyListKey` (string or `null`), `createdAt`, `updatedAt`, and `deletedAt`. A null group appears as **General** in the UI. Group names are matched case-insensitively to the existing spelling.

| Method and path | Behaviour |
| - | - |
| `GET /departures-clarifications` | Paginated list (`items`, `totalCount`, `start`, `length`). Supports `search` on text and group, and `includeDeleted=true`. Orders by group case-insensitively with ungrouped entries last, then `sortOrder`, creation time, and ID. |
| `GET /departures-clarifications/groups` | Returns `{ data: string[] }` with distinct live group headings, sorted case-insensitively. Ungrouped entries are excluded. |
| `GET /departures-clarifications/:id` | Returns one entry, including a deleted entry with its deletion timestamp. |
| `POST /departures-clarifications` | Creates an entry; returns `201`. |
| `PATCH /departures-clarifications/:id` | Updates supplied fields; a deleted entry returns `409 deleted`. |
| `DELETE /departures-clarifications/:id` | Soft-deletes an entry; returns `204`. Repeating the delete preserves its original deletion timestamp. |
| `POST /departures-clarifications/:id/restore` | Restores an entry with its ID and position; returns `200`. Restoring a live entry makes no change. |

Reads require an authenticated organisation member. Writes require an organisation admin. An entry outside the caller's organisation returns `404`.

Create requires non-blank `text` (maximum 4,000 characters). `group` is optional, trimmed, and nullable; blank stores no group. `sortOrder` is an optional whole number of 0 or more; omission appends after the organisation's highest position, including retired entries. PATCH accepts the same fields, all optional; omitted fields are unchanged, and `group: null` clears the group. Invalid fields return `400`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.