Base prefix
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
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.
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.
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:
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 atimezone 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 tostralos-admin-api, which owns billing state.
GET /org/organisations
Returns the signed-in person’s active organisations. The Global API relays the request tostralos-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.
{ 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.
PUT /org/organisation
Updates the caller’s own organisation. The Global API relays the request tostralos-admin-api, which owns the organisation record and enforces the rule: the caller must be an organisation administrator.
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.
GET /org/users
Returns the list of people in the caller’s organisation.POST /org/users
Invites a person to the caller’s organisation. The request body is forwarded tostralos-admin-api unmodified — that service owns the invite schema and returns its own validation messages.
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.
PUT /org/users/:userId
Changes a member’s display name or role within the caller’s organisation. The request body is forwarded tostralos-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.
DELETE /org/users/:userId
Removes a member from the caller’s organisation. The Global API relays the request tostralos-admin-api, which owns the membership record and enforces all removal rules.
401 or 403. The billing entitlement check applies when billing enforcement is enabled.
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 returnscanSelfJoin: true and the caller can join it with POST /projects/:id/self-join (see 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
{ "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
{ "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.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 }.
Default office
Each person has a default office per organisation. Stralos resolves the effective default from three sources, in priority order:
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.PUT /me/default-office
Sets or clears the calling user’s chosen default office.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.
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.
The response follows the standard list envelope:
userId, locationId (UUID or null), and source. Members are ordered by user ID.
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 returns404.
PUT /organisation-users/:userId/default-office
Sets or clears the default office for one member of the caller’s organisation.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.
Tour progress
Stralos stores each person’s guided-tour progress per organisation (migration 0087). Each tour is identified by atourId 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
tours object keyed by tourId. A person with no progress receives { "tours": {} }.
PUT /me/tour-progress/:tourId
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.
The body is strict: unknown keys, such as
userId or updatedAt, return 400.
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 tostralos-admin-api, which owns those records and every rule behind them.
See Contractor companies for the data model and Contractor copy and refresh 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 tostralos-admin-api.
403 user_token_required.
Contractor link relays
These routes relay tostralos-admin-api over SigV4, the same relay as 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.
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 fromsourceOrgId, and an admin of that contractor company.
POST /org/contractor-copy/preview
Counts what a copy would bring, without writing anything. The body is the same as a copy job’s, withoutkind 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:
Rows the client company created itself never appear.
POST /org/contractor-copy-jobs
Starts a copy or refresh job and runs its first slice. Returns202 with { jobId, status }.
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 afailed 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:
List responses from
GET /parties include two additional fields per row:
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 includeslocationCount and contactCount in addition to the standard company fields.
POST /parties
Creates a company. Returns201 with the created record.
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 asPOST /parties/:id/contacts(see Company contacts) apart fromlocationId. It must have a phone or an email, otherwise the create returns422 contact_reach_required.address:{ name, kind, addressLine1, city, region, countryCode, latitude, longitude, phone, email, gstNumber }.namedefaults toOfficeandkindtooffice. The address must be a complete picked one, with an address line and coordinates, otherwise the create returns422 location_address_required. The location is created as a site of the new company.
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.
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 asPOST /parties.
Removing a role that is still in use returns 409 with code role_in_use:
- Removing
isClientis blocked when the company is assigned to one or more projects.
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 settingstatus 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), 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
409with codeparty_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
WhenisSupplier 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 isinactive 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:
POST /parties/:id/contacts
Creates a contact for the company. Returns201.
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 asPOST /parties/:id/contacts, including the email and phone format constraints.
DELETE /party-contacts/:id
Deletes the contact and returns204. 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 for the full field list. Each row’s status is the effective status and ownStatus is the location’s own.
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. Returns201 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.
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 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.
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. GET /parties/:id also carries the active logo.
Project fields
Every project response includes the following fields alongside standard metadata: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.
capabilities is not included in list responses (GET /projects, GET /project-directory).
Project create body
POST /projects accepts these fields:
The project body does not carry the Search distance. Set it with
PUT /projects/:id/search-radius; see 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(defaultQ-) andnextQuoteNumber: counter3yieldsQ-0003.POST /projectsallocates the project’s quote number in the create transaction, so a failed create rolls the counter back. - Job numbers use
jobNumberPrefixandnextProjectNumber. WhenjobNumberPrefixisnull, the prefixJOB-is used: counter3yieldsJOB-0003. A job number is allocated once, in the transaction that moves the project toawardedwhen an estimate is marked Won, andjobCreatedAtis stamped at the same time.
Number allocation errors
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
{ "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" }.
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
Projectstatus is server-owned. Status can only change through POST /projects/:id/transition.
POST /projectsalways creates a project withstatus: "lead". A suppliedstatuskey is ignored for compatibility.PUT /projects/:idmay 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/:idreturns400 Bad Requestwith codestatus_via_transitionand 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
Available actions
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
Idempotency
Supply a new UUID forcorrelationId 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 onepending 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
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
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
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
endDateonPOST /projectsorPUT /projects/:idleaves any existing value unchanged. - Sending
endDate: nullclears the stored value. - Sending a date string sets it.
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 scopeprojects: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
Response
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
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:
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 explicitproject_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.
Each item in the response contains:
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.
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.
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).
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.
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.items— current team members. Each entry containsid(membership ID),userId,name,email,roles(string array),grantedAt,grantedBy, andprovenance(creatorfor the user who created a new project,assignedfor members added through the API, ormigrationfor rollout memberships).candidates— active organisation users not yet on the team. Each entry containsid,name, andemail.
PUT /projects/:id/team/:userId
Assigns or updates a team member’s role.DELETE /projects/:id/team/:userId
Removes a team member from the project.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.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.
Returns
201 with { id, state: "pending" }.
PATCH /projects/:id/access-requests/:requestId
Approves, declines, or withdraws an access request.approvedanddeclinedrequire project lead or organisation admin access. Approving a request also adds the requester as a project member.withdrawncan only be sent by the original requester.
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.id, actorId, subjectId, action, detail, and createdAt. Common action values are assigned, removed, requested, approved, declined, withdrawn, escalated, and self_joined.
POST /projects/:id/self-join
Adds the caller to the project as amember when the project’s office is one of their self-assign locations.
self_joined in the access history. The response contains projectId, userId, and roles (for a new join, ["member"]).
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 hasexists: false and all fields are null or empty — the endpoint never returns 404.
PUT /projects/:id/contract-profile
Creates or replaces the contract profile.contractForm is required; all other fields are optional.
Accepted
contractForm values:
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
WhencontractDocumentIds 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
customforms, at least one document must have statusprocessed. - The caller’s
Authorizationheader 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-
2xxresponse, thePUTreturns502and the profile is not saved.
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.
404.
Request body
Response
null when cleared). Every project response also carries searchRadiusKm.
Error responses
GET /projects/:id/office-location
Returns only what rate pricing reads about where a project prices from: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.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.
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.Query parameters
Send at least one location ID or company ID.
Response
routes contains:
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
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.Request body
Send at least one location ID or company ID.
partyIds, straightLineKm, and alsoNearest are refused with 400 unless partial is true.
Response
routes array holds the resolved legs, ordered to match the deduplicated requested locations. Each entry contains:
unroutable is present only when partial: true is sent. Each entry has the locationId and a reason:
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
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 theseed_template.provisioned event. The substances seeded depend on what the admin seed template carries:
- Template includes a
disposalSubstancesarray: those rows are seeded (admin-managed defaults for the organisation’s business type). - Template includes an empty
disposalSubstancesarray ([]): 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.
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 bysortOrder ascending then name alphabetically.
The response follows the standard list envelope:
{ items, totalCount, start, length }.
POST /disposal-substances
Creates a new disposal substance. Returns201 with the created record.
PUT /disposal-substances/:id
Replaces a disposal substance. Requires an organisation admin. The substance must exist and belong to the caller’s organisation; otherwise returns404.
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 itsstatus 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: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: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 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.
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 asGET /organisation-settings, including the read-only organisationName and countryCode fields.
Members and platform tokens without an organisation receive
403. Schema-invalid values receive 400.
PATCH /organisation-settings/onboarding
Organisation admins record checklist progress atPATCH /api/global/organisation-settings/onboarding. The strict body requires at least one of these fields; unknown keys return 400:
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 throughPUT /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 for the upload flow, storage and validation.
GET /organisation-branding
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. Thelogo 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
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.
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.
Each step reads live records, not the onboarding checklist state:
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
Location fields
Every location response includes:
The
geoTag object contains:
GET /locations query parameters
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:
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.
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:
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
WhenaddressLine1 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.
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.
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:
suppliers entry contains:
Each
locations entry follows the standard location shape. 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 aparentGroup — 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
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 returns404.
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 returns409.
parentGroup field
Every rate category has aparentGroup 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:
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.
Access control
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.
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.
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.
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:- Category margin (
marginPercenton the rate category). - Rate-group margin (
PUT /rate-group-settings/:rateGroup). - Organisation default (
defaultMarkupPercentin organisation settings, interpreted according topricingBasis).
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 theirlistKey:
- Org-scoped vocabulary keys —
takeoff_categories,rate_group_labels, andtakeoff_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.
410 with code retired_setting_list:
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 therate_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:
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
ThesortOrder 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
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 receives404. 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
APUT 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.
409.
Query parameters
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.
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:
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 arolefilter (client,supplier, orsubcontractor) and astatusfilter (activeorinactive; defaults toactive). The list search matchesnameandbusinessNumber. - 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
Cache-Control: no-store to prevent a cached version from defeating the version gate.
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
- Unauthenticated: no bearer token is required.
- Cached bytes: the document is serialised once at boot and returned unchanged on every request.
Cache-Control: no-storeis set so intermediate caches do not hold stale copies. - Contract version:
info.versionis 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: undocumentedin 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:Retry-After header.
Other unclassified errors — any other error that escapes a route handler returns:
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-levelbearerAuth 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 inBACKEND_CONTRACTS.md at the root of the repository. The suite currently covers:
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 inSENDING_CONTRACTS.md at the root of the repository:
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.Local development
Local setup uses Postgres plus DynamoDB Local:Departures and clarifications
This organisation-owned list replaces the retiredallowances_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.
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.
