Skip to main content
The Activities API owns standard activities, nested activity structure, and activity components. Components can reference rate item IDs without taking ownership of Rates data.

Base prefix

Endpoint groups

Version endpoint

Returns the running service’s configured version. This endpoint is unauthenticated and always returns Cache-Control: no-store.

Response

The endpoint reads 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

Returns the service’s OpenAPI 3.1.0 document. This endpoint is unauthenticated and always returns 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

Returns a paginated list of activities for the caller’s organisation. Accepts the standard list query parameters (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:
The 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 — either project_margins (the activity prices through project margin settings) or fixed_price (the activity is a publishable package with its own sell amount). All existing activities default to project_margins.
  • defaultSellMargin — the package’s default sell margin as a percentage (0–95). Present only when pricingMode is fixed_price; null otherwise.
  • publishedSellAmount — the sell amount at the time of the last publication. Present only on published fixed-price packages; null otherwise.
  • publishedCostAmount — the cost build-up at the time of the last publication. Present only on published fixed-price packages; null otherwise.
  • publishedAt — ISO 8601 timestamp of the last publication. Present only on published fixed-price packages; null otherwise.
  • componentCount — total number of components on the activity.
  • estimatedCost — sum of quantity × resourceCount × unitCost across 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. min equals estimatedCost. When every component is unranged, min and max are equal. See Indicative pricing for library lines.
  • optionalCostRange — the same range for the optional components as { min, max }. min equals optionalCost.
  • optionalComponentCount — how many of the components are optional.
  • components — the activity’s component rows. Each component includes rateGroup, which is null when no group has been recorded, plus optionality and an optionGroup when it is optional. Rate-item components also include unitCostRange (see Indicative pricing for library lines).
estimatedCost changed meaning.It used to be the sum across every component. Since optional items shipped it is included-only, and optionalCost carries the rest. A consumer that still reads estimatedCost as “the whole build-up” now under-reads any activity with optional lines.Since indicative pricing shipped, estimatedCost is the low end of the range for any activity that has rate-item lines with multiple options. Use estimatedCostRange.max when you need an upper bound.estimatedCost + optionalCost is not a price anyone can be charged either: optional components may name an optionGroup, and at most one line per group may be ticked, so the sum adds up mutually-exclusive alternatives. Price a specific selection through the estimate insertion preview rather than by adding the two figures.Each side rounds once, half-up, to cents after all of its quantity × resourceCount × unitCost products have been summed — so estimatedCost is a sum-then-round figure, not a sum of rounded lines.
The three 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 a unitCostRange 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.
Loadings and tier prices are included in both ends of the range. The range comes from the rates service’s 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), so stepAmountRange 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 by pricingMode.

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

A fixed_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

Supply sellAmount 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
  • sellAmount may only be supplied when pricingMode is fixed_price — otherwise 422 Unprocessable Entity.
  • Supplying sellAmount alongside pricingMode: "project_margins" in the same request returns 422 Unprocessable Entity.
  • The included build-up cost must fit within the service’s numeric(14,2) money boundary — otherwise 422 Unprocessable Entity.
A 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, the defaultSellMargin 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 defaultSellMargin and an updated sellAmount in the same request.
Attempting to change the margin via 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 as included (the default) or optional.
  • Included components contribute to estimatedCost on the activity DTO and are used to compute the package sell when publishing.
  • Optional components contribute to optionalCost and optionalComponentCount. They are not included in the package cost roll-up.
An optional component must reference a 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 a calculatorGenerated: 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

Accepts the same fields as the create body except 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

Deletes the profile and returns 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

Each delete returns 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 (including search) and the components and profiles embedded in an activity leave them out. A deleted activity frees its name and code for a new activity.
  • GET /activities/:id still returns a deleted activity, with deletedAt set. 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 204 and changes nothing.
  • Writes to a deleted row return 409 with code deleted. That covers PUT on a deleted activity, component or profile, and creating or updating a component or profile under a deleted activity.
An activity’s own 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

This endpoint creates an activity and all its components in one transaction. Either the complete activity is saved or nothing is written. Use it instead of a separate activity create followed by multiple component creates when an incomplete, silently under-priced activity would be unsafe. The write requires a signed-in admin or member of the organisation. A service token receives 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 Created with idempotentReplay: false.
  • A previously used key returns the original activity and its components with 200 OK and idempotentReplay: true.
Concurrent requests using the same organisation and key are also constrained to one activity. Different organisations may use the same key independently. Requests without a key create a new activity each time.

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 returns 403 Forbidden.
  • The activity’s parentActivityId and every component childActivityId must reference an activity in the caller’s organisation. Missing or foreign references return 422 Unprocessable Entity and 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 Conflict with code activity_name_taken.

Response

The response is the complete activity DTO, including componentCount, 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 receive 403.

Eligible activities

A source activity must be active, use fixed_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:
An adjusted result outside JavaScript’s safe-integer cents range returns 422.

List and get sheets

The list contains active sheets only and returns 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

The strict body requires 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

At least one of 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

This sets 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

Returns a read-only preview of what a refresh would change without writing anything. The response includes the current saved sheet, the proposed sheet after applying the latest published values, a per-line 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

Refresh revalidates every existing source activity, then replaces each line’s name, unit, and sell with the current published package values while preserving row order. If any source is now ineligible, the complete request returns 422 and nothing changes. Optional stale-plan guard The request body accepts an optional expectedPlanHash field:
When supplied, the service recomputes the plan hash inside the same write transaction (after acquiring the sheet lock and reading share-locked activity rows) and compares it with the submitted value. If they differ — because the sheet was updated or a source activity’s published price changed since the preview — the request returns 409 without writing:
Omitting 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 the changes array describes one line:

Duplicate a sheet

The strict body requires a new 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 receives 403.

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. 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 no DELETE /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 service warnings 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.