Skip to main content
The Estimate API owns project estimate data and references global projects, contractors, rate items, activities, and subcontractor bid rates by ID. Pricing ownership is reached indirectly through Rates records — specifically through a rate option, which carries scope, supplierId, locationId, and cost.

Base prefix

Owns

  • Estimates and sections.
  • Estimate document references.
  • Takeoff measurements and map sheets.
  • Work items, components, pricing snapshots, and TAG links.
  • Subcontractor packages and bid review.
  • Programme items and dependencies.
  • TAGs/tender clauses.
  • Import jobs.
  • Contract baselines, variations, lines, revisions, events, and assessment runs.

Endpoint groups

Adding build-up lines

Resources (labour, plant, materials, subcontract) go inside an activity. Every new build-up line sits inside an activity of a work item.
  • Add a line to an activity that already exists: use POST /work-item-components/:id/children, where :id is the activity. The line’s quantityBasis sets how it is charged inside the activity, for example whole_activity (its quantity once) or per_allocated (per unit of the activity’s quantity).
  • Add several lines together: create one activity with POST /work-items/:id/manual-activity, then add each line with POST /work-item-components/:id/children.
  • Add one line on its own: a line sent to POST /work-item-components has no parent, so the server puts it in a new activity named after it, in the same transaction. A line in hours, days, weeks or months sets the activity’s duration. Any other line goes into a one-day activity, charged whole_activity. The 201 response is the line, whose parentComponentId is the new activity, plus activityContainer, the new activity.
wrapInActivity can be left out or sent as true. On POST /work-item-components, these return 422: POST /work-items/:id/day-rate-lines follows the same rule. With parentComponentId, the line goes inside that activity. Without one, a Flat line priced per hour, day, week or month goes into a time container, and any other line (a Fixed line, or a non-time Flat line) goes into a new one-day activity named after it. The response’s container is the new activity. At the top level, wrapInActivity: false returns 422 line_needs_activity and a quantityBasis returns 422 wrap_quantity_basis_not_allowed. POST /work-items/:id/components/:componentId/move moves a line to another place in the same work item’s build-up. A line can’t move out of every activity (422 move_needs_activity). A line whose quantity is linked to a takeoff or calculator can’t move into an activity (422 move_linked_line_into_activity): unlink its quantity first, then move it. Some older lines can still sit outside an activity, where a line has no equal inside one (for example, a line with a delivery link). They keep their price.

Onboarding summary

Returns a read-only checklist of the seven key “Your first estimate” milestones for the caller’s organisation, plus the most recently updated project that has an estimate. The endpoint issues a single database read — seven existence probes in one statement — and never creates an estimate or any other record. Soft-deleted rows never count. Results are scoped by the caller’s organisation and by project membership under row-level security, so a member sees only the projects they belong to. Authentication: requires a valid bearer token with a resolved organisation context. The response carries Cache-Control: no-store.

Response

200 OK

Error responses

Retired routes

The following retired routes return 404. Use the active routes above for current integrations. The POST, PUT and DELETE routes for sections, takeoff measurements, map sheets, work item components, subcontract packages, programme items, programme dependencies and tender clauses are unchanged.

Retired estimate field

projectScope (column estimates.project_scope) is retired. POST /estimates and PUT /estimates/:id no longer accept it, and estimate reads no longer return it. A body that still sends projectScope has it stripped rather than refused. A saved estimate version that still carries the key restores normally.

Resolve estimate access scope

Returns the list of estimate IDs visible to the caller under their current project membership. The server resolves project membership fresh from the Global API on every call and runs the query through the same project-scoped RLS that all other estimate routes use. Use this endpoint to synchronise a client’s local estimate list with the server’s current membership view, for example after a project membership change or after an initial sign-in, without re-fetching each estimate individually. Authentication: requires a valid bearer token with a resolved organisation context.

Response

200 OK
The response carries Cache-Control: no-store. Project membership is resolved fresh on each call; do not cache the result.

Error responses

Portfolio values

Returns the current stored tender price for each project in a batch of up to 100 project IDs. The endpoint reads settled work-item totals and applies contingency once — it never creates an estimate, triggers a recompute, or resolves rates. Missing projects or projects with no estimate in the caller’s organisation are omitted without error. Authentication: requires a valid bearer token with a resolved organisation context.

Query parameters

Response

200 OK
Each entry in items corresponds to one matched estimate in the caller’s organisation: Projects with no estimate in the caller’s organisation, or estimates whose totals are not readable, are omitted from items. The response always returns 200; an empty items array means none of the requested projects matched.

Error responses

Calculator substance usage

Returns how many of the organisation’s project calculators name each disposal substance, across all projects. Use this to populate the “Most used” group in a substance dropdown — the client applies the ranking cut (for example, top five), and this endpoint supplies the full counted list. A calculator counts once per substance regardless of how many strata within it name that substance. Soft-deleted calculators (those with deleted_at set) are excluded from the count. The response covers all three calculator types: trench, pit, and volume, which all store their strata under existingLayers in the calculator config. Authentication: requires a valid bearer token with a resolved organisation context.

Response

200 OK
The items array is sorted by count descending, then by substanceId ascending as a tie-break. Only substances with a count of at least one appear.

Error responses

Section deletion

Deletes a section and returns 204 No Content. The estimate must be editable (draft or review). On a frozen estimate (submitted, accepted, rejected, or archived) the delete returns 409 estimate_frozen with the estimate’s status, and the section is kept, because the delete recomputes the estimate’s totals. The operation acquires the estimate-totals advisory lock before the delete so the lock is held across both the stamp and the recompute. The delete is soft: the section is stamped deletedAt and leaves every list. Its work items are moved to the Unsectioned pool in the same transaction (the route clears their section_id explicitly, as the foreign key’s ON DELETE SET NULL did while the delete was hard). A later restore would not move them back. Estimate totals are recomputed unconditionally after deletion. This covers both ordinary sections and the Shared Costs section, which has additional side effects:
  • Deleting an ordinary section moves its rows to Unsectioned and updates the estimate totals to reflect the new membership.
  • Deleting the Shared Costs section removes that section’s rows from the pool, clears every persisted spreadableCost share that was distributed to other sections, and recomputes totals so the formerly pooled rows are counted normally.

Stored rates and totals

Every work item stores its sell rate to the cent, and its price is always round2(quantity × unitRate) in exact decimal arithmetic. Each section stores its subtotalPrice, and the estimate stores its totals. Clients read these figures rather than summing rows.

Sections response envelope

GET /estimates/:id/sections returns the standard list envelope (items, totalCount, start, length) plus the estimate’s stored totals as top-level fields, so a client that re-reads only the sections after an edit never shows a stale total. The read stores any missing item rates and totals before it answers. null means the estimate has no stored totals yet.

Work item unitRate

Work item reads carry unitRate, the stored sell rate to 2 decimal places. It is null for provisional sums, rows with a quantity of zero or less, and frozen rows whose stored price no cent rate reproduces. Use unitRate when it is non-null; derive a display rate from totalPrice ÷ quantity only when it is null. When sell rate rounding moved the rate, unitRateUnrounded holds the cent rate before rounding; otherwise it is null. See Sell rate rounding.

Shared Costs

Creates or returns the estimate’s single Shared Costs pool. Creation adds a section flagged isSpreadable, titled Shared Costs, numbered S, and seeded with a Shared Costs item. The section receives an end-of-list sortOrder so existing SOQ numbering is not disturbed, while estimate reads surface it first. The endpoint is idempotent:
  • 201 Created creates the section and placeholder item.
  • 200 OK returns the existing section without adding another item.
Both responses contain { section, workItem, created }. workItem is the seeded row on creation and null when the pool already existed. During recompute, the pool’s rolled cost and sell totals are apportioned independently among non-pool top-level rows. Both apportionments use each recipient’s rolled non-fixed cost as the weight: fixed-price portions and provisional sums do not receive a share, while an open portion of a partly fixed row does. Largest-remainder allocation reconciles each set of integer-cent shares exactly to its own pool total. The cost share is added to totalCost and recorded in spreadableCost; the independently calculated sell share is added to totalPrice, so the two shares can differ when the pool carries margin. GET /estimates/:id/work-items includes spreadablePrice on each work item row: the row’s Shared Costs sell share. It is already inside totalPrice, exactly as spreadableCost (the cost share) is already inside totalCost. The value is 0 when the row takes no share, and null when the estimate’s rows predate a stored spread base. The field is omitted entirely on estimates with no Shared Costs pool. Distribution runs when either the pool cost or pool sell total is positive, the non-fixed recipient base is positive, and at least one recipient exists. This includes a zero-cost pool with a positive sell total. Once distributed, pool rows remain visible but their section contributes zero to estimate totals because their cost and sell have moved into the recipients. If there is no valid base or recipient, the pool remains an ordinary section for that pass and its rows continue to count. Sub-items are never separate recipients; their non-fixed cost rolls into the top-level parent. Add further pool rows through the normal work-item routes using the returned section ID.

Plant hire prices

Returns separate supplier hire quotes for a plant rate item — one for a day basis and one for an hour basis. Use this endpoint to display indicative hire costs when the project schedule has a machine shortfall and the system needs to present external hire options to the estimator. The endpoint is display-only. No estimate or schedule is written. Authentication: requires a bearer token for a signed-in organisation member. Service tokens are not accepted.

Query parameters

Price resolution

The endpoint uses the project’s current price-selection order, restricted to supplier options. Internal (own-fleet) options are excluded before selection. A supplier project override wins first. The project’s rate rules and pricing location then form one ordered preference list; the first entry with an eligible supplier option wins. A broad rule placed before a specific rule can win, and the pricing location can win before a rule when it comes first. If no preference yields an option, the project’s office location and then the deterministic lowest-amount supplier option are tried. The day and hour quotes are resolved independently. A day option does not derive hours from the day rate, and a week option is not converted to days. Only options whose unit spelling maps to the target time basis are candidates for that quote. Accepted unit spellings:
  • Day basis: day, days, d
  • Hour basis: hour, hours, hr, hrs, h

Response

200 OK Each quote object contains:

Error responses

Work item export

These routes produce the work-items export workbook, the tender submission PDF, the tender Word file, and the workbook’s build-up level preview.

Download the export workbook

Builds and streams a .xlsx workbook for the estimate’s work items. The response is a binary application/vnd.openxmlformats-officedocument.spreadsheetml.sheet file with Content-Disposition: attachment and Cache-Control: private, no-store. Authentication: requires a valid bearer token with a resolved organisation context. Request body level: "build_up_resources" behaviour Each item row is followed by one row per resource in its own build-up, in the build-up grid’s order. Subcontract mirrors are excluded. Activity containers are not rows; each resource carries its nearest enclosing activity as columns (activity path, instances, allocated quantity, allocation unit, production rate, and duration in days). Resource rows carry separate columns (build-up group, resource count, usage, charged quantity, rate unit, resource unit cost, resource cost rate, resource total cost) plus the pricing engine’s per-line markup or margin percentage, sell rate, and total sell. Every item money and quantity column is left empty on resource rows so no item total is counted twice. A line priced by a rate table with no row chosen, or by a stepped rate with no step picked, has no price. Its money cells hold the text Row needed or Step needed instead of an amount, and it contributes nothing to the totals. Two item-level columns are added in this mode:
  • shared_costs_sell_share: the item’s share of the Shared Costs pool’s sell.
  • item_price_adjustment: the item’s price before Shared Costs less its resource line sells. Captures allowance amounts, fixed-package sells, and rounding so the reconciliation closes.
Sell figures come from the pricing engine using the estimate’s frozen margin ladder and live bid rates. A line with no margin sells at cost. A selected subcontract bid is written as one line at the engine’s margin; its replaced build-up rows show cost with a zero sell. The 10,000-row cap applies to the combined item and resource count at this level. Response headers Error responses

Build-up level preview

Returns the resource rows the build_up_resources level workbook would write, as JSON, without building the file. Use this to show a preview of the build-up level on the export page without a client-side copy of the row builder. Applies the same 10,000-row cap check as the export route. Refused with 422 when the cap is exceeded. Authentication: requires a valid bearer token with a resolved organisation context. Response: 200 OK The response carries Cache-Control: private, no-store. Error responses

Tender submission PDF

Builds and streams the tender submission as a PDF from the same reads as the "submission" workbook. The response is application/pdf with Content-Disposition: attachment; filename="<project name> - Schedule.pdf" and Cache-Control: private, no-store. The PDF carries a title block with the project, estimate, revision, client, site, reference, and date; the section summary and headline totals; the schedule grouped by section, with GST and the total including GST; and the departures and clarifications. Every figure comes from the estimate’s stored totals. A spread Shared Costs pool is not listed, because each item’s amount already includes its share. The PDF uses the company’s brand colour and logo, falling back to the Stralos look, and the unit catalogue’s display symbols. Authentication: requires a valid bearer token with a resolved organisation context. Request body Limits. The PDF is refused with 422 before drawing when it would exceed 4,000 rows (items, breakdown lines, section headings and subtotals, and departures and clarifications), and after drawing when it exceeds 4 MB. Both messages suggest exporting the XLSX. The 4 MB message also suggests turning off the cost breakdown, and the row message does so when the breakdown is on. A terms PDF appended by the tender download (at most 10 MB) is not counted toward the 4 MB. Error responses

Tender Word file

Builds the tender as an editable Word document, application/vnd.openxmlformats-officedocument.wordprocessingml.document, with Cache-Control: private, no-store. It takes the PDF’s body, except that sections is required (400 without it): the Word file exists only as a tender download. It draws the same blocks as the PDF, in the same order and from the same model and tender layout. Block headings are Word Heading 1, the project name is the Title, and the schedule, summary, documents, and totals are Word tables whose header rows repeat. Page numbers are Word fields. Inter, and Sora when the layout uses it, are embedded. A terms PDF can’t go inside a Word file, so the terms block says the terms are a separate PDF. Limits. Refused with 422 before drawing above 4,000 rows, and after drawing above 4 MB. Error responses are those of the tender submission PDF, plus 400 when sections is absent.

Tender downloads, names, and stored copies

A request with sections to the "submission" workbook, the PDF, or the Word route is a tender download:
  • The file is named <project> - Tender - Rev N with its extension, with the estimate’s name after the project for an alternative estimate, and - No prices when prices are left out. A draft carries the revision it will become, stamped with today’s date; a submitted revision carries its own number and submission date.
  • On a tender frozen at its latest revision, the response is that revision’s stored as-submitted file (X-Tender-Copy: stored), or one drawn from its submitted figures when none was stored (X-Tender-Copy: live). A request with leavePricesOut is drawn fresh. A draft answers X-Tender-Copy: draft.
  • Every tender download is logged.
See Tender copies for the stored copies, the saved Tender download options, and the download log.

Tender download sections

The "submission" workbook, the submission PDF, and the tender Word file take sections and documentIds, read and checked the same way. Without sections, the workbook and the PDF are what they were before the tender download. sections is an object of six booleans, all required: projectScope, summary, schedule, documentsReceived, departures, terms. The ticked sections are written in that fixed order. The workbook writes a sheet for each one; the cost breakdown is written only when schedule is ticked.
  • Project scope: the project’s rich-text scope from the Global API. A ticked scope that is empty is skipped, unless it is the only tick.
  • Documents received: the documentIds the project holds, read from the Documents API, by title (else file name), folder, and received date in the project’s time zone. IDs the project does not hold are ignored.
  • Terms and conditions: the company’s terms from GET /api/global/organisation-terms. Typed terms print as their own section. A terms PDF is appended after the PDF’s last page, and the section says the terms are attached; the workbook and the Word file say they are attached as a separate PDF.
Every ticked section is read and checked before anything is built, so the output never silently drops one.

Work item name suggestions

Returns a deduplicated list of past work-item names across the organisation’s projects. Callers can use it to surface previously used names while an estimator types. Names are deduplicated case-insensitively on their trimmed value. The search value is matched as literal text, so %, _, and \ are ordinary characters. When more than one work item shares the same normalised name, the most recently updated occurrence is the exemplar — its stored spelling and project are what the response returns.

Query parameters

Response

200 OK — a list envelope { items, totalCount, start, length }. Each items entry contains: totalCount is the count of distinct normalised names matching the query, not the count of raw matching rows. When limit is lower than totalCount, the response carries the truncated set and the full count so callers can render a “Showing N of M” message. The response does not include project or client names. Those live in the Global service; join the returned projectId against the project register the caller already holds. With facets=projects, the response also carries projects: one { projectId, count } entry per project with live work items in the months window, where count is that project’s number of distinct normalised names. The facet ignores search and projectIds, so a client filter built from it lists every project with work in the period. The Reuse a past build-up sheet groups these projects by client for its Clients filter and sends the chosen clients’ projects as projectIds.

Error responses

Re-resolve prices

Re-resolves default prices for eligible components using the current project rate context, project pricing rules, and project office location. Call this endpoint after a rate-library or location change, or when carrying an estimate into a new pricing context. The endpoint supports two calling modes:
  • Dry run (dryRun: true) — returns the per-component analysis without writing any changes and includes a previewHash that can be used in the confirmed mode.
  • Confirmed apply (dryRun: false, previewHash, resolutionInstant) — hash-gated atomic apply. The hash echoes the one returned from a prior dry run, binding every resolved input; the server rejects the write if the preview has changed since the dry run.

Eligibility (D12 candidates)

A component is eligible for re-resolution when all of the following are true:
  • It has a live rateItemId (not a generated or mirror row).
  • Its pricing provenance is auto, or it is a pinned or legacy component explicitly listed in convertToAuto.
Pinned components are excluded by default. Activity containers have no rate item and are excluded. Rate-bearing recipe-derived children remain eligible, but the apply step never writes a quantity override onto them. Managed fixed-charge companions (components with companionArrival: "ticked") cannot be re-resolved directly. They are reviewed through their parent component. Attempting to include one in convertToAuto returns 409 with code managed_companion_requires_parent.

Request body

The request body is a discriminated union: Dry-run mode (dryRun: true) Confirmed apply mode (dryRun: false with hash)

Price resolution

A component carrying a pinned rateOptionId is excluded before automatic selection. For other components, project-scoped override options are considered first. The project’s rate rules and pricing location form one ordered preference list, using each entry’s position; the first matching entry with an eligible option wins. List position takes precedence over rule specificity. An earlier rate-group rule can beat a later item rule, and the pricing location can beat a rule when placed earlier. If no preference produces an option, the project’s office location and then the deterministic lowest-amount candidate are tried. A rule that covers the line but matches no candidate records resolvedRuleOutcome as unsatisfiable or policy_unmet, and selection continues to the next entry. Within a matching entry, the candidate with the lowest resolved line amount at the line’s quantity wins, with option id ascending as the tie-break. A display rate alone does not account for bands, matrix, floors, or loadings. source rules — All-locations targets match only location-less prices. A source rule with locationId: null (an All-locations target) matches only the owner’s prices that carry no locationId. It never matches a place-specific price from that owner, even a cheaper one. If the owner has no all-locations price for the item, the rule records resolvedRuleOutcome as unsatisfiable and selection continues to the next entry. A source rule with a specific locationId is unchanged.

Response

200 OK Each items entry contains:
currentPriceId and resolvedPriceId were renamed to currentOptionId and resolvedOptionId. They are option ids now, not price ids, and no alias is served for the old names.

Apply behaviour

When dryRun is false, the server:
  1. Runs the fan-out read (project rate context, office location, project rate rules, and rate option alternatives) before opening a transaction.
  2. Opens the estimate advisory lock.
  3. In confirmed mode, computes the preview hash and compares it with the supplied previewHash. If they differ, returns 409 with code stale_re_resolve_preview and a fresh preview payload in the error body.
  4. Verifies under the estimate lock that the estimate, pricing rows, work items, and related component rows still match the fan-out snapshot. A difference aborts the whole write and returns 409 stale_re_resolve_preview with a fresh preview.
  5. Writes the previewed pricing changes atomically. A line that moves onto the mirror of its current option (same project, supplier, yard, and condition, confirmed by mirrorsOptionId) is not treated as a parent change; its ticked fixed-charge companions are preserved.
  6. Removes managed fixed-charge companions listed in managedTickRemovals.
  7. Never writes a quantity override on recipe-derived child rows.
  8. Recomputes estimate totals once after all writes.

Error responses

Rule change preview and apply

These two endpoints let a caller preview the pricing impact of an ordered project rate rule-set change before committing it, then apply the change atomically once the Rates API has saved the new rule set. A project’s rate preferences are one ordered list: the project’s rules plus the pricing location entry, in position order. The first preference in that list that covers a line prices it. The preview resolves the estimate under both the proposed rule set and the currently stored settings in a single pass, so the caller can show the user exactly what will move before anything is written. A frozen estimate (submitted, accepted, rejected, or archived) returns 409 estimate_frozen on both routes. Variation lines are never read or modified by either route.

Rule change preview

Resolves the estimate under the proposed rule set and under the currently stored settings from one shared peer-read snapshot, and returns the lines that would move plus a digest. Nothing is written. Request body Each entry in entries is one of two shapes: Location entry (kind: "location") — represents the pricing-location preference: Rule entry (kind: "rule") — a discriminated union keyed on ruleKind: Entry order is position order: the first entry is position 1, the second is position 2, and so on. A live rule whose id is absent from the entries is deleted. A rule entry without an id creates a new rule. Response — 200 OK The same response shape is returned by the rule change apply endpoint on success. The applied flag distinguishes the two. Each entry in preferences contains: When detail is "lines", groups is an array sorted by rate group: Each entry in groups contains: Each entry in items contains: Each entry in lines contains: Error responses

Rule change apply

Re-plans the estimate under the rule set the Rates API now stores and applies the result atomically, but only when the outcome still matches the previewed digest. Call this endpoint after PUT /project-rate-settings/rule-set in the Rates API has committed the new rule set. Request body Apply behaviour The server:
  1. Re-plans under the rules currently stored in the Rates API.
  2. Computes a fresh digest and compares it with the supplied impactDigest. If they differ, returns 409 stale_rule_change_preview with a fresh preview. Nothing is written.
  3. Validates the apply, pin, and unpin selections against the plan. Every re-priced line must be in exactly one of apply or pin.
  4. Opens the estimate advisory lock.
  5. Writes all resolutions, pins, and unpins in one transaction, together with re-stamped lines and managed companion removals.
  6. Recomputes estimate totals.
  7. Recounts the rate exception badge from the new state.
Response — 200 OK Same response shape as a line-detail preview, with applied: true and populated groups. The response does not include a detail field. Error responses

Rate exceptions

Rate exceptions identify components where the current pricing selection or frozen pricing-rule outcome needs review. The server computes them on demand by running a counterfactual resolution pass against today’s pricing context and comparing it with what is stored.

List rate exceptions

Returns the full list of rate exception rows for an estimate. The response is computed fresh on every call and the result is persisted as a cache (keyed by an inputs digest) so the count badge can use it without recomputing. Response — 200 OK Each exception row contains:

Exception kinds

Action tokens

Count rate exceptions

Returns the persisted exception count without recomputing the full pass. Indicates whether the badge count is stale (i.e. the inputs have changed since it was last computed). Response — 200 OK The endpoint reads the persisted badge count and checks whether the digest of the current inputs still matches. When a peer service is unavailable during the stale check, stale is set to true rather than returning a false “fresh” status.

Accept a rate exception

Records a persistent acceptance for a specific exception, suppressing it from the badge count and list. Only user tokens can accept exceptions; service tokens return 403. Accepting an exception records a suppression and refreshes the badge count. It does not repin a rate option or recompute totals, but a frozen estimate is read-only, so the acceptance is refused on one. The acceptance is keyed on (componentId, kind). Accepting a later exception for the same key replaces the previous acceptance. Request body Response — 200 OK Error codes

Delete a rate exception acceptance

Removes a previously recorded acceptance for a (componentId, kind) pair, making the exception visible again. Invalidates the persisted badge count so the next panel open recomputes it. Request body Response — 204 No Content Error codes

Apply rule to a component

Replaces the component’s current pricing selection with the option the active project rule would select. For a rule_changed exception whose old rule no longer covers the line, it follows the ordinary resolution ladder. The new selection is written with provenance auto. Request body Response — 200 OK — the updated component and pricing override: Error codes

Choose an option for a component

Manually pins a specific rate option onto a component. The new selection is written with provenance pinned. Request body Response — 200 OK — same shape as apply-rule response. Error codes

Unpin a component

Clears a manual pin and sets provenance back to auto, allowing the next re-resolve pass to apply the project rule or default selection. Returns 422 if no eligible option is available after unpinning. Request body — empty object {} Response — 200 OK — same shape as apply-rule response. Error codes

Clear a band override

Clears a manual band selection without unpinning the rate option or refreshing its frozen pricing-rule snapshot. The component returns to the automatic band for its stored charged quantity. Request body Response — 200 OK — same shape as the apply-rule response. Error codes These routes manage the relationship between work items in an estimate and the project’s rate catalogue. They cover three distinct operations:
  • Attaching a project rate option to a work item as a candidate (before it is priced).
  • Inserting a project rate into one or more work items as a priced component.
  • Reading which project rate options are used in an estimate and where.
All write endpoints take the estimate-totals lock and validate before the first write. A refused write never leaves a partially applied change. These endpoints refuse with 409 estimate_frozen when the estimate status is submitted, accepted, rejected, or archived.

Get project rates for a work item

Returns the project rate links for one work item:
  • attached — rate options attached to this work item but not yet inserted as priced components.
  • costed — priced component lines on this work item that are priced from the project’s rates.
Response — 200 OK Each attached entry: Each costed entry:

Attach a project rate to a work item

Records that a project rate option should be considered for insertion on a work item. An attachment is a candidate, not a priced line. Use it to flag work items for later bulk insertion. Re-attaching the same rate option to the same work item updates the suggestion fields rather than returning 409, so a document import that records the same work item twice is idempotent. Request body Response
  • 201 Created — the attachment was created.
  • 200 OK — an existing attachment for the same option was updated.
The response body is the attachment record (same shape as the attached array entries above). Error codes

Delete a project rate attachment

Removes a project rate attachment. Returns 204 No Content. The estimate must not be frozen. Error codes

Get project rate usage

Returns, for every project rate option referenced in the estimate, where it is attached and where it is priced. Use this to build a summary view of all project rate usage across an estimate. Response — 200 OK Each options entry:

Insert a project rate into work items

Inserts a project rate as a priced component into one or more work items in a single transaction. The rate item is inserted at the cheapest current project rate, which may differ from the rate option you requested. Any attachments on the targeted work items for the same rate item are deleted as part of the insert — the insertion fulfils them. You can also name specific attachment IDs in attachmentIds to delete additional attachments from this rate item on the targeted work items. The insert validates every target before writing. If any target fails (e.g. the item is already priced from this project’s rates), the entire operation is refused and nothing is written. After a successful insert, Stralos recomputes the rate exception count for the estimate. Every inserted line sits inside an activity. A target with parentComponentId (an activity on that work item, library activities included) adds the line at the end of that activity; only total is accepted there. In an activity with daily production the line is charged whole_activity; otherwise it is stored per unit of the item so its total is the typed quantity. A target without one puts the line in a new activity named after the rate, so the line’s money is what the caller previewed. Each inserted entry also carries parentComponentId and, when the insert created one, activityContainer. Request body Response — 201 Created Error codes

Automatic re-price

Called automatically by the Rates service after a project rate write commits. It scans the named estimate for lines priced from the changed rate items and moves each line to the best current project rate. The operation is server-authored and idempotent: a second call with the same changes moves nothing. This endpoint is called with the user’s bearer token by api-rates after a project rate write. Rule-list changes use the separate rule-change preview and apply flow. It is not normally called directly by client applications. After the re-price commits, Stralos recomputes the rate exception count even when no line moved — a new quote can create an exception without moving any existing line. Query parameters Request body Response — 200 OK Error codes

Subcontract bid review

Returns the whole-return review for a subcontract package. The review gives estimators a side-by-side comparison of each contractor bid rate against the internal build-up cost, plus a selectable flag and per-rate warnings to guide rate selection. The response is computed under the estimate-totals advisory lock and covers the package, its associated work items, and all bid rates received for those items. Authentication: requires a valid bearer token scoped to the package’s organisation. An unknown package returns 404 without leaking cross-tenant information.

Response

200 OK Each workItems entry contains: Each bidRates entry contains: Warning tokens A bid rate is selectable only when: amount is not null, internalCost is not null, hasPackagePricing is false, basisMatches is true, the rate is the only bid for its work item, the package response has not been declined, and the package status is one of draft, issued, or estimates_received.

Error responses


Contractor portal capability tokens

The token in GET /subcontract-portals/:token is a bearer capability. Treat it as a credential: do not log it, place it in analytics payloads, or include it in support messages. An unknown capability returns 404 with the generic message Subcontract portal not found. The API deliberately does not echo the supplied token in that response. All three public portal routes are unauthenticated. They carry security: [] in the OpenAPI document. The organisation is resolved server-side from the capability token using a SECURITY DEFINER function; no bearer token is accepted or required. The GET /subcontract-portals/:token read endpoint remains accessible regardless of estimate status. A contractor can open their portal and read back their submitted rates even after the tender has been submitted. The two write endpoints (PUT .../rates/:workItemId and POST .../response) return 409 estimate_frozen when the estimate has been submitted.

Save a portal bid rate

Saves or updates the contractor’s quoted rate for one work item in the package. This is an unauthenticated write — no bearer token is needed or accepted. The unit and quantity must match the work item’s values in the estimate. The server canonicalises the supplied unit string before comparing. If the unit or quantity does not match, the request returns 422. Saving a rate does not select it as the active pricing source. The estimator makes that selection explicitly in the estimate workspace. Request body Response — 200 OK The saved bid rate record, with internal fields (organisationId, sourceDocumentId) stripped: Error responses

Submit or decline a portal quote

Records the contractor’s formal response for the package. This is an unauthenticated write — no bearer token is needed or accepted. Two actions are available:
  • submit — marks the package as estimates_received and sets responseStatus to submitted. At least one bid rate must be saved before submitting. Duplicate or unresolvable rates return 409.
  • decline — marks the package as closed and sets responseStatus to declined. No rates are required.
A response is idempotent when the same action is sent more than once — the stored timestamp (respondedAt) is preserved and not updated on replay. Sending a different action after a response has been recorded returns 409. After a response is recorded, the portal becomes read-only. The contractor can no longer save or edit rates. The estimator must reopen the package (set its status back to issued) before any further edits are possible. Reopening clears responseStatus and respondedAt. A response never changes the estimator’s selected pricing source, recalculates money, or awards the package. Request body Response — 200 OK Error responses

Package responseStatus and respondedAt

Two fields are added to subcontract package rows to track contractor responses: These fields are returned on the package list endpoint, GET /estimates/:id/subcontract-packages, and on the package object of GET /subcontract-packages/:id/review. They are read-only outside of the portal response and the reopen path. Reopening a package Setting status to "issued" on PUT /subcontract-packages/:id when responseStatus is not null is treated as a reopen. The server clears responseStatus and respondedAt in the same write, making the portal editable again. Any other PUT on a package with a recorded response returns 409 Conflict until it is reopened. Deletion guard DELETE /work-items/:id returns 409 Conflict when the work item is a member of a package that has a recorded response. The estimator must reopen the package to issued before the work item can be deleted.
GET /work-items/:id/tags returns the tender clauses linked to the work item, ordered by group (case-insensitive, ungrouped clauses last), then sortOrder, then id. POST /work-items/:id/tags adds links, removes links, and creates new free-text TAGs in one mutation transaction. If any mutation fails, every mutation rolls back. The handler lists the resulting links after the transaction commits, so a later response-read or transport failure does not undo a successful write.

Request body

An ID appearing in both add and remove returns 422. Each create entry produces an included manual clause in the tags_clarifications list. No code is generated. baseItemId is null, its sort order follows the existing list, and the organisation, project, and estimate IDs come from the work item and estimate rather than the request. Creation and linking are atomic, including when the request also adds or removes existing links.

Response

200 OK

Error responses

TAGs/tender clauses sync-on-read

GET /estimates/:id/tender-clauses triggers a sync step before listing. On each call, the server fetches the organisation’s departures and clarifications from the global API (GET /api/global/departures-clarifications) and inserts any items not yet present on the estimate, using the global item’s ID as baseItemId to detect duplicates. The response is then returned ordered by group (case-insensitive, ungrouped clauses last), sort order, and ID.

Tender clause schema

Sync behaviour

  • Single source: the server calls one endpoint (GET /api/global/departures-clarifications), not two separate setting lists. Clauses from the legacy tags_clarifications and allowances_assumptions lists that were previously copied retain their stored listKey; new synced clauses use tags_clarifications.
  • Group stamping: each synced clause receives the global item’s group value (trimmed; blank or absent becomes null). No code is written on synced rows.
  • Idempotent: rows already present (matched by estimateId + baseItemId) are skipped via ON CONFLICT DO NOTHING. Concurrent first reads are safe.
  • Non-destructive: an existing clause that has been edited, regrouped, or excluded by the project retains its project-specific values. The sync never overwrites an existing row, including a clause whose group was changed on the estimate.
  • Frozen-safe: the sync step is skipped entirely when the estimate is frozen (submitted, accepted, rejected, or archived). The endpoint returns 200 with whatever clauses exist on the frozen estimate. This ensures a frozen tender’s clause list reflects exactly what was submitted.
  • Fail-soft: if the global service is unreachable, returns a non-2xx response, or returns an unreadable body, the sync step is silently skipped and the endpoint returns 200 with whatever clauses already exist.
  • Org-gated: the sync only runs for authenticated callers with a resolved organisation ID. Anonymous callers receive 401; authenticated platform or null-org callers receive 403.
  • Field mapping: each synced clause is stamped with organisationId and projectId from the estimate row (not from the caller’s token). The global item’s label maps to text. Values that exceed the column length limit are clamped with a server-side warning rather than causing an error.
The generic CRUD write routes (POST /tender-clauses, PUT /tender-clauses/:id, DELETE /tender-clauses/:id) are unaffected and do not trigger any sync. The CRUD write routes (POST, PUT, DELETE) return 409 estimate_frozen when the estimate is frozen.

Takeoff map sheets

A map sheet is a named, saved map view (centre, zoom, and imagery layer) that map measurements are drawn on. It is the map equivalent of a drawing page.
GET /estimates/:id/takeoff-map-sheets returns the estimate’s live sheets, oldest first. A sheet row returns its view as centreLat, centreLng, zoom, and layer. Create request body PUT /takeoff-map-sheets/:id accepts { name } only. The view is saved separately with PATCH /takeoff-map-sheets/:id/view, which accepts { centre, zoom, layer } in the same shapes, so a view save sent while the map pans never races a rename. The PATCH returns 409 deleted on a deleted sheet. DELETE /takeoff-map-sheets/:id soft-deletes the sheet and returns 204. A repeat delete also returns 204. The delete is refused with 409 map_sheet_in_use and { linkedMeasurementCount } while any live measurement on the sheet is linked to a work item; unlink or delete those measurements first. Otherwise only the sheet is stamped: its measurements are not deleted, but they drop out of measurement lists because lists include only measurements on live sheets. Every write returns 409 estimate_frozen on a frozen estimate.

Map measurements

Takeoff measurements carry a coordinateSpace: Creating a map measurement. POST /takeoff-measurements with coordinateSpace: "geo" takes estimateId, mapSheetId, label, type (line, area, or count), points as { lat, lng } (at most 2,000), quantity, and optional workItemId, category, unit, and sourceSnapshot. There is no documentId, scale, paperSize, or pageNumber. The server computes the quantity geodesically and stores it with scale: "geodesic". The unit is fixed by type (line in metre, area in square_metre, count in each) and may be omitted. Updating a map measurement. PUT /takeoff-measurements/:id recomputes the quantity from new points under the same rules. When the quantity changes, a linked work item that was created from the measurement and whose quantity has not been edited by hand takes the new quantity in the same transaction. This also applies to drawing measurements.

Multi-line and Multi-segment takeoff

A Multi-line (type: "multi_line") and a Multi-segment (type: "multi_segment") are structured measurement types that group spans or lines by org takeoff tag. Both types store a parts descriptor alongside the flat points array.

parts structure

Clients supply spanLengths on create and on any PUT that changes the number of lines, adds or removes points, or shifts line starts. When a PUT changes only the tags in parts and leaves starts unchanged, the server re-groups the already-stored per-line lengths without requiring spanLengths.

quantityBreakdown

The server stores and returns a quantityBreakdown field alongside parts. It is never accepted from the client. When a Multi-line or Multi-segment has tags, each tag subtotal becomes a linkable source alongside the measurement total:
  • Candidates (GET /estimates/:estimateId/work-items/:itemId/quantity-link/candidates and the component variant) include a byTag array on each tagged measurement candidate. Each entry has tag, label, quantity, spans, and lines (Multi-line only).
  • Usage (GET /estimates/:id/takeoff-measurements/usage) returns one entry per source (total or tag). Each entry carries measurementId, tag (UUID or null for the total), workItems, components, and calculatorFields.
  • Creating a work item from a tagged measurement. POST /estimates/:id/work-items/from-measurement accepts an optional tag UUID. When supplied, the new work item takes its quantity from that tag subtotal and is linked to it. The work item name is <measurement label> — <tag label>.
  • Quantity link descriptor. A takeoff source with a non-empty tag links to that tag’s subtotal. A descriptor may not mix the total of a measurement with one of its own tag subtotals (quantity_link_takeoff_overlap).

R15 — refusing edits that remove a linked tag

PUT /takeoff-measurements/:id checks whether the edit would remove a linked tag from the measurement. An edit removes a linked tag when it repoints that tag’s last line (Multi-line) or last span (Multi-segment) to another tag, or removes the points that carry it. When the edit would remove a linked tag, the server returns 409 takeoff_source_removed without writing. The body contains: Adding ?unlink=true to the request applies the edit and unlinks all affected users. The response body then includes unlinked: an array of { workItemId, quantity } for every work item that was unlinked (frozen at its last value).

Council utilities

These routes return reviewed public council three-waters assets (stormwater, wastewater, and drinking-water nodes and pipes) for map takeoff. They require normal authentication and an organisation context, and read no estimate data. The Takeoff panel shows utility overlays only when a feature flag is on; the flag does not restrict the routes. The sources cover parts of 41 council areas through 27 providers. Coverage is not complete for any council or asset type, and an empty response is not proof that no utilities exist. Display the returned attribution wherever the data is shown or exported. Two map routes sit alongside them: layer availability for the councils and layers in a view, and property boundaries from LINZ. They have the same authentication and organisation guard.

Providers

Returns { providers }, the catalogue of supported sources. Each provider has id, name, bounds, sourceUrl, licenceUrl, attribution, coverageNote, checkedOn, and layers. Each layer has key, name, network (stormwater, wastewater, or drinking-water), kind (node or pipe), areas (the council areas it covers), and optional bounds. Each council area and network belongs to one provider only. The bounds are coarse discovery rectangles, not council boundaries.

Features

Returns one provider layer’s assets for a small map view, as WGS84 GeoJSON. Query parameters The view can be at most 0.025° of longitude by 0.02° of latitude. No other parameters are accepted. Response. A GeoJSON FeatureCollection with fetchedAt, provider, providerName, sourceLayer (the layer key), sourceUrl, attribution, and licenceUrl. Node layers return Point features and pipe layers return LineString features; a multipart pipe is split into separately selectable parts. Each feature’s id is <provider>:<layerKey>:<objectId>:<part>, and its properties hold provider, sourceLayer, layerId, objectId, part, network, kind, assetId, assetType, material, diameter, district, loadedAt, and the source’s scalar attributes. A view with more than 2,000 feature parts, 50,000 vertices, or a 3 MB response fails rather than returning a partial result. Each API worker caches a response for up to 15 seconds and coalesces identical requests in flight.

Layer availability

Returns the council areas in a map view and the provider layers registered for them. It takes west, east, south and north with the same ranges as Features, but no size limit, so a regional view can be checked. No other parameters are accepted. Response. { providers, councils, coverage, maxView }:
  • providers: the provider catalogue narrowed to providers whose bounds intersect the view and that have a layer for a council in view. Each layer’s areas lists only the councils in view.
  • councils: { id, name } for each territorial authority in view.
  • coverage: sourceUrl, attribution and licenceUrl for the Stats NZ council boundaries.
  • maxView: { longitude: 0.025, latitude: 0.02 }, the largest view that Features and Property boundaries accept.
Each API worker caches a response for up to 60 seconds and coalesces identical requests in flight.

Property boundaries

Returns LINZ NZ Property Boundaries outlines for a small map view. It takes west, east, south and north with the same ranges as Features, and the view can be at most 0.025° of longitude by 0.02° of latitude. No other parameters are accepted. Response. { outlines, fetchedAt, sourceUrl, attribution, licenceUrl }. Each outline is { id, points }, where id is <objectId>:<part> and points are { lat, lng }. No parcel attributes are returned. A view with more than 2,000 parcels, 5,000 outlines, 100,000 vertices, or a 3 MB response fails rather than returning a partial result. Each API worker caches a response for up to 15 seconds and coalesces identical requests in flight.

Recording provenance

When a map measurement is created from a utility feature, the client may send sourceSnapshot: The server checks the snapshot’s shape and that provider, sourceLayer, and layerId name a layer in the catalogue, but does not check it against the upstream source, and still computes the quantity itself. Later point edits do not change the stored snapshot, and workspace versions include it.

Frozen estimate writes

A frozen estimate refuses every user write except a rename. An estimate is frozen when it is:
  • a tender whose status is submitted, accepted, rejected, or archived
  • a tender that was not awarded (hidden when another estimate was marked won)
  • a Delivery estimate in in_sign_off or signed_off
  • a saved version
Tenders in draft or review and Delivery estimates in draft are editable. Delivery is a separate phase that creates a copy of the estimate data; the submitted tender is preserved exactly as submitted. Every write route that changes the estimate returns 409 estimate_frozen on a frozen estimate. This covers work items, components, calculators, the margin ladder, pricing, subcontract packages, project rate links, rate-exception decisions, acceptances and band clears, sections, all fields on PUT /estimates/:id, and the tender document data: Rename exception. PATCH /estimates/:id, and a PUT /estimates/:id whose body contains only name, succeed on a frozen tender (submitted, accepted, rejected, archived, or not awarded) and on a Delivery estimate in sign-off or signed off. A saved version stays fully read-only: the PATCH returns 404 estimate_not_found and the PUT returns 409 estimate_frozen. A PUT that carries name alongside any other field is refused as a whole. The 409 response body includes the error code estimate_frozen, a human-readable message naming the actual status and how to proceed, and a status field for client branching:
The message text varies by status: A not-awarded tender answers “This estimate was not awarded, so it’s read-only.” and adds hidden: true. A saved version answers “This is a saved version, so it’s read-only.” and adds kind: "saved_version". A deleted estimate returns 409 with code deleted instead. Read endpoints are not affected. All GET endpoints remain available regardless of estimate status. Sync-on-read GETs write nothing. GET /estimates/:id/tender-clauses skips its library-sync step on a frozen estimate. GET /estimates/:id/programme-items skips its programme-header backfill step. Both return 200 with whatever data the frozen estimate holds. Lifecycle transitions, variation writes, and version creates are not gated. Those are the supported ways to act on a frozen estimate.

Document references

GET /estimates/:id/document-references?documentId={uuid} returns every reference within an estimate that points at a given document. This endpoint is used by the cloud-drive document-sync service as a pre-deletion guard: if the response contains an empty references array the document is safe to unroute; a non-empty array means at least one estimate record still depends on it.

Query parameters

Document reference response

Reference kinds

Each reference includes the id of the referencing record and, where available, a label (the measurement label, package name, or work-item name).

Behaviour notes

  • Returns 404 if the estimate does not exist.
  • Returns 200 with an empty references array when the estimate exists but nothing references the document.
  • All four reference kinds resolve by documentId alone — no per-suggestion parameters are accepted.
  • Bid-rate and work-item rows that were accepted before source_document_id was introduced carry null in that column and are not visible to this endpoint. A one-off backfill (migration 0013) can populate source_document_id for any such legacy rows.

OpenAPI document

Returns the OpenAPI 3.1 document generated at service boot from the Estimate API’s route-descriptor registry.
  • Unauthenticated: no bearer token is required.
  • Cached bytes: the document is serialised once at boot and returned unchanged on every request. Cache-Control: no-store is set so intermediate caches do not hold stale copies.
  • Contract version: info.version is the descriptor-contract version, not the deployed service version.

Response bodies and phasing

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

Security

The OpenAPI document marks protected operations with the document-level bearerAuth requirement and a 401 response. The three public subcontractor-portal routes (GET /subcontract-portals/:token and its /documents/:docId variants) carry security: [] and do not document a 401. Runtime authentication is mandatory on every protected operation. Every authenticated descriptor operation documents and can return 403 Forbidden when the caller holds a platform or service token with no resolved organisation context. This includes reads: all tenant reads run through the row-level security scope, which rejects a null-org token before any row is read. The three public subcontractor-portal operations (GET /subcontract-portals/:token and its /documents/:docId variants) are excluded; they carry no bearer token and resolve the organisation from the capability token, so a null-org scenario is structurally unreachable.

Service version

Returns the running Estimate API’s configured version and deployed commit. This endpoint is unauthenticated and returns Cache-Control: no-store. It reads SERVICE_VERSION for the version and SERVICE_COMMIT for the commit, both stamped by the deployment pipeline. When SERVICE_VERSION is absent it falls back to the package version; when SERVICE_COMMIT is absent it returns "unknown". Response — 200 OK
The release coordinator reads this endpoint before mutation and compares it with the manifest’s minimum version.

Billing access

The Estimate API enforces subscription access on user write operations. When an organisation’s subscription is not in full access mode, any write request from a user token returns:
Read operations are not restricted. Service tokens bypass the billing check.

Project estimate

Get or create a project’s estimate

Returns the project’s primary estimate (see Project estimates for the others). If no estimate exists yet, the server race-safely creates one on behalf of the caller’s organisation (lazy creation). An existing estimate is always returned regardless of billing state. Error responses The server validates project access with the Global API before creating an estimate. The following typed responses apply when no estimate exists: When a 409 project_not_provisioned is returned, the estimate will be created shortly. Retry after the Retry-After interval. Do not treat this response as a permanent failure.

Ensure a project’s estimate exists

Idempotent endpoint that ensures a project’s estimate exists. Creates the estimate if one does not yet exist, or returns the existing estimate if it does. Nothing is rewritten on a repeat call — the margin ladder and hoursPerDay stay frozen at their original values.
  • 201 Created — the estimate was created by this call.
  • 200 OK — an estimate already existed; it is returned unchanged.
This endpoint applies the same project-access validation as the GET above and is subject to the same 402, 403, 404, and 502 responses. It is not affected by the lazy-create mode — it is always the authoritative create path. Authentication: requires a valid bearer token with a resolved organisation context.

Project estimates

A project can hold more than one tender estimate when its Multiple estimates setting is on (see Project estimate settings). One live tender estimate is always the primary — the estimate that opens on project load and that GET /projects/:projectId/estimate returns. The others are alternative pricing scenarios. A Delivery estimate is created at award, not through these routes. The write routes run under the project’s lifecycle lock, so they serialise with submit, revise and award.

List a project’s estimates

Returns the project’s live tender and Delivery estimates, primary first, then in creation order. Saved versions and deleted estimates are never listed. Hidden (not-awarded) estimates are excluded unless includeHidden=true is passed. Query parameters Response — 200 OK
Each items entry:

Create an estimate

Creates another tender estimate on the project. The new estimate is never the primary; call POST /estimates/:id/make-primary to change the primary afterwards. Request body
The three creation modes:
  • Blank (omit from) — an empty estimate that takes hoursPerDay and the margin ladder from the organisation defaults at creation time.
  • Duplicate (from.estimateId) — a full copy of a live tender estimate on this project, including its work items, build-up, sections and project rates. The copy is created in draft status.
  • From version (from.versionId) — a draft copy of a saved version of an estimate on this project.
A copy runs as a two-phase copy job within the request: the project rates are backfilled and copied in the Rates service first, then the estimate graph is copied in one transaction with a parity assertion. The copy rolls back with 500 copy_parity_failed if the copied totals do not match the source to the cent. Response — 201 Created The new estimate row. Error responses

Rename an estimate

Renames an estimate. The new name must be unique among the project’s live estimates (case-insensitive). A rename is the one write a frozen estimate still accepts. It succeeds on a submitted, accepted, rejected, archived, or not-awarded tender and on a Delivery estimate in sign-off or signed off. See Frozen estimate writes. Request body Response — 200 OK The updated estimate row. Error responses

Make an estimate primary

Makes the tender estimate the project’s primary. No body is required. Exactly one estimate is primary at a time: the previous primary is demoted in the same transaction, under the project lock. After the commit, the Rates service’s default estimate for the project is updated to follow the primary; repeating the call on an estimate that is already primary returns it unchanged and re-sends that update. Response — 200 OK The updated estimate row. Error responses

Delete an estimate

Soft-deletes the estimate; a deleted estimate is no longer listed. Returns 204 No Content. A repeat DELETE on an already-deleted estimate also returns 204 and changes nothing. Error responses

Document suggestion idempotency

Accepting a document suggestion (POST /estimates/:id/work-items/accept-suggestion) is idempotent per estimate. Accepting the same suggestion into a different estimate of the same project is treated as a fresh accept in that estimate, not a replay. Each estimate tracks its own accepted suggestions independently. The same rule applies to subcontract bid suggestions: idempotency is per subcontract package, so duplicating an estimate also duplicates its packages, and the same bid can be accepted into the new package.

Project estimate settings

Reads and writes the project-level estimate settings. The only setting is Multiple estimates.

Get estimate settings

Response — 200 OK

Update estimate settings

Updates the project’s estimate settings. Requires a caller with a resolved organisation context. Request body Response — 200 OK A body that fails validation returns 400.

Estimate commercial settings

The editable fields above are accepted on POST /estimates and PUT /estimates/:id; pricingBasis is response-only. Changing defaultMarkupPct, defaultMarginPct, or contingencyPct through the estimate PUT recomputes work-item totals and section subtotals in the same transaction under the estimate-level advisory lock. pricingBasis is server-owned and cannot be changed after creation. On a frozen estimate, PUT /estimates/:id returns 409 estimate_frozen for every field, including gstRate, currency, quantitySource, and soqUnlocked, not only the repricing fields. The one exception is a body that contains only name, which is treated as a rename and succeeds on a frozen estimate other than a saved version. A body that carries name alongside any other field is refused as a whole.

Sell rate rounding

sellRateRounding on PUT /estimates/:id rounds every item’s sell rate up to a step from SELL_RATE_ROUNDING_OPTIONS (see Estimate rounding) and reprices every item that has no rounding of its own. Each rate is rounded up, stored to the cent, and the amount is quantity × rate. A token outside the list is 422 sell_rate_rounding_invalid; a frozen estimate is 409 estimate_frozen. sellRateRounding on PUT /work-items/:id is the item’s own setting: null inherits the estimate’s and "none" keeps the item unrounded. The response carries effectiveSellRateRounding and unitRateUnrounded (the cent rate before rounding, null when rounding moved nothing). Provisional sums, rows with a quantity of zero or less, and rows in a Shared Costs section are never rounded.

Working day inheritance

When hoursPerDay is absent from a POST /estimates request body, the server reads the organisation’s defaultHoursPerDay from the Global API and stamps that value onto the new estimate. An explicit hoursPerDay value in the body always wins over the organisation default. The same inheritance applies when an estimate is created lazily via GET /projects/:projectId/estimate: if no estimate yet exists, the server creates one using the organisation’s working-day default. When the Global API is unavailable or returns an unreadable value, the server falls back to 8 and logs a warning. The fallback never causes the request to fail.
A work item no longer carries a markup of its own. markupPct was removed from the work-item create and update bodies on 2026-09-18: a markup belongs to the activities and materials inside a work item, not to the item. A request that still sends the field is accepted and the field is ignored.

Quantity entry policy

The quantitySource field controls how work-item quantities are entered for the project. The default is soq. This field is accepted on POST /estimates and PUT /estimates/:id. Unlike the commercial settings above, changing quantitySource does not trigger a pricing recompute — it controls editability only.

soqUnlocked

soqUnlocked is a boolean field on the estimate row. The database column defaults to false, but the GET /projects/:projectId/estimate lazy-create and PUT /projects/:projectId/estimate ensure paths write true. POST /estimates also defaults to true while allowing the caller to supply false explicitly. An existing estimate can be locked by accepting an SOQ import suggestion (which sets soqUnlocked: false under the estimate-totals lock) or by explicitly locking it via PUT /estimates/:id. This field is accepted on POST /estimates and PUT /estimates/:id. It is in the same class as quantitySource — it controls editability, not pricing — but it does take the estimate-totals advisory lock on write because it determines which rows are SOQ-frozen and the work-item writers read it under that lock. Setting soqUnlocked: true lifts:
  • The quantity 422 on PUT /work-items/:id and on batch setQuantity.
  • The move and renumber 422 on PUT /work-items/:id.
  • The relative-order 422 on POST /estimates/:estimateId/work-items/reorder.
  • The bulk-move 422 on POST /estimates/:estimateId/work-items/batch.
  • The section move and renumber 422 on PUT /sections/:id.
soqUnlocked does not unfreeze automatic item numbering. An imported row carrying its own itemNumber keeps it through a reorder even after the estimate is unlocked. A person may still PUT a new itemNumber by hand on an unlocked estimate. soqUnlocked is excluded from tender revision snapshots (it is an editing-mode flag, not a pricing input, and two revisions of one estimate must hash identically regardless of lock state). Estimate version snapshots include it and restore it verbatim.

SOQ lock

A work item’s quantity is read-only when all of the following are true:
  • the estimate’s quantitySource is soq;
  • the estimate’s soqUnlocked is false;
  • the item’s quantityOrigin is import;
  • sourceQuantity is not null; and
  • quantity exactly equals sourceQuantity.
Takeoff measurements (quantityOrigin: "measurement") remain editable in soq mode. If an estimator changed an imported quantity while the estimate was in plans mode, switching back to soq does not restore or re-lock that changed value. The lock is value-aware: submitting the stored quantity again succeeds, so unrelated edits such as renaming, retagging, repricing, or changing markup are still allowed. A different quantity returns 422 Unprocessable Entity. An SOQ-locked item’s position is also fixed. These operations return 422 and write nothing when they would move or renumber a locked item: All of the above restrictions are lifted when soqUnlocked is true. See soqUnlocked above.

Work item pricing

Read all pricing rows

Returns one pricing row per live work item in the estimate. Each row includes a read-only componentOverrides array, synthesised from each live component’s price-selection columns and active value overrides. It is a compatibility shape only: the stored JSON column was dropped (migration 0095), and the key cannot be written (see below). Use priceSelection and valueOverrides on individual component reads (GET /work-items/:id/components and the components array on GET /work-items/:id) to read pricing state per-component without fetching the full estimate pricing.

Save a pricing row

Saves a work item’s pricing row. The body accepts: A body that includes componentOverrides is refused with 422 component_overrides_retired. Write a component’s price selection through PUT /work-item-components/:id/pricing-rule and its hand-changed values through PUT /work-item-components/:id/value-overrides.

Sparse pricing update

Updates the pricingSource on a work item’s pricing row without replacing the full row. The body accepts: A body that includes componentOverrides is refused with 422 component_overrides_retired. Write a component’s price selection through PUT /work-item-components/:id/pricing-rule and its hand-changed values through PUT /work-item-components/:id/value-overrides.

Effective pricing source on read

GET /work-items/:id and GET /estimates/:id/work-items return a pricingSource that reflects the source the money recompute actually used, not the raw stored token. The effective source is "internal" when the stored token is absent, null, or empty; when the named bid does not belong to this work item; or when the item has a published package. In all other cases the stored "subcontract:<bidRateId>" token is returned unchanged.

Frozen pricing rule selection

Sets or clears a frozen pricing-rule envelope on a work item component and recomputes the authoritative resolution. The request body is a discriminated union:
  • Set — include selection, envelope, resolvedBandKey, and chargedQuantity.
  • Clear envelope only — include selection, set envelope, resolvedBandKey, and chargedQuantity to null.
  • Clear entirely — omit selection and set envelope, resolvedBandKey, and chargedQuantity to null.
The selection object accepts these fields: Moving the component’s unit atomically (unit field) A plain PUT /work-item-components/:id can change the unit on a frozen selection, but it deliberately unfreezes that selection: pricedUnit and conversionFactor are removed while the existing rate option identity and numeric unit cost remain for the caller to correct. A row governed by a pricing-rule snapshot still refuses a plain unit change because the rule owns its unit. Send unit through this pricing-rule endpoint when the caller is selecting another option priced in the target unit. The component’s unit, pricing selection, and recomputed totals then move in one write without passing through an unfrozen intermediate state. The unit value must equal the canonical form of pricedUnit. This restricts the move to same-unit selections by construction: an option that requires a unit conversion has a pricedUnit the component is not moving to, so it cannot satisfy the equality and the component’s unit is left unchanged. Do not set unit when a conversion applies. A row measured in m³ priced from a per-tonne option legitimately keeps m³ and carries a conversion factor. Sending unit on such a selection returns 422. 422 responses

Hand-changed value overrides

Records hand-changed values on a work item component. The server diffs each supplied field against the line’s current original and stores only what differs. Use this endpoint when an estimator manually edits a component’s name, category, quantity, resource count, unit, or unit cost in the pricing panel. A manual unit-cost edit also pins the component’s price selection: the stored selection is updated to provenance: "pinned" with the edited unit cost as the scalar. Request body Send either { clear: true } to revert every override, or one or more of the following fields: At least one field is required unless clear: true is sent. Response — 200 OK Each entry in valueOverrides: Error codes

Contract variations

Work item delete behaviour

DELETE /work-items/:id runs in a single transaction under the estimate-level advisory lock. Deletions are soft: the row is stamped with deleted_at rather than removed. Every work item, its live sub-items, every live component on those items, and each item’s programme header subtree share one deleted_at timestamp from the same delete call. The operation performs the following steps:
  • Nulls the workItemId on any linked takeoff measurements (bare-UUID reference, no foreign key).
  • Removes the deleted item’s ID from the workItemIds array on every subcontract package within the same estimate. Packages in other estimates are not modified.
  • Stamps the work item, its live sub-items, all of their live components, and all of their programme header rows with a shared deleted_at timestamp.
  • Recomputes section subtotals and estimate totals.
Callers receive 204 No Content on success. A repeat DELETE on an already-deleted item also returns 204 and changes nothing. The item is not returned on list or aggregate reads after deletion, but GET /work-items/:id continues to return the row with its deletedAt field set. Any write to a deleted work item (PUT, sub-item, duplicate, component creation) returns 409 { code: "deleted" }. The caller must not assume the item was removed from the database; the row survives for audit purposes. 404 Not Found is returned only when the item does not exist or is outside the caller’s organisation.

Duplicating a work item

POST /estimates/:estimateId/work-items/:itemId/duplicate creates an atomic copy of a work item within its estimate:
  • The copy is named <original name> (copy), truncated to the name column limit.
  • Provenance fields (sourceDocumentId, sourcePageNumber, sourceExcerpt, sourceSuggestionId) are cleared — the copy is not linked to any source document or suggestion.
  • Package-owned mirror components (those with type: "subcontract", a subcontractBidRateId, or a subcontract: pricing source) are not copied. Only internal build-up components are cloned.
  • If the source item’s pricing source is subcontract:<bidRateId>, both the work item column and canonical pricing row are reset to internal on the copy, because the clone is not a member of that package.
  • Pricing overrides are remapped onto the new component IDs. An override referencing a component that was skipped (package-owned) is dropped from the copy rather than carried as an orphan.
  • Calculator-generated component keys are rebound to the clone’s component IDs. Rebound keys are canonicalised and any collisions are resolved deterministically, preserving a unique reconcile identity within the copied work item.
  • The copy is recomputed immediately; totals and section subtotals reflect the new item before the response is returned.
The operation runs in one transaction under the estimate-level advisory lock. It returns 201 Created with the new work item and its derived tagCount and hasTakeoffLinks fields. It returns 404 Not Found if the source item does not exist or does not belong to the specified estimate.

Splitting an SOQ line into sub-items

Adds a sub-item beneath an SOQ-sourced work item. The parent retains its contractual identity — its item number, name, and quantity are unchanged. The sub-item has its own build-up and stored totals in the same section. Recompute rolls those totals into the parent, and aggregate consumers sum top-level rows only, so section and estimate totals include the sub-item exactly once.

Placement rules

  • The parent must be SOQ-sourced: its quantityOrigin must be import. A takeoff-measured item (quantityOrigin: "measurement") does not qualify even though it carries a sourceDocumentId.
  • The parent must not itself be a sub-item. Only one level of nesting is supported. The server checks this first and returns 422 sub_item_nesting_unsupported when it fails.
  • The sub-item always lives in the parent’s section. There is no sectionId in the request body.

Item numbering

When the parent has an item number and the generated value fits the 80-character ceiling, the sub-item receives a number of the form <parent itemNumber><letter> — for example, 2.3a for the first sub-item under 2.3, 2.3b for the second, and so on. Suffixes walk a through z, then aa, ab, and continue indefinitely. The server allocates the suffix against all existing children of that parent. If the parent has no number or no candidate fits within the column limit, the sub-item’s item number is null. Sub-items numbered before this change keep their stored <parent>.<n> numbers. A parent that already holds a child numbered 2.3.1 gives its next sub-item the suffix a without colliding.

Reorder behaviour

Sub-items are excluded from the section reorder membership set. When calling POST /estimates/:estimateId/work-items/reorder, send only top-level item IDs. Sub-items are re-ranked with their parent when the parent’s position changes; the response returns the whole section, sub-items included.

Move behaviour

A sub-item cannot be moved independently to a different section (422 sub_item_section_bound). Moving a parent is allowed and carries all its sub-items to the new section.

Delete behaviour

Deletions are soft. Deleting a parent stamps the parent and all of its live sub-items with a shared deleted_at timestamp in one transaction. Deleting a sub-item individually stamps only that sub-item and recomputes totals. In both cases the rows survive; list and aggregate reads exclude them, and a repeat delete returns 204 without changing anything.

Request body

Response

201 Created — the new sub-item row, with the same derived fields (tagCount, hasTakeoffLinks) as every other work-item creator.

Error responses

parentWorkItemId is not accepted in the generic work-item create or update bodies. This endpoint is its only writer.

Copying a build-up into a work item

This endpoint copies components from a source work item’s build-up into the target work item identified by :itemId. It preserves the hierarchy of materialised activity instances — instance containers are copied with their instance fields intact, and their children are re-parented onto the copied container.

Request body

Rate refresh

When refreshRates is true, the server re-resolves each rate-library-bound component (those with a non-null rateItemId) against the current library before inserting into the target. Hand-entered lines without a rate-library link are never refreshed. With the current Rates contract, the server has two effective match tiers in order, stopping at the first hit: The resolver retains a legacy code-match branch, but current rate-item responses carry no code, so that branch cannot select a candidate. A tier that finds more than one matching item is treated as ambiguous; no choice is made and the line retains its historical price. Only rate-library-bound lines refresh; the rate-refresh pass runs entirely before the write transaction so that no peer-service call holds the estimate advisory lock.

Copy rules

  • Package-owned mirror components (type: "subcontract", subcontractBidRateId set, or a subcontract: pricing source) are excluded. They point at source-package state the target does not own.
  • Calculator-generated rows (generatedKey set) are excluded. A generated row belongs to the calculator that produced it and does not transplant to a work item whose calculator does not own it.
  • Day-rate lines (type: "day_rate") are copied verbatim, including their unit_cost overrides. When the source is from a different project, the copied lines retain the source revision and are marked inProjectSet: false on read if the target project pins a different revision of that schedule.
  • Instance containers are copied with their allocatedQuantity, productionRate, productionOutputUnit, productionProfileId, scopeLabel, chainLabel, and activityCount fields intact. Instance children are re-parented onto the copied container and retain their quantityBasis values.
  • An instance container whose productionOutputUnit does not match the target work item’s unit (normalised, case-insensitive) is silently dropped along with its children. The response includes a warning with the count of dropped instances and the target’s unit.
  • An unknown componentIds entry, or an activity-instance child selected without its container, returns 422 Unprocessable Entity.
  • A copied line does not arrive loose. A line copied without its activity (possible only for an older activity with no daily production) goes into one new activity, named after the line when there is only one and after the source work item otherwise. A source line that sits outside an activity goes into one new activity named Activity (or Activity 2, and so on, if the name is taken). Each line is charged so its cost is unchanged. New activities are listed first in copied. A line that has no equal inside an activity stays outside one.
  • With exactSelection: true, any selected line inside an activity whose activity is not selected returns 422 orphan_component_selection. An activity with no rate of its own that is selected with none of its lines returns 422 empty_activity_selection. Both list the offending IDs in invalidComponentIds. An activity priced by its own rate item may be copied on its own.
  • Estimate-owned pricing overrides are deep-copied with component IDs remapped. When refreshRates is true, a successfully refreshed ordinary rate line retires its historical override; unmatched, ambiguous, and no-price lines retain their frozen pricing inputs.

Copy-build-up response

The endpoint returns 201 Created with { copied, warnings, unitMismatchCount, rateRefresh }. The rateRefresh object contains: When refreshRates is true, each copied component row also carries a rateRefreshStatus field: The operation runs under the target estimate’s advisory lock. It reads the source build-up without locking the source estimate. Returns 404 Not Found if the target work item or source work item does not exist or is outside the caller’s organisation. Returns 502 Bad Gateway when refreshRates: true and a peer service (Rates API or Global API) read failed.

Work item component rateGroup

rateGroup is an optional classification stamp on a component row. It records which cost category the component belongs to, and the pricing engine reads it to decide whether the labour multiplier applies. Accepted values Behaviour on create and update
  • A valid token stamps the field. An invalid token returns 400 Bad Request.
  • An explicit null clears a previously stamped group.
  • An omitted key leaves an existing stamp untouched (null-vs-absent contract).
  • null and omitted are both valid on create; the field defaults to null.
  • POST /work-item-components returns the fully-stamped component row in its 201 Created response. The rateGroup value in the response body reflects the persisted state after stamping, not the pre-write input row.
Relationship to category category is a free-text description field used by the legacy classifier. A recognised rateGroup is an authoritative snapshot that supersedes category: a labour token applies the labour multiplier regardless of the category text, and any other recognised token suppresses it. When rateGroup is null, absent, or unrecognised, the engine falls back to the category-based sniff. Rows with no recognised group therefore price identically to their pre-stamp behaviour. Propagation When an activity is materialised into a work item using POST /work-items/:id/materialise-activity, each expanded leaf row receives the rateGroup stored on the corresponding library component. A library component with no rateGroup produces a leaf with rateGroup: null. When a work item is saved as an activity using POST /work-items/:id/promote-to-activity, the rateGroup on each component is included in the recipe key digest. A re-promote that changes a component’s group mints a new recipe rather than replaying the existing one. Project-calculator rows Project-level calculators publish physical outputs through their assignment register. The destination component owns its rate item, rate option, unit cost, category, and rate group; these pricing fields do not live in the calculator configuration.

Work item component calculator provenance fields

Every component read response — GET /work-items/:id/components and the components array on GET /work-items/:id — includes calculator provenance fields on every row. Hand-entered rows carry null for the identity fields and false for the flag. calculatorName is not stored on the component row. It is read from the live calculator record so that a rename is immediately visible. calculatorId is the stable identity; calculatorName can change or be absent without affecting the link. calculatorReusesExcavatedMaterial identifies rows produced by a calculator layer configured with isBackfill: true. Because the material is drawn from the site’s own excavation, its supply quantity may be zero. Consumers can use this flag to distinguish reuse rows from bought-in fill during pricing review. Tender revision snapshots rateGroup is a pricing input. A snapshot that omits it cannot reproduce the labour-multiplier decision it recorded. The snapshot schema version was advanced to v9 when this field was introduced. Snapshots stored under v1–v8 remain valid as recorded because their rateGroup is null, which falls through to the category-based sniff — the same path those snapshots used at submission time.

Work item component rate option snapshot fields

When a component is linked to a rate item and a rate option is selected, the server persists a snapshot of the selection context. These fields are stored on the component row itself and are returned on every component read. rateOptionId is a generated read-only field. It is derived from the component’s stored price selection and reflects the pinned rate option when selection_provenance is pinned, and null otherwise. To set or change a component’s rate option, use PUT /work-item-components/:id/pricing-rule. Organisation-owned selections and sourceName When a selected rate option is owned by the organisation (its scope is organisation), sourceName is always stored as null. Display the organisation’s current name from your shell context rather than from this field. Supplier-owned selections store the supplier’s name as sourceName as normal. Name currency The stored sourceName and locationName on editable estimates are kept current automatically. When a supplier or location is renamed in the global API, the server updates these fields on every affected build-up line in every editable estimate of that organisation. Frozen estimates keep the names they held at submission. Write protocol for sourceName and locationName On POST /work-item-components, supplied values are stamped and omitted fields are stored as null. On PUT /work-item-components/:id, a supplied value replaces the snapshot and an omitted key preserves it. Both fields accept an explicit null on update, which clears the stamp. Rate item re-link exception When a PUT /work-item-components/:id request changes rateItemId, the previous selection snapshot is stale. The server automatically clears any of sourceName and locationName that the same request does not replace. Explicitly supplied values, including null, are applied as sent. Pricing behaviour rateOptionId, sourceName and locationName are snapshot fields used for display and provenance. They do not affect pricing calculations.

Work item component priceSelection and valueOverrides fields

Every component read response — GET /work-items/:id/components and the components array on GET /work-items/:id — includes two additional fields that expose the component’s full pricing state without requiring a separate call to GET /estimates/:id/work-item-pricing. priceSelection — null when no rate option is selected. When a selection exists, it includes: valueOverrides — an array of manual field-level overrides applied to this component. Each entry contains:
chargeQuantity and chargeUnit are retired, and their columns were dropped from the component table, together with selectedLocationId. No route accepts them, no read returns them, and no pricing path reads them — the charge basis they encoded belonged to a rate offering, and a rate option is priced in one unit with an optional conversionFactor instead. The previous statement here — that the component cost divides the ordered quantity by chargeQuantity before applying the rate — is no longer true of any code path.estimateQuantity and packageSellBasis are retired too. The server always wrote 1 and "markup" and nothing read either value. They are no longer accepted on a component body or returned on a component read. A client that still sends either key has it stripped; the request is not refused. Their database columns were physically dropped by migration 0083.
Existing components Existing components retain their current pricing. The option snapshot fields are populated when a component is next explicitly selected or refreshed through the pricing panel. No automatic backfill occurs.

Work item component deliveryActivityComponentId

deliveryActivityComponentId is an optional nullable UUID field on a work item component row. It records that a line-entered material row is associated with (delivered through) a canonical activity container on the same work item. Source eligibility (D12) Only a line-entered, price-bearing material row may carry an outgoing delivery link. The following rows are not eligible as a source:
  • Calculator-generated rows (those with a non-null generatedKey).
  • Materialised child rows (those with a non-null parentComponentId).
  • Activity container rows.
Attempting to set a delivery link on an ineligible source returns 422 Unprocessable Entity. Target predicate (D13) The target must be a canonical activity container on the same work item: a component whose type is "activity", parentComponentId is null, activityId is non-null, and rateItemId is null. The target must belong to the same organisation. Write protocol (null-vs-absent triad)
  • A UUID value sets the link.
  • An explicit null value clears the link.
  • An omitted key preserves the existing link.
This applies on PUT /work-item-components/:id (update). A new line always arrives inside an activity, so POST /work-item-components refuses a link with 422 wrap_delivery_link_not_allowed. Only an existing line outside an activity can carry one. Invariant enforcement The server validates the effective post-patch state on every write. An edit that would leave the link in an invalid state returns 422 without making changes — links are never silently cleared by a conflicting edit. To resolve an invariant conflict, clear the delivery link first, or delete the container (which triggers the delete sweep and nulls all incoming links on the same work item). Cost parity (D14) A delivery link is metadata only. It does not move cost between components or change work item totals. Totals are identical before and after setting or clearing a link. Copy and duplication behaviour
  • POST /estimates/:estimateId/work-items/:itemId/duplicate — the link is rebound to the equivalent container in the copied work item.
  • POST /estimates/:estimateId/work-items/:itemId/copy-build-up — links within the copied component set are rebound to the copied containers. A material row copied without its linked container has its delivery link cleared to null.
Delete sweep DELETE /work-item-components/:id on an activity container that is the target of one or more delivery links nulls those links in the same transaction. This is the only write path that nulls a delivery link without an explicit null from the caller. Tender snapshot deliveryActivityComponentId is included in the tender revision snapshot. It is present on each component node that carries a link.

Work item component generatedKey

generatedKey identifies generated component rows. Current project calculators reconcile their own rows through the assignment register; legacy embedded keys remain compatible stored identities. The server mints project-calculator keys in lower case. The ordinal is omitted for the first row and included from the second row when a layer is partitioned across destinations in the same work item. See Project calculators for the current routes and Legacy embedded calculator columns removed for compatibility behaviour. POST /work-item-components refuses a generatedKey with 422 generated_key_not_allowed. On PUT /work-item-components/:id, the server lowercases the embedded UUID in a wic: key while preserving the case of <calcKey>. wi: keys and values outside the wic: scheme are stored unchanged. When a work item is duplicated, each component-scoped key is rebound to the corresponding component in the copy. If more than one rebound key canonicalises to the same value, later occurrences receive a deterministic ~N suffix, starting at ~2, without taking a suffix already present in the copied build-up.

Legacy embedded calculator columns removed

Migration 0058 guards against unconverted embedded configurations. Migration 0059 physically drops the calculator columns on work items and components. Their replacement is the estimate_calculators entity and its assignment register. Stale clients that still send a calculator key on POST /work-items, PUT /work-items/:id, POST /work-item-components, or PUT /work-item-components/:id are not rejected. Zod strips undeclared keys, so the field is silently ignored and nothing is written. New calculator work uses the project-level estimate_calculators entity and its assignment register. Project-level reconciliation owns the calc: generated-row namespace. A build-up line (work item component) can carry a quantityLink descriptor that links its quantity to the sum of one or more calculator readings or takeoff measurements. The descriptor uses QuantityLinkV1 from @stralos-co-nz/calculators. See Linked work-item quantity for the full descriptor schema. rateItem sources are not accepted on build-up lines. Stralos re-resolves every linked build-up line at the head of each estimate recompute, so a calculator save, reconcile, or delete updates the line’s quantity in the same request. Eligibility. Only whole-item priced lines that are not calculator- or recipe-generated can carry a link. Lines priced per unit of the work item, lines belonging to activities, and calculator-generated lines are ineligible. Rounding. quantityLink.rounding rounds the line’s linked quantity up after the multiplier and offset. A line priced in whole stock lengths, rolls, or sheets already rounds up and takes none: 422 component_link_rounding_whole_stock.
Returns the candidate sources a build-up line can link to, filtered to the line’s dimension. Error responses

Materialising an activity into a work item

Preview an activity expansion

Returns a deterministic expansion plan for a library activity without writing anything to the estimate. Use this to show an estimator the recipe, optional add-ons, package sell pricing, and any unpriced lines before they confirm the insert. The response includes a planHash (SHA-256 hex digest) that binds the expansion inputs and the reachable recipe, component, option, publication, and warning state observed at preview time. Pass this hash to POST /work-items/:id/materialise-activity as planHash to detect a relevant change before insert.

Request body

Response — 200 OK

Error responses


Insert a materialised activity

This endpoint expands a library activity into price-bearing rows in a work item’s build-up. It first walks the recipe through the Activities service, outside the database transaction. One transaction then takes the estimate-level lock, writes a non-pricing activity container and its flattened child rows, and recomputes the estimate once.

Request body — legacy mode

Omit allocatedQuantity to insert using the legacy (quantity-fold) mode.

Commercial-rule resolution

The three commercial rules (minChargeDuration, roundingIncrement, mobilisationAmount) were removed as a feature on 2026-09-20. The instance columns on work item components were dropped by migration 0060. Activity production profiles no longer carry default values for these fields. Any request body that still sends these field names is accepted and the fields are ignored. No response carries them back.

Charged-duration calculation

For an instance container, the children’s total cost is calculated from a duration derived from the allocation:
The unpriced.value figure in the response uses the charged duration.

Request body — instance mode

Supply allocatedQuantity to create an activity instance. Hour- and day-based activities retain their base duration unit. A quantity-based activity is also valid when its unit matches the work-item unit; Stralos converts it to a day-based instance and scales its recipe by the supplied units-per-day rate. The materialisation request does not accept productionOutputUnit. Creation derives the allocation unit from the work item; a selected production profile can supply its rate automatically only when its output unit matches that work-item unit. To use a different allocation unit after creation, update the returned container through PUT /work-item-components/:id with productionOutputUnit and any corresponding allocation or production-rate adjustments; the editing contract below canonicalises and stores it. In instance mode the container’s quantity is fixed at 1; the allocation drives the duration rather than a container quantity multiplier. Child quantities are stored per base unit of the activity (per day or per hour). The commercial-rule fields (minChargeDuration, roundingIncrement, mobilisationAmount) are no longer accepted. Sending them is ignored — no 422 is returned — but they are not stored or applied.

Materialisation response

The endpoint returns 201 Created with { container, children, warnings, unpriced, planHash }. The returned hash is the plan the server committed. The unpriced object contains:

Package sell fields on the container

When the inserted activity is a published fixed-price package, the container row carries these additional fields:

Package add-on provenance on child rows

Materialised children are detached snapshots: later library edits do not update them. Their server-owned parentComponentId attaches them to the container, and pricing treats them as recipe-derived rows. They are written with generatedKey: null, so calculator reconciliation cannot claim or remove them.

Editing an activity instance container

After materialisation, the instance container can be updated using PUT /work-item-components/:id. The following fields apply only to instance containers: A non-null activityCount must belong to an instance container. Explicit null is accepted as a harmless clear on other component rows.

Child quantity basis

quantityBasis controls how a direct child of an instance container is charged: When a library activity component carries whole_activity or per_count, the materialisation planner propagates that basis to the leaf row. The insert endpoint applies the same token to the written component, so an activity designer’s charge basis reaches the estimate without manual adjustment after materialisation. An activity whose recipe contains any non-null basis token requires instance mode (allocatedQuantity) — materialising it in legacy mode returns 422 quantity_basis_requires_production. On PUT /work-item-components/:id, omission preserves the stored basis, a token sets it, and explicit null clears it. POST /work-item-components/:id/children accepts the field when creating a direct instance child. A non-null basis on any other row returns 422; explicit null is accepted. Invalid tokens fail schema validation with 400. Changing the basis recomputes estimate totals in the same transaction. During POST /work-item-components/:id/enable-daily-production, children that already carry a non-null basis are left unchanged rather than being rescaled as legacy per-container quantities. Both activityCount and quantityBasis are pricing inputs included in tender revision snapshots. Their introduction advanced the snapshot schema to v19. The container’s activityId, unit, quantity, and type are fixed. Delete and re-materialise it to use another activity or unit. A work item’s unit cannot be changed while it has an instance whose productionOutputUnit matches the work item’s unit (canonicalised, case-insensitive). An instance denominated in a different unit does not block a work item unit change.

Option-group exclusivity

When an activity recipe includes optional components under an option_group, the materialisation plan enforces at most one selected line per group and owning activity. Two nested recipes can use the same group name independently, so selecting one option from each is valid.

Expansion and error behaviour

  • The root activity is depth 1. Nested activities are expanded through depth 3; deeper content is skipped with a warning.
  • A missing nested activity is skipped with a warning. A missing root activity returns 404 Not Found.
  • A recipe cycle returns 422 Unprocessable Entity.
  • A recipe requiring more than 200 activity fetches returns 422 Unprocessable Entity.
  • An unreachable Activities service, a non-success response other than a missing activity, or an unreadable response returns 502 Bad Gateway.
  • A missing work item, including one outside the caller’s organisation, returns 404 Not Found.
  • A parentActivityComponentId that is missing, belongs to another work item, or is not an activity container returns 422 Unprocessable Entity.
  • A quantity-based activity whose unit does not match the work-item unit returns 422 Unprocessable Entity.
  • Supplying instance-mode fields without allocatedQuantity returns 422 Unprocessable Entity.
  • Supplying quantity alongside allocatedQuantity returns 422 Unprocessable Entity.
  • A productionProfileId that does not belong to the root activity returns 422 Unprocessable Entity.
  • A mismatched profile unit without an explicit productionRate returns 422 Unprocessable Entity.
  • A derived duration that underflows or exceeds bounds returns 422 Unprocessable Entity.
  • Omitting planHash returns 422 Unprocessable Entity with code plan_hash_required, before activity fan-out or any database write. A present hash that is not 64 lowercase hexadecimal characters is a schema-level 400.
  • A selectedOptionalComponentIds entry that is not a reachable optional component in the plan returns 422 Unprocessable Entity.
  • Two entries in selectedOptionalComponentIds sharing the same activity-scoped exclusivity group returns 422 Unprocessable Entity.
  • A published fixed-price package nested beneath an existing production instance container returns 422 Unprocessable Entity with code published_package_instance_unsupported.
  • An activity that contains components with whole_activity or per_count quantity bases, materialised without allocatedQuantity, returns 422 Unprocessable Entity with code quantity_basis_requires_production. Enabling daily production (instance mode) is required so the charging bases can be applied.
  • When the reviewed hash differs from the fresh plan, the server returns 409 Conflict with code stale_materialisation_plan. The response body includes an updated plan from which the client can display the changes and re-confirm.

Saving a work item or activity subtree as an activity

This endpoint copies all or a selected part of the work item’s authoritative server-side build-up into a reusable Activities-library recipe. Omit componentIds to copy the whole build-up. Otherwise, each supplied ID must identify either a top-level component or an activity container; selected activity containers include their complete descendant subtree. The current detail-pane flow supplies the selected activity container’s ID to save that activity subtree. The operation does not modify the work item or attach the new activity to it. Library writes require the caller to be an organisation admin.

Request body

category is not part of the activity record. If a stale client sends it, schema parsing strips it and the value is not saved.

Copy rules

  • Ordinary internal build-up rows are copied in their stable display order using their stored base unit costs. Subcontract mirror rows are excluded because the activity library cannot represent bid pricing.
  • A materialised activity container is omitted and its expanded children are copied as ordinary flat components. This preserves the work item’s snapshot and prevents the container and its children from pricing the same recipe twice.
  • An activity container with no attached expansion is omitted with a warning because there is no recipe content to copy.
  • Calculator identity, multiplier, working-day, and pricing-override data are retained in component metadata as provenance where present. These values are inert in the activity library; the saved recipe prices from base unit costs.
  • A manual row without a rate-library link cannot be copied. The server refuses the entire save before calling the Activities service because that row would be skipped as unpriced when the saved activity was later materialised. Add each manual line to the rate library and link it before saving the activity.
  • If no eligible components remain, the API saves an empty activity and returns a warning.
  • An explicit empty componentIds array (present but zero-length) returns 422 Unprocessable Entity with code empty_component_selection. An absent componentIds means copy the whole build-up; an empty array means nothing was selected and is rejected rather than treated as a full copy.
Before saving, the server performs a best-effort, case-insensitive check for an existing activity with the same name and unit. A match adds a warning but does not block the save. The idempotency key is derived from the work item ID, requested name and unit, and mapped component content. Retrying the same unchanged save returns the existing library activity; changing content that would be written produces a new recipe.

Save-as-activity response

The Estimate API always returns 201 Created, including when the Activities API reports an idempotent replay. The endpoint returns 403 Forbidden when the caller is not permitted to maintain the organisation’s activity library, 404 Not Found when the work item is unavailable, and 502 Bad Gateway when the Activities service cannot complete the save.
Returns work items from across the caller’s organisation whose names are similar to a query name, together with their build-up components. Use this endpoint to surface reusable work items while an estimator is building up a new one. This endpoint and the activity endpoints below are protected. A missing or invalid bearer token returns 401 auth_required; an authenticated caller without organisation context returns 403. Results are ranked by name similarity. Items below the service’s 0.2 similarity cutoff are excluded, and the remaining results are ordered by descending score. Only work items that have at least one build-up component are candidates. Items with no components are excluded before the limit is applied. This means the response may return fewer results than limit, or an empty items array, when the organisation’s past work items are largely unpriced imports.

Query parameters

Response

200 OK — { items } where each entry is a work item row extended with a similarity score and a components array containing that item’s build-up components in display order. Every item in the response has at least one component.

Manual activity

Creates a work-item-local daily-production activity container with no activity-library source. Use this when an estimator needs a daily-production build-up that is not derived from a library activity. After creation, append price-bearing rate rows beneath the container using POST /work-item-components/:id/children.

Request body

The new container is inserted at the end of the work item’s build-up (or at the end of the parent container’s children when parentActivityComponentId is supplied). Response — 201 Created — the created activity container component row. Error responses

Wrapping a time-based rate in its own activity

Atomically creates an activity container and a nested rate child for a labour or plant/vehicle rate priced per hour, day, week, or month. Use this endpoint when an estimator adds such a rate at the top level of a work item: a standalone time-based rate has no production rate to anchor its quantity, so it must live inside an activity container. The endpoint creates both rows in one transaction. The server re-derives the routing decision from the supplied rate fields. It will reject any rate that is not classified as labour or plant_vehicles, and any rate whose unit is not hour, day, week, or month. A caller cannot use this endpoint to wrap a material, a subcontractor row, or a compound productivity unit such as m/day.

Request body

How duration maps to the activity container

The server maps the entered duration into the container’s base unit before deriving production:
  • For an hourly rate, the container unit is day, instanceDuration = duration / hoursPerDay, and the nested child quantity is hoursPerDay. This preserves the entered total hours while expressing production in working days.
  • For day-, week-, and month-based rates, the container keeps that unit, instanceDuration = duration, and the nested child quantity is 1.
  • When the mapped duration is greater than zero, allocatedQuantity is the work-item quantity when that value is positive, otherwise 1; productionRate = allocatedQuantity / instanceDuration.
  • When duration is zero, the container is structural (non-instance) and holds a zero-quantity child. The estimator can fill it in later.
Container identity, ancestry, quantity, and sort order are all server-owned.

Response

201 Created — { container, child }.

Error responses

Appending a child row to an activity container

Appends a price-bearing rate row as a direct child of an activity container. Use this alongside the manual activity endpoint to build up daily-production activities line by line, or to add lines to any existing activity container. The target component identified by :id must be an activity container (a component with type: "activity"). A non-container target returns 422.

Request body

The body accepts the same fields as POST /work-item-components, except that workItemId, type, activityId, subcontractBidRateId, generatedKey, calculator, deliveryActivityComponentId, and all activity-instance fields are omitted. The type defaults to rate_item. Response — 201 Created — the created child component row. Estimate totals are recomputed. Error responses

Converting an activity to daily production

Converts a legacy materialised activity container into the daily-production (instance) model in place. Use this when an activity was materialised without allocation and production rate fields and the estimator now wants to manage it as a daily-production crew. The target container must be an activity container, must not already have daily production enabled (allocatedQuantity and productionRate must both be absent), and its quantity must be greater than zero. The container’s unit must match the parent work item’s unit. The server converts the container’s existing legacy-basis child quantities from absolute (scaled by the container quantity) to per-day-output-unit quantities using the supplied production rate, then sets allocatedQuantity, productionRate, and productionOutputUnit on the container atomically. A child with a non-null quantityBasis is preserved and not rescaled.

Request body

Response — 200 OK — the updated activity container component row. Estimate totals are recomputed. Error responses

Project work item import

These two endpoints together implement a two-phase import of work items from one project’s estimate into another: the whole schedule, or chosen sections and lines. Both routes are organisation-scoped and require a valid bearer token.

Preview

Reads the source project’s estimate and returns a summary of what would be imported, together with a previewToken that must be supplied when confirming the import.

Request body

Response

200 OK

Error responses

Confirm import

Applies the import. The write is atomic and idempotent: sending the same requestId a second time returns the previously recorded result without writing again.

Request body

Import selection

Calculators feeding a brought line are copied only when calculators is copy. Whole-schedule append also checks section titles. It numbers the arriving sections first: a top-level section keeps its number when the destination has nothing at or under it, otherwise it takes the next free whole number, and its subsections follow it. If any arriving section then shares a title with a destination section at the same level, the import is refused with 422 project_import_section_title_taken and nothing is written.

Response

201 Created (first delivery) or 200 OK (idempotent replay).

Error responses

Work item batch mutations

POST /estimates/:estimateId/work-items/batch applies multiple work-item mutations to a single estimate in one atomic transaction with a single trailing pricing recompute.

Request body

All operation groups are optional, but at least one must be present.

Envelope invariants

The request is fully validated before any write. The entire batch is rejected if:
  • No operation contains any work-item IDs (400).
  • Any ID appears in more than one operation group, or appears twice within one group (409 — the response includes a conflictingIds array).
  • Any ID does not belong to the path estimate (404).
  • reviewedBidPackage is present alongside delete, move, setQuantity, or duplicate (422 — a reviewed bid selection may only be combined with reprice).

Execution order

When multiple operations are present, they run in this fixed order: setQuantity → reprice → move → duplicate → delete. A single recompute covers all writes.

Reviewed bid selection

When reviewedBidPackage is supplied, the server validates the whole-return review snapshot before applying the reprice entries. This prevents rates from being applied to work items whose quantities, units, or package membership have changed since the review was opened. The reviewedBidPackage object requires: When reviewedBidPackage is present, all reprice entries must use a selectable bid rate from that package. The batch validates that every named pricingSource of the form "subcontract:{bidRateId}" identifies a bid rate that was selectable in the snapshot — correct package, matching unit and quantity, no warnings that disqualify selection, and no duplicate rates for that work item. If the snapshot has changed (for example, a bid rate was edited, a work item quantity changed, or the package membership changed since the review was loaded), the server returns 409 with a message prompting the caller to refresh the review before selecting rates. Nothing is written. If a selected rate is not selectable (missing, wrong package, basis mismatch, or a disqualifying warning), the server returns 422. Nothing is written. Error responses specific to reviewed bid selection

Batch response

HTTP 200 on success. A failure in any operation rolls back the entire batch.

Reorder work items within a section

Replaces the display order for one complete section group. Pass sectionId: null for unsectioned items. The service takes the estimate lock and compares the supplied IDs with current membership before writing. It rejects partial, duplicated, stale, or filtered sets with 409 work_item_membership_drift; the response includes currentIds so the client can refresh. A missing or cross-organisation section uses the same non-leaking 409 response, while a visible section from another estimate returns 422. If the requested order would change an SOQ-locked item’s relative position, the service returns 422 Unprocessable Entity with code soq_order_violation and writes no rows. The response body includes offendingIds — the IDs of the locked items that form at least one inverted pair. See SOQ lock for the locking conditions. The SOQ check compares the relative order of locked items, not their absolute positions. Manual (non-locked) items may be placed anywhere in the list, including between two locked rows. The invariant that must hold is that all locked items appear in orderedIds in the same sequence they currently occupy — this is a subsequence check. A reorder that does not move any locked item past another always succeeds, regardless of what the stored sortOrder values happen to be. On success the service updates both work-item sortOrder values and their linked top-level programme headers. An empty list is a valid no-op for an empty group. The response is 200 OK with the reordered work items. After writing the new sort order, the service re-derives itemNumber for every row in the section. See Work item numbering for how numbers are assigned.

Work item numbering

Stralos assigns and maintains itemNumber automatically on every server write that affects a section’s membership or order. Clients normally omit the field; the server derives one from the section’s own number and the row’s position. Import integrations may supply a contractual number explicitly, which the service stores verbatim.

Section prefix

A section that has a sectionNumber uses that value as the prefix. A section with no number uses its 1-based sort position instead (the first section in an estimate is 1, the second is 2, and so on). Unsectioned items have no prefix and are never numbered automatically.

Unfrozen sections

A section is unfrozen when none of its existing rows are SOQ-locked. In an unfrozen section every row receives a dense sequence: {prefix}.1, {prefix}.2, and so on in display order. When a row is inserted, moved, or reordered, the whole section is renumbered to close any gaps and maintain the sequence.

Frozen sections (SOQ-sourced rows)

A section is frozen when it contains at least one SOQ-locked row whose number Stralos must not rewrite. In a frozen section:
  • Existing rows are never renumbered. Their itemNumber values stay exactly as imported.
  • A newly inserted row without a number is allocated a number from the row above it. When the new row is appended at the end of the section (nothing follows it), Stralos uses the next sibling — for example, 1.3 after 1.2. Segment width is preserved, so 1.009 advances to 1.010 rather than 1.10. When the new row is inserted between two existing rows, Stralos nests under the row above ({above}.1) to avoid claiming a reference a subsequent SOQ row may carry. If the candidate is already taken, the suffix increments until an unused one is found.
  • A reorder in a frozen section moves sortOrder values but leaves every itemNumber unchanged.

Inserting a row with an existing number

An imported row that arrives carrying its own itemNumber keeps it exactly and is treated as a frozen contractual reference, regardless of the estimate’s quantitySource. Setting quantitySource to plans controls whether quantities are editable; it does not unfreeze item numbers on rows that carried their own reference at import time. The section that receives such a row becomes frozen; subsequent inserts nest or use the next sibling as described above.

Column length limit

itemNumber is varchar(80). When the server cannot build a number that fits within 80 characters it leaves the row unnumbered rather than truncating. The row remains visible and the write that created or moved it still succeeds; numbering is a side effect and never causes a write to fail.

Programme header mirror

When itemNumber changes, the corresponding top-level programme header is updated in the same transaction.

Server-owned estimate fields

The status, version, and workingVersion fields on an estimate are server-owned. Clients cannot set or change them through POST /estimates or PUT /estimates/:id. Both explicit POST /estimates creation and lazy creation through GET /projects/:projectId/estimate use these initial values.

Error responses

Sending a server-owned field in a create or update body returns 400 Bad Request: The API checks the raw request body before schema parsing, so it returns the error even if the supplied value matches the current value. An update that omits all server-owned fields, such as renaming an estimate, is unaffected.

Provisional sums and item kinds

Item kinds

The itemKind field classifies how a work item is priced. Accepted values are: lump_sum is retired. Any write that sends itemKind: "lump_sum" stores measured instead — the API never refuses the value, so older clients and integrations keep working. A stored lump_sum row becomes measured on its next save. Lump sums are priced from their build-up like any other measured item.

Provisional sum rules

A provisional sum is a work item whose price is its allowance and nothing else. No build-up, no margin, no sub-items.
  • Allowance only. The allowance entered on the item is both its cost and its price. Build-up components, activities, and sub-items cannot be added to a provisional sum. Any of these writes returns 422 with code provisional_sum_no_build_up.
  • Quantity always 1. A provisional sum’s quantity is coerced to 1 on every write. Sending a different quantity is never refused — it is silently replaced.
  • Unit PS and itemKind move together. The unit provisional_sum (canonicalised from ps) and itemKind: "provisional_sum" are the same fact. Changing either sets the other. A write that changes both to contradictory values returns 422 with code provisional_sum_unit_mismatch.
  • allowanceAmount belongs to provisional sums. An allowanceAmount sent for any other item kind is silently cleared, not refused.

Converting an item to a provisional sum

When a PUT /work-items/:id write changes an item’s kind or unit to provisional_sum:
  • The item’s entire build-up is soft-deleted (components and sub-items).
  • Any quantity link is cleared.
  • Any calculator destinations, rate attachments, and pricing selections are removed.
  • The allowance is set to the allowanceAmount in the request body, or to the item’s current total price when none is sent.
  • The quantity is set to 1.
The response body includes a provisionalSumConversion object:

Converting a provisional sum back to a measured item

Change the unit away from provisional_sum (or send itemKind: "measured" with the new unit). The allowance is cleared. The quantity remains at 1 until you update it. The build-up is empty; add components manually.

Work item unit normalisation

The unit field on work items, takeoff measurements, and subcontract bid rates is normalised to a catalogue code at every write. The API maps common document abbreviations to their canonical equivalents before storing the value. Examples of recognised spellings and their canonical codes: The normalisation is case-insensitive and tolerates leading or trailing whitespace. ML (megalitre) is the one case-sensitive exception. An unrecognised spelling is retained after trimming and collapsing repeated whitespace; no other spelling is guessed. The four time-unit canonical codes (hour, day, week, month) are also the accepted units for POST /work-items/:id/time-resource-activity. A rate whose unit does not normalise to one of these four returns 422 from that endpoint. Subcontract bid rates with no unit (unit-less bids) produce a lump_sum mirror component. A unit-less bid represents a lump-sum amount rather than a unit rate.

Update behaviour

PUT update schemas omit owner/parent foreign-key fields for records that should not be re-parented. Omitted fields are left untouched rather than reset to create-time defaults.

Clearing work-item notes and clause references

notes and clauseRef use a three-state update contract on PUT /work-items/:id: omission preserves the stored value, an empty string is coerced to omission and is therefore a no-op, and explicit null clears the field. Neither field affects pricing totals.

Estimate lifecycle

Estimate status moves through a fixed set of states. Status changes only via POST /estimates/:id/transition; direct writes to status on the create or update endpoints return 400.

Statuses

Pricing freeze

A tender estimate is editable only when its status is draft or review and it has not been hidden as not awarded. Every user write (work items, components, sub-items, calculator links, margin-ladder rungs, pricing rows, subcontract packages and bid rates, reorder operations, matrix inputs, band overrides, companion charges, sections, rate-exception decisions and acceptances, and every field on PUT /estimates/:id) returns 409 estimate_frozen for any other status. The error body includes the current status. Renaming is the one exception; see Frozen estimate writes. Reads, previews, re-resolve dry runs, lifecycle transitions, variation writes, and version creates are not blocked. To change pricing after submission, use Revise tender (action: "revise") to return the estimate to draft, make the changes, then submit again.

Transition endpoint

Both routes accept the same request body and run the same handler. The project-based route is used by the global lifecycle orchestrator, which only holds a project ID. A 404 { code: "estimate_not_found" } response from that route signals no estimate exists for the project. Global decides the outcome by action: loss, archive, unarchive, and non-award admin overrides can continue with no estimate; award actions fail with baseline_required; and reopen preserves the estimate_not_found failure. These protected endpoints require a valid EdDSA bearer token. Request body User-facing actions — require a user token (not a service token): System-to-system (S2S) actions — require a service token with the lifecycle:transition scope plus a forwarded actor: Response — the updated estimate object on success. Error codes

One-in-flight guarantee

Only one transition may be in progress for a given estimate at a time. A submit or revise action places a pending reservation in the status history until the peer call completes. Any concurrent transition attempt returns 409 transition_in_flight. An additional per-project guard prevents two estimates from both reading a zero submitted count and both calling submit_tender: any estimate of the project that holds a pending transition blocks a new orchestrated submit or revise on the same project. If another estimate of the project holds a pending transition, the attempt returns 409 transition_in_flight on the project route. If the peer call result is ambiguous (network timeout, 5xx), the pending reservation is left in place and the background sweeper replays the correlation ID against the project service until it receives a definitive outcome.

Tender revisions

A tender revision snapshot is committed each time a submit transition completes. The snapshot captures the full priced work-item graph at the moment of submission under a REPEATABLE READ transaction, with a SHA-256 hash for integrity verification. The current snapshot schema version is v31. New submissions produce v31 snapshots. Stored v1–v30 snapshots remain valid and are read-tolerated. The estimate’s version field is set to the latest committed revision number (as a string) when each submit transition commits. An accept of a qualifying legacy estimate can also create revision 1 retroactively and set the version to match it.

Quote numbers

Each tender estimate, saved version and revised tender carries its own quote number, allocated by api-global’s POST /projects/:id/quote-numbers. The project’s original primary estimate uses the project’s own quote number. Other estimates are numbered when they are created or copied, a saved version when it is saved or first listed, and the working estimate again when Revise tender or Reopen returns it to Draft. Delivery estimates have none. GET /estimates/:id and GET /projects/:projectId/estimate number a tender estimate that has no quote number yet, and submit and accept do the same before they run. If api-global cannot allocate a number, the request returns 502 and can be retried: the allocation is keyed by a stable identity, so a retry reuses any number already allocated. Tender downloads, the submission PDF and work item spreadsheet exports print the estimate’s quote number.

List revisions

Returns committed revisions in descending revision order, without the full snapshot payload. Each revision’s quoteNumber is the quote number the estimate carried when that revision was submitted, or null for a revision submitted before quote numbers were recorded on revisions. Response — 200 OK
GET /estimates/:id/revisions/:revision, which returned one revision with its full snapshot, is retired and returns 404. The sign-off and comparison views read through their own endpoints.

Margin ladder

The pricing ladder resolves a margin or markup percentage for each component according to the estimate’s frozen pricingBasis. It uses the same five-rung order on both bases:
  1. Component override — a per-rate override set directly on the component row (margin_pct_override).
  2. Item category — the component’s rate category.
  3. Rate group — the component’s rate group (labour, plant & vehicles, bulk materials, materials, subcontractors, or disposal).
  4. Tender default — defaultMarginPct or defaultMarkupPct, according to the frozen basis.
  5. Organisation default — the organisation’s global percentage, frozen onto the estimate at creation.
The ladder is per-component, not per-work-item. A work item whose build-up mixes rate groups can have different effective margins on different lines.

Component override (marginPctOverride)

marginPctOverride is a writable field on a component row. It is accepted by four write endpoints: POST /work-item-components, PUT /work-item-components/:id, POST /work-item-components/:id/children, and POST /work-items/:id/time-resource-activity. The body schema accepts the union of the two basis bands (whole percent, 3 dp, −100 to 999.999) because the schema is parsed before any estimate is read. The estimate’s frozen pricingBasis is checked inside the write transaction. If the supplied value is outside the band that applies to this estimate, the server returns 400 component_margin_override_out_of_band naming the basis and the allowed range. null clears the override so the component inherits from the next rung. Omitting the key preserves the stored value on update. At pricing time, a stored override that is outside the band for the estimate’s basis is dropped and the next rung resolves instead. This means a value copied from a markup tender into a margin one cannot cause a recompute error. The organisation default and per-category and per-rate-group percentages are frozen onto the estimate at creation (copied from the organisation’s Global Settings). Pricing reads only from that frozen copy — it does not call the Global API at recompute time. The frozen values appear on the GET response as organisationPercent on each rung alongside whatever the tender itself has set. These routes require organisation context and access to the estimate in that organisation. An unknown or foreign-organisation estimate returns 404; organisation-admin status is not required.

Get the margin ladder

Returns the effective percentage at each rung, who set it ("organisation" or "tender"), and the frozen organisation value underneath — enough information to display the pricing-basis panel and offer a Reset to organisation action without a second read. Response — 200 OK Each rungs entry:

Set a tender rung

Upserts one tender-owned rung on the pricing ladder for this estimate. The write takes the estimate-totals lock and recomputes totals before returning — the same contract as the estimate PUT’s affectsTotals gate. The organisation default rung (scopeKind: "organisation") cannot be set here. To change the tender’s own default, use defaultMarginPct or defaultMarkupPct on PUT /estimates/:id, according to the estimate’s basis. Request body Response — 200 OK — the full updated ladder in the same shape as the GET response. Error codes

Reset a tender rung

Removes the tender’s override on one rung so the frozen organisation value takes effect again. The write takes the estimate-totals lock and recomputes totals. Deleting a rung the tender never set is idempotent — it returns 204 because the requested end state already holds. :scopeKind must be "rate_group" or "category". Attempting to delete the "organisation" rung returns 400 margin_ladder_organisation_rung_not_editable. Response — 204 No Content Error codes

Estimate workspace versions

Workspace versions let you save immutable snapshots of an estimate’s editable workspace at any point during estimating. Each saved version captures the full priced work-item graph including sections, components, pricing, takeoff measurements, subcontract packages, programme items, and tender clauses. Versions are independent of the tender revision flow: a workspace version can be saved at any lifecycle status, and saving one does not change the estimate’s status or tender revision history. All workspace-version endpoints are protected. A missing or invalid bearer token returns 401 auth_required; an authenticated caller without organisation context returns 403. The workingVersion field on the estimate tracks the current version number. It is server-owned and advances to the next integer each time a version is saved or branched.

List workspace versions

Returns the saved workspace versions for an estimate in descending version order. Response — 200 OK
isCurrent is true for the version whose number matches the estimate’s workingVersion. sourceVersionId is non-null when the version was branched from a historical one. versionEstimateId is the ID of the read-only saved-version estimate that holds this version’s rows; the estimate screen uses it to open a past version read-only. restorable is true when versionEstimateId is set. Versions saved in the older format have versionEstimateId: null and restorable: false: they can be viewed but not restored or duplicated. Each version object includes a summary field with frozen money read directly from the saved snapshot. These values are never repriced or recalculated after saving. Error responses

Save a workspace version

Saves the current editable estimate workspace as a new immutable version. The server recomputes totals immediately before snapshotting, so the stored amounts always reflect the current pricing state. The estimate’s workingVersion advances to the new version number. Request body Response — 201 Created — the saved version summary object, including the summary field with frozen money. Error responses

Branch a version

Restores a historical saved version into the editable workspace and saves the result as the new current version. The source version remains immutable. The estimate’s workingVersion advances to the new version number. Use this to return to an earlier pricing state without losing the history of intermediate versions. :version is the integer version number (1-based) to branch from. Request body Response — 201 Created — the branched version summary object, including the sourceVersionId pointing at the historical version it was created from, and the summary field with frozen money read from the restored snapshot. Error responses

Baseline supersede

An org admin can use this endpoint to deactivate the current baseline and create a new one bound to a specific committed revision. This is the resolution path when an accept transition returns 409 baseline_conflict because the existing baseline is referenced by variations. Requires the admin role. Authentication is always enforced. Existing variations retain their original baseline. New variations bind to the new active baseline. Request body Response — 201 Created — the newly created baseline and its items (same shape as POST /estimates/:id/baseline). Error codes

Linked work-item quantity

A linked quantity is a work-item quantity that Stralos derives automatically from one or more sources and re-resolves whenever those sources change. Sources can be calculator readings (a specific layer and output from a project calculator), all whole-item build-up rows of a rate item on that work item, or a takeoff measurement. The resolved value is Σ sources × multiplier + offset, rounded up when the link carries a rounding. The quantityLink field on PUT /work-items/:id accepts a descriptor or null: Linking or replacing a link Each entry in sources is one of three shapes: Calculator source Rate-item source Takeoff source Unlinking Send quantityLink: null to unlink. The work item keeps its current quantity as a manual origin value (or stays import for an SOQ line).

Quantity origin

A linked work item has quantityOrigin: "derived". An SOQ-locked item (quantityOrigin: "import") that is linked stays "import" — its measured-alongside value is what moves. Unlinking restores "manual" (or leaves "import" on an SOQ line).

Write rules

A typed quantity that differs from the stored value on a linked item is refused 422 quantity_linked. An equal resend (a full-row echo) passes without error. Unlink the item before typing a new quantity. A new or replaced link is checked before anything is written. The server refuses: The batch setQuantity operation applies the same quantity_linked guard: a linked item’s quantity cannot be set via the fill-handle batch. The entire batch rolls back if any item in it is linked and being given a different quantity.

Activity followers on quantity change

When a work item’s quantity changes — whether through a typed edit, a link resolution, or a batch set — Stralos also updates any top-level activity container that was allocated exactly the previous quantity in the same unit. These followers are reported in the response:
Skipped reasons:

Linked quantity re-resolution

Every write that can move a source — a calculator save, a calculator PUT /calculators/:id/links, a takeoff measurement update, or a POST /estimates/:id/recompute-totals — re-resolves linked quantities for the affected estimate as a pre-pass before pricing. Frozen estimates are skipped (D14). When a re-resolution moves a linked item’s quantity (a derived move), the write response includes:
This field appears on POST /work-item-components, PUT /work-item-components/:id, and calculator write routes when at least one linked item moved.

Delete guards

Deleting or modifying a source that a linked item uses is refused 409 until confirmed. Takeoff measurement delete — DELETE /takeoff-measurements/:id Returns 409 measurement_in_use with a usage array when at least one live work item’s link names that measurement:
Add ?unlink=true to confirm. The server unlinks each using work item entirely (not just the deleted source), keeping its last quantity as a manual value. The response includes unlinked (each item and its retained quantity) and workItemsChanged (the derived moves the re-resolution triggered). Map sheet delete — DELETE /takeoff-map-sheets/:id Applies the same measurement_in_use guard over all measurements on the sheet. ?unlink=true unlinks and removes the sheet in one step. Calculator delete — DELETE /calculators/:id Returns 409 calculator_in_use with a usage array listing each work item that links to any layer of the calculator. Add ?unlink=true to confirm; the server unlinks whole items and reports them in unlinked. Calculator save (PUT) — PUT /calculators/:id or PUT /calculators/:id/links If the save would stop the calculator emitting a layer that a link names, the server returns 409 calculator_source_removed before writing anything:
Add ?unlink=true to confirm. The calculator is saved and the affected items are unlinked in the same transaction. Both calculator saves and deletes trigger a full re-resolution pass unconditionally afterwards.
Returns the sources a work item’s quantity can be linked to, filtered by the item’s unit dimension. Use this to populate the link-source picker. Authentication: requires a valid bearer token with a resolved organisation context. Response — 200 OK Each calculators entry: Each readings entry: Each rateItems entry: Excluded row reasons: Each takeoffs entry: Error responses

Creating a work item from a measurement

POST /estimates/:id/work-items/from-measurement now creates the new work item with a takeoff link by default. When the estimator accepts the measurement’s quantity as-is and the measurement’s unit shares a dimension with the item’s unit, Stralos sets quantityOrigin: "derived" and stores a quantityLink pointing at the measurement. The item’s quantity then tracks the measurement automatically. If the estimator overrides the quantity (types a different value) or uses a unit in a different dimension, the item is created as a manual item with no link. The measurement’s work_item_id back-pointer continues to record the “created from” relationship and is not used to resolve linked quantities.

Project calculators

Project-level calculators feed physical quantities into one or more work items. Pricing belongs to each consuming work-item line. Every route is organisation scoped, and each mutation holds the estimate totals lock while it reconciles rows and recomputes pricing.

List calculators

Returns { items }, sorted by calculator name. Each calculator includes its configuration, assignment links, default destination, distinct Used by destinations, and linkedUseCount. linkedUseCount is the number of live work items and build-up lines whose quantityLink names this calculator. A work item or line that reads several of the calculator’s readings counts once. This is the count behind the “ƒ n uses” indicator in the Calculations list.

Create a calculator

Returns 201 { calculator, warnings, engineWarnings }. Pricing data in the configuration, a malformed or misplaced backfillFrom, or an ineligible destination returns 422. A fresh reinstatement selection whose referenced stratum has canUseForReinstatement: false returns 400 calculator_config_backfill_from_ineligible.

Copy a calculator

Copies the geometry and default destination under a new identity and allocated name. Explicit assignment links do not copy. Returns 201 { calculator, warnings, engineWarnings }.

Update or rename a calculator

All fields are optional and omitted fields are preserved: A body containing only name renames the calculator without running the engine, rewriting links, reconciling rows, or repricing. Supplying config, defaultDestination, or inputLinks performs a full save. Inheriting links follow a changed default; explicit links remain on their selected targets. When a config or inputLinks save would reconnect a row that an estimator previously unlinked, the server returns 409 calculator_unlinked_rows instead of saving. The caller must supply unlinkedRowChoices to resolve each pending row before the save proceeds. Returns 200 { calculator, warnings, engineWarnings }.

Unlinked row resolution

When PUT /calculators/:id carries config or inputLinks and the calculator has rows that were previously unlinked (via POST /work-item-components/:id/unlink-calculator), the server checks whether the updated engine would re-create a row at the same destination. If it would, the server returns 409 with code calculator_unlinked_rows and a list of pending rows. A measurement edit that re-resolves a linked input field does not ask. When a takeoff measurement changes and a linked calculator input is updated as a follow-on, the server reconciles without triggering the 409: the unlinked row is left as-is and the conflict check is deferred to the next explicit save. The error body contains:
Retry the PUT with unlinkedRowChoices to confirm how each row should be handled: Up to 500 entries are accepted. Once the choices are applied, the unlinked row record is cleared and subsequent saves proceed without the conflict check.
Send { links: [...] } with at most 500 entries: The replacement is atomic. Fill layers must have one whole-layer destination. Excavated-stratum shares may total less than one, which returns a warning, but cannot exceed one. The API reconciles every destination reached by the calculator and returns 200 { calculator, warnings, engineWarnings }.

Delete a calculator

keepRows is required. true detaches generated rows so they become ordinary editable lines; false deletes them. The response reports how many rows were detached or deleted. The delete is soft. The calculator is stamped deletedAt, and with keepRows=false the rows it generated share that one stamp. Its links are kept as history, but every write route refuses a deleted calculator (409 deleted), so nothing regenerates rows from them. A repeat DELETE returns 200 with both counts 0. The calculator’s own activity. A calculator whose destination is a work item itself puts its lines in a manual activity named after it. The delete soft-deletes that activity too, with the same stamp, when all of these hold: the calculator names it as its default destination or in a link; it is a live top-level manual activity (no library activity, rate item, generated key, or bid mirror); it still carries the calculator’s current name and has no live value override; it was created no earlier than the calculator; and it is empty once the rows are gone (no line, companion, delivery link, or other live calculator sending lines to it). With keepRows=true the kept rows sit inside it, so it stays. Quantity-link usage check. If any live work item’s or build-up line’s quantityLink names this calculator, the delete returns 409 calculator_in_use with a usage array listing the affected items. Add ?unlink=true to confirm: Stralos removes each quantity link and keeps the last resolved value as a manual quantity. The response then includes:
  • unlinked — work items whose quantity link was removed, each with their kept quantity.
  • unlinkedComponents — build-up lines whose quantity link was removed, each with componentId, quantity, and a label of the form <item number item name> · <line name>.
Removes the calculator assignment from one generated component row, keeping its current quantity, rate selection, and pricing intact as an ordinary editable line. The row’s generatedKey is cleared, and the calculator no longer manages it. The calculator records the unlinked row. When the same calculator is later saved with a config change that would re-create a row at that destination, the server returns 409 calculator_unlinked_rows until the caller resolves the conflict via unlinkedRowChoices on PUT /calculators/:id. See Unlinked row resolution. Response — 200 OK
Error responses

Engine warnings (engineWarnings)

engineWarnings is an array of advisory objects the engine emits alongside the calculator response. Each object has a kind string and optional numeric fields. Clients must tolerate kinds they do not recognise — new advisories are added by a pin bump alone.

Common errors