Base prefix
Endpoint groups
Version endpoint
Cache-Control: no-store.
Response
SERVICE_VERSION and falls back to package.json when that variable is absent, such as in local development. Release coordinators compare this value with the manifest’s minimum version before promotion.
OpenAPI document endpoint
Cache-Control: no-store. The document is built once at service startup from the route-descriptor registry and served as stable bytes — it is never recomputed per request.
The document uses bearerAuth (http bearer JWT) as its security scheme. Every
descriptor-backed business operation is protected. Health, version, and OpenAPI are
auth-exempt infrastructure routes and are intentionally excluded from the document’s operations.
Listing activities
start, length) plus:
The response uses the standard paginated envelope:
items, totalCount, start, and length. Both the items list and the totalCount are scoped to the same predicate, so the count always reflects the number of activities matching the current filters.
Activity fields
Activities have the following writable fields.Activity name uniqueness
Activity names must be unique within each organisation. The check is case-insensitive and whitespace-trimmed, so"Bulk Excavation" and " bulk excavation " are treated as the same name. POST /activities, POST /activities/from-components, and PUT /activities/:id all enforce this rule on the server side.
When the submitted name matches an existing activity in the organisation, the server returns 409 Conflict:
message field uses the stored spelling of the conflicting activity, not the submitted value. The response also includes field: "name" and existingId (the UUID of the conflicting activity) to help the caller identify the duplicate.
Saving an activity under its own current name (a no-change update) is allowed. The uniqueness check excludes the activity being updated.
Activity DTOs returned by the API also include computed and read-only fields:
pricingMode— eitherproject_margins(the activity prices through project margin settings) orfixed_price(the activity is a publishable package with its own sell amount). All existing activities default toproject_margins.defaultSellMargin— the package’s default sell margin as a percentage (0–95). Present only whenpricingModeisfixed_price;nullotherwise.publishedSellAmount— the sell amount at the time of the last publication. Present only on published fixed-price packages;nullotherwise.publishedCostAmount— the cost build-up at the time of the last publication. Present only on published fixed-price packages;nullotherwise.publishedAt— ISO 8601 timestamp of the last publication. Present only on published fixed-price packages;nullotherwise.componentCount— total number of components on the activity.estimatedCost— sum ofquantity × resourceCount × unitCostacross the activity’s included components. For rate-item lines this is the low end of the indicative range. Optional components are excluded.optionalCost— the same sum across the activity’s optional components. For rate-item lines this is the low end of the indicative range.estimatedCostRange— the indicative cost range for the activity’s included components as{ min, max }. Both ends are in dollars.minequalsestimatedCost. When every component is unranged,minandmaxare equal. See Indicative pricing for library lines.optionalCostRange— the same range for the optional components as{ min, max }.minequalsoptionalCost.optionalComponentCount— how many of the components are optional.components— the activity’s component rows. Each component includesrateGroup, which isnullwhen no group has been recorded, plusoptionalityand anoptionGroupwhen it is optional. Rate-item components also includeunitCostRange(see Indicative pricing for library lines).
productionProfiles— the activity’s production profile rows (see Production profile fields).
published* fields are atomic: either all three are present or all three are null. You cannot publish only a sell amount without a matching cost and timestamp.
Indicative pricing for library lines
Activity library costs are indicative. The same build-up can be performed in different locations at different labour and plant rates, so a rate-item component is priced as a range across all live options for its rate item.unitCostRange on components
Each rate-item component gains aunitCostRange field at read time:
unitCostRange is null when:
- the component is not a rate-item line,
- the rate item has no live options reachable in the component’s unit (options priced by step never count, see Stepped items), or
- the rates service could not be reached.
POST /rate-items/lookup batch read, which returns each referenced item’s live options with their loaded prices. Each option’s price is normalised to the component’s unit; options whose unit conversion is not possible are excluded rather than priced as zero.
A minimum charge is not part of the per-unit range. It is a per-order floor and does not appear in this field.
Stepped items
A rate option can be priced by step: its base pricing rule lists named steps, each a fixed amount for the whole line. A step is never multiplied by quantity or resource count, and the estimate picks the step. api-rates cannot store a stepped rule yet (see Stepped base rules), sostepAmountRange stays null until it can.
A rate-item component whose item has live options priced by step gains a stepAmountRange field at read time:
stepAmountRange is null when the component is not a rate-item line, the rate item has no live option priced by step, or the rates service could not be reached.
Stepped options never contribute to unitCostRange. When a line has no unitCostRange but does have a stepAmountRange, it contributes nothing to estimatedCost, optionalCost or their ranges, and its stored unitCost is not used in its place.
estimatedCostRange and optionalCostRange on activities
The activity DTO includes two range fields that aggregate across all components:estimatedCostRange—{ min, max }for the included components. The low end prices every rate-item line at its cheapest live option; the high end at its most expensive. A non-rate-item line or one with no resolvable range contributes the same figure to both ends.optionalCostRange— the same range for optional components.
estimatedCost equals estimatedCostRange.min. optionalCost equals optionalCostRange.min. When every line has a single price or no live range is available, each range has equal minimum and maximum values. Included and optional totals remain separate and can differ.
Rates service availability
The ranges are resolved outside any database transaction and cached per bearer token for 30 seconds. When the rates service is temporarily unavailable,unitCostRange is null for all rate-item lines, estimatedCostRange.min equals estimatedCostRange.max, and the range uses the stored fallback costs. The activity read still succeeds, but estimatedCost can differ from a successful live-rates read.
Package pricing
Activities support two pricing modes, controlled bypricingMode.
project_margins mode
This is the default. The activity’s cost is derived from its components and priced using the estimating workspace’s margin rules. Package pricing fields (defaultSellMargin, publishedSellAmount, publishedCostAmount, publishedAt) must not be supplied on a project_margins activity — any attempt returns 422 Unprocessable Entity.
fixed_price mode
Afixed_price activity is a subcontract package with a single sell price. It may carry a defaultSellMargin (a percentage). The sell price is recorded by supplying sellAmount in the same create or update request — there is no separate publish step.
Switching an activity from fixed_price back to project_margins clears all package pricing fields atomically.
Recording a sell price
SupplysellAmount on POST /activities, POST /activities/from-components, or PUT /activities/:id. The server records the sell, the current included build-up cost snapshot, and the timestamp in the same transaction as the rest of the save.
Validation
sellAmountmay only be supplied whenpricingModeisfixed_price— otherwise422 Unprocessable Entity.- Supplying
sellAmountalongsidepricingMode: "project_margins"in the same request returns422 Unprocessable Entity. - The included build-up cost must fit within the service’s
numeric(14,2)money boundary — otherwise422 Unprocessable Entity.
defaultSellMargin is not required. When it is supplied with sellAmount, the margin, sell, cost, and timestamp are saved atomically. The inline sellAmount has no tolerance check against the build-up cost.
Margin lock
Once a sell price is recorded, thedefaultSellMargin cannot be changed through a PUT /activities/:id request that does not also update the price. To change the margin on a priced activity, either:
- clear the price first by sending
sellAmount: null, then update, or - supply both a new
defaultSellMarginand an updatedsellAmountin the same request.
PUT while a price is recorded without also changing the price returns 409 Conflict.
Changing the basis unit when profiles exist
When updating an activity (PUT /activities/:id), changing the unit field while the activity has production profiles requires an extra confirmation field:
Activity component fields
Activity components have the following writable fields.
Activity component DTOs returned by read endpoints also include these computed fields:
Component optionality
Components on an activity may be marked asincluded (the default) or optional.
- Included components contribute to
estimatedCoston the activity DTO and are used to compute the package sell when publishing. - Optional components contribute to
optionalCostandoptionalComponentCount. They are not included in the package cost roll-up.
rateItemId or a childActivityId. Setting optionality to optional without either returns 422 Unprocessable Entity.
Legacy embedded calculator rows
Component rows previously generated by the embedded calculator are identified by acalculatorGenerated: true flag in their metadata. These rows are preserved as ordinary components unless the calculator column is explicitly set to null, which deletes them all. New calculator rows are generated through the Activity Calculators entity, not through the calculator column.
Production profile fields
Production profiles store output rates for time-based activities. They are managed via their own endpoints under the activities base prefix.Create a production profile
The write requires a signed-in admin or member of the organisation. A service token receives
403 Forbidden.
The condition fields depthMinM, depthMaxM, diameterMinMm, diameterMaxMm and groundType are not part of the API. A body that still carries them is accepted, but the values are stripped and nothing is stored.
Update a production profile
activityId; every update field is optional. outputDescription and outputUnit must remain non-empty when supplied. Send null or an empty string to clear notes; omitting a field leaves its stored value unchanged.
Delete a production profile
204. The write requires a signed-in admin or member of the organisation. The profile is kept for history: it leaves the activity’s profile list and every read, and it stops being the default. See Deleting activities, components and profiles.
Production profile DTO fields
DTOs returned by the API include:Deleting activities, components and profiles
204. Deleting an activity requires an organisation admin; a member receives 403. Deleting a component or production profile requires a signed-in admin or member. None of them removes the row: it is kept for history with deletedAt (and the acting user as deletedBy).
- Lists hide deleted rows.
GET /activities(includingsearch) and the components and profiles embedded in an activity leave them out. A deleted activity frees its name andcodefor a new activity. GET /activities/:idstill returns a deleted activity, withdeletedAtset. Estimates that already use the activity keep resolving it, so they are unaffected.- Deleting an activity leaves its components and profiles untouched. It releases the activity’s library-calculator assignments: its links are removed, and a calculator whose default destination it was is left with no default. Those calculators stop writing rows into it, and the rows they generated before the delete are kept as they were. A deleted activity cannot be chosen as a calculator destination (
422 calculator_destination_out_of_library). - Deleting a production profile also clears its
isDefault. - A repeat delete returns
204and changes nothing. - Writes to a deleted row return
409with codedeleted. That coversPUTon a deleted activity, component or profile, and creating or updating a component or profile under a deleted activity.
inactive status is unrelated: it is a working state you choose, and an inactive activity stays listed.
Parent and child references
The API validates activity references when creating activities or components.parentActivityId on an activity and childActivityId on a component are soft references — bare UUIDs, not database foreign keys. The service validates them on write: a missing or foreign-organisation reference returns 422 Unprocessable Entity and writes nothing.
activityId on a component is a real composite foreign key backed by a database constraint. Deleting an activity does not remove its components; see Deleting activities, components and profiles.
rateItemId on a component is a soft reference to the Rates service. The activities service stores the ID but does not validate it.
Atomic activity and component creation
403 Forbidden.
Request body
Component entries use the writable fields from
POST /activity-components, except activityId, which the server sets to the newly created activity. In this batch shape, optional rateItemId and childActivityId references may be omitted or explicitly set to null. The rateGroup, optionality, and optionGroup fields are accepted in the same way as the individual create endpoint — optionality defaults to included.
Production profile entries use the create fields above except activityId, which the server also sets. Profiles require a time-based activity basis. A batch can contain at most one profile marked as the default.
Idempotency
An idempotency key identifies one activity within one organisation:- A new key creates the activity and components and returns
201 CreatedwithidempotentReplay: false. - A previously used key returns the original activity and its components with
200 OKandidempotentReplay: true.
Validation and errors
- A malformed activity, component, production profile, or idempotency key returns
400 Bad Request. - An unauthenticated request returns
401 Unauthorized; a service token returns403 Forbidden. - The activity’s
parentActivityIdand every componentchildActivityIdmust reference an activity in the caller’s organisation. Missing or foreign references return422 Unprocessable Entityand write nothing. - More than 500 components returns
422 Unprocessable Entity. - More than 50 production profiles, multiple default profiles, or profiles on a
non-time-based activity returns
422 Unprocessable Entity. - An activity code already used by the organisation returns
409 Conflict. - An activity name already used by the organisation (case-insensitive, whitespace-trimmed) returns
409 Conflictwith codeactivity_name_taken.
Response
The response is the complete activity DTO, includingcomponentCount, estimatedCost, components, productionProfiles, and idempotentReplay.
Rate sheets
A rate sheet is an organisation-owned snapshot of published fixed-price activity sells. Each line freezes the source activity’s name, unit, and sell amount. Later library changes do not alter the sheet until an admin explicitly refreshes it. Reads are available to authenticated organisation members. Create, update, archive, refresh, and duplicate require an organisation admin; other callers receive403.
Eligible activities
A source activity must be active, usefixed_price, have a non-null
publishedSellAmount, and belong to the caller’s organisation. A mutation that
touches lines validates the complete set atomically. An ineligible, foreign, or
missing activity returns 422, as does a repeated activity id.
Sheet and line fields
The server computes
adjustedSellAmount on every response with scaled integer
arithmetic and half-up rounding:
422.
List and get sheets
items, totalCount, start,
and length. It orders by name, then id.
Get-by-id can read either an active or archived sheet. A missing or foreign
sheet returns the same
404 response.
Create a sheet
name and an ordered activityIds array. Optional
fields are clientName, adjustmentPercent, includeLogo, and termsText.
The array may be empty. The service snapshots eligible activities in the given
order and assigns zero-based sortOrder values. It returns 201 with the new
sheet. A duplicate active name returns 409.
Update a sheet
name, clientName, adjustmentPercent, includeLogo,
termsText, or lines is required. Supplying lines replaces the complete
snapshot array. Each strict input line requires activityId, name, unit,
sellAmount, and sortOrder; the service rechecks unique ids and live activity
eligibility, but preserves the submitted display and sell snapshots.
An archived sheet returns 409. Send null to clear clientName or
termsText; omission leaves a field unchanged.
Archive a sheet
status to archived, preserves all snapshot data, and returns
204. Repeating it is an idempotent 204. Archived sheets leave the list and
reject update or refresh with 409.
Preview a refresh
changes array, a count of lines that would change, and a planHash.
The planHash is a SHA-256 digest of the sheet’s current state and every proposed price, name, and unit. It covers the sheet id, organisation, updatedAt timestamp, adjustment settings, and the ordered line tuples for both the saved and proposed snapshots. Pass it to the refresh endpoint to guard against concurrent edits.
An authenticated organisation member may call this endpoint; org admin is not required.
Response
An archived sheet returns
409. A missing or foreign sheet returns 404.
Refresh snapshots
422
and nothing changes.
Optional stale-plan guard
The request body accepts an optional expectedPlanHash field:
409 without writing:
expectedPlanHash skips the check and applies the refresh unconditionally. This preserves compatibility with callers that predated the preview endpoint.
The response contains the updated sheet and a changes entry for each line.
Change fields
Each entry in thechanges array describes one line:
Duplicate a sheet
name; clientName and adjustmentPercent may
override the source. The service copies lines, includeLogo, and termsText
without rechecking live activity eligibility. An archived source is allowed and
always produces a new active sheet. It returns 201; an active-name collision
returns 409.
Activity calculators
Activity calculators are reusable organisation-owned quantity engines for trench, pit, and volume geometry. Their assignment register directs layer outputs to component rows on one or more activities. Reads are available to authenticated organisation members. Create, update, and link operations require a signed-in admin or member; a service token receives403.
Calculator fields
Each link names a
layerId, destination activityId, physical outputKey, optional excavation share, and whether it inheritsDefault. The response also carries its stable generatedKey and current componentRowId.
List and read
GET /activity-calculators lists calculators. Pass activityId to list only calculators feeding that activity. GET /activity-calculators/:id returns one calculator with its links and usage.
Create
POST /activity-calculators requires type and config. name and defaultActivityId are optional. When the name is absent, the service allocates the next calculator name. A default destination seeds one inheriting link for each output layer.
An unreadable config returns 422 calculator_config_unreadable. A config carrying pricing returns 422 calculator_config_carries_pricing.
Update and rename
PUT /activity-calculators/:id is partial. Omitted fields remain unchanged. Updating config or defaultActivityId runs the engine, rewrites the relevant register state, and reconciles every affected activity component row. Passing defaultActivityId: null clears the default and its inherited links.
A body containing only name takes the rename path. It changes no geometry, links, or component rows and does not run the engine.
Replace assignment links
PUT /activity-calculators/:id/links replaces the complete register atomically and reconciles all affected activity rows.
A non-excavation layer accepts one whole-layer destination. An excavated stratum may be partitioned across activities, but its shares cannot exceed 100%, duplicate destinations are rejected, and a whole-layer assignment cannot be mixed with shares. Layer and output keys must come from the engine. An under-allocated stratum succeeds with an excavation_partition_under warning.
No delete or copy route
There is noDELETE /activity-calculators/:id. It had no caller and was removed in the soft-delete programme; a request to it returns 404.
There is no POST /activity-calculators/:id/copy either. The only screen that used it was removed, so the route was removed too (October 2026). A request to it returns 404. To make a similar calculator, create a new one with the same geometry.
When a save reconciles an activity’s rows, rows the calculator no longer generates are kept for history with deletedAt rather than removed. Every row swept by one save shares one deletedAt.
Disposal substance usage
GET /activity-calculators/substance-usage returns { items: [{ substanceId, count }] }. It counts distinct calculators in the organisation that reference each disposal substance, ordered by count descending and then substance id. A calculator counts once per substance even when several strata use it.
Warnings and errors
Create, update, copy, and link replacement can return servicewarnings and engine engineWarnings. Clients must tolerate engine warning kinds they do not recognise. Unknown layers, unsupported outputs, foreign destinations, invalid shares, malformed backfill sources, and unreadable configs return 422 without a partial register update.
