Skip to main content
The Rates API owns the rate catalogue, its pricing rules, day rate schedules, and project-level rate context. Shared records such as suppliers, company locations, and rate categories stay in the Global API. Rates routes use the shared EdDSA bearer-token verifier. Every non-exempt API route requires a valid user or service token. See Authentication.
Rewritten for the rates reimage. The three-table stack — rate items → rate offerings → rate prices — no longer exists. A rate option replaces both inner layers: one row carrying scope, supplier, location, supplier code, condition, basis, unit, conversion factor and cost. The eleven /rate-offerings/* and /rate-prices/* routes documented here previously have been removed and now return 404.
Migration 0045 — rate source ownership (breaking). The rate_sources and rate_source_locations tables in api-global have been removed. Rate options no longer carry rateSourceId or rateSourceLocationId. They now carry scope (organisation or supplier), supplierId (a parties.id, null for organisation scope), and locationId (a locations.id, null means all locations). The sourceIds= filter on GET /rate-items and GET /rate-summaries has been renamed sources=; its values are supplier party UUIDs or the literal token organisation. The source_location rule kind was retired in that migration; the later unified source-rule contract also retired supplier and location kinds. These are wire-breaking changes; see the sections below for details.

Base prefix

Endpoint groups

Removed routes

These eleven routes were removed with the rate-option model. They are absent from the OpenAPI document and return 404. No compatibility shim exists.
The fields those routes carried are gone with them. RateOffering and RatePrice are not types any longer, and neither are locationScope, quotedUnit, chargeQuantity, chargeUnit, materialPricingUnit, overheadPercent, markupPercent, finalCost, finalRate, priceCount, defaultMarkupPercent, labourMultipliers, supplierId, locationLabel, sourceName, or the item-level swellFactor, payloadTonnes, binVolumeM3, density, isMaterial, isDisposal, isPipe and pipeDiameter. Read the sections below rather than mapping old field names onto new ones — several concepts moved rather than being renamed.

Retired override routes

S6b removes the legacy override API and its backing table. These routes are absent from OpenAPI and return 404 Not Found:
Existing converted prices remain project rate options with their original ids. Use the project rates routes:
  • Read GET /project-rates?projectId=<id>&status=current.
  • Create with POST /project-rates/replacements or POST /project-rates/project-only.
  • Edit with PUT /project-rates/:optionId.
  • Retire with DELETE /project-rates/:optionId.

Retired project rate rule routes

Migration 0047 — ordered project rate rules (breaking). The per-rule write routes have been removed. They are absent from OpenAPI and return 404:
Use PUT /project-rate-settings/rule-set to create, update, reorder, and remove rules and the pricing location in one atomic call. See Project rate context and rules for the full contract.

Version endpoint

GET /api/rates/version returns the semver identity and commit SHA of the running deployment.
This endpoint is unauthenticated and returns Cache-Control: no-store. SERVICE_VERSION is stamped by the deployment process from the promotion manifest’s appVersion. SERVICE_COMMIT is stamped from the Git commit SHA of the deployed artifact. Release coordinators compare version with the manifest’s minimum version before promotion, and can use commit to confirm which exact build is serving.

Data isolation

Rate items, rate options, pricing rules, day rate schedules, project rate rules and project rates are tenant data. Each record belongs to one organisation. Routes execute tenant queries in an organisation-scoped transaction, use explicit organisation predicates where response semantics require them, and rely on FORCE RLS as the database backstop. For an authenticated organisation caller, a direct lookup or mutation of another organisation’s record returns 404; a filtered collection read returns an empty result. Neither response confirms that another organisation’s record exists. Composite foreign keys prevent an option from binding to a rate item in a different organisation.

Authentication requirements

“Org member or admin” means a signed-in person whose role in the organisation is member or admin. A service token never passes that gate. A platform or service token with no organisation receives 403 on tenant business endpoints. The health, version, and OpenAPI infrastructure routes are auth-exempt. A member token receives 403 on the admin-gated endpoints above. An unauthenticated request to a write endpoint receives 401. When billing enforcement is enabled for the environment, an authenticated user write that cannot be matched to an active paid subscription for the organisation also receives 403.

OpenAPI document

Returns the OpenAPI 3.1 document generated at service boot from the Rates 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.
  • Drift-gated: a checked-in snapshot is compared against the generated document in both directions in CI, so a route schema cannot change without the document changing with it. Treat the document as authoritative over this page.

Response bodies and phasing

Request schemas come from the same Zod objects used by the 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.
  • Unclassified errors — those not handled by a route — use a distinct shape: { code, reference }. A saturated database returns 503 with Retry-After: 5 and code: "service_unavailable"; any other unhandled error returns 500 with code: "internal_error". In both cases, reference equals the x-request-id response header value and can be used to correlate the incident in service logs. See Safe error handler for operational details.

Security

The OpenAPI document marks every included operation with the document-level bearerAuth requirement and a 401 response. It contains no public business operations. The auth-exempt version and OpenAPI infrastructure routes are intentionally excluded from the descriptor operations. Every included operation also documents 403 Forbidden because its tenant-scoped transaction rejects an authenticated caller with no resolved organisation context.

Rate items

A rate item is what the thing is: name, category, status, and the physical attributes its category licenses. Everything about what it costs, from whom, where, and under what conditions belongs to its options.
code and unit are no longer item fields. Migration 0036 dropped both columns from rates_rate_items. A supplier’s own code for a line now lives on the rate option as sourceItemCode. modeUnit, derived from the option set and returned on rate summaries, is the summary display, filter, and initial-seed axis; it is never written on the item itself. Estimate pricing instead normalises candidates into each component line’s stored unit.isDisposal is a retired field. The server accepts isDisposal in a POST /rate-items or PUT /rate-items/:id body but always ignores it and forces the stored value to false. Disposal classification is determined solely by the item’s rateGroup — an item whose selected category has the disposal parent group receives rateGroup: "disposal" on every write. The field remains on the response body and is always false.
Rate items are org-scoped. Any signed-in member or admin of the organisation can create or update one. Deleting an item, or bringing a deleted item back, requires the admin role; a member receives 403. GET /rate-items supports search, includeDeleted, categoryId, projectId, projectScope, and sources. search is treated as literal text — %, _, and \\ match only themselves — and totalCount always reflects the same predicate as the returned rows. Outside uuid mode, it matches the item name, category name, and sourceItemCode values on the item’s options, including superseded options. sources is a comma-separated list whose members are either the literal token organisation (for internally owned prices) or a supplier’s party UUID. When supplied, only items that have at least one live (active, no validTo) option owned by one of the named sources are returned. An item with no live options at those sources is excluded even if it has historical options there. The filter applies the same query shape as the sources filter on GET /rate-summaries. A malformed value returns 400; a well-formed token that matches no source returns an empty result rather than 404. Project scope on item and option reads. Without projectId, every read returns only Rates Library rows — items and options whose projectId is null. A project-only item is excluded and a project’s option never appears in a code search. Supplying projectId (a GUID) widens the listing to include the Rates Library plus that one project’s items and options. Another project’s rows are never included. projectScope=all is a special value for GET /rate-items that returns every project’s items alongside the library, for broad catalogue sweeps such as category-usage reporting. Sending projectScope=all together with projectId is a 400. A malformed projectId is a 400. The projectId filter also applies to individual item and option reads:
  • GET /rate-items/:id — a project-only item is a 404 unless ?projectId names its project.
  • GET /rate-items/:id/options — without projectId only Rates Library options are listed and a project-only item is a 404; with it, the library’s options plus that project’s options are returned (each option carries projectId).
  • GET /rate-options/:id — a project-scoped option is a 404 unless ?projectId names its project.
  • GET /rate-options/:id/companions — a project option as the parent is a 404 unless ?projectId names its project, and companions are drawn from the library plus that project only.

Batch item and option read

Reads up to 100 rate items by id in one request, each with all of its options, unpaged. It is the batch form of GET /rate-items/:id plus GET /rate-items/:id/options and returns the same item and option shapes, so a per-item parser works unchanged. The body is { "ids": ["<uuid>", ...] }, with 1 to 100 UUIDs. Duplicates are answered once, and items keeps the order of the requested ids. The response is:
Every id that is not answered appears in missingIds: an unknown id, another organisation’s item, a deleted item without includeDeleted=true, or a project-only item whose project is not named. The endpoint never returns 404; its error responses are 400 and 502. Options are ordered as GET /rate-items/:id/options orders them. The estimate-reprice service login may call this endpoint as one of its read-only routes.

Paging a category safely

categoryId is not only a filter. Supplying it switches the sort from category, name, id to id alone, and that matters for correctness rather than performance: the default sort reads a denormalised category string that this endpoint’s own reconcile can rewrite between two pages of an offset sweep, which silently drops or repeats rows. The uuid column and an id sort are both immune. Page one category at a time whenever completeness matters — for example, when deciding whether a category still holds anything. A malformed categoryId is a 400, never an ignored filter. In uuid mode, search no longer matches the category name. That column is rewritable and redundant once the category is pinned, so search covers only name and option sourceItemCode and sourceItemName values.

Duplicate-rate warning

Returns up to five live rate items whose name looks like name, most alike first. The Rates page and the rate editor use it to warn that a rate may already exist while a new one is being named. It is a warning only and never refuses a write; the exact-name refusal on writes (409 name_conflict) is unchanged. A match needs a name similarity of 0.5 or more, and the sizes in the two names must agree in at least one direction, so AP20 never warns about AP40. Ties are ordered by name.
Deleted items are left out. Malformed query parameters return 400.

Category licensing

categoryId is the one user-authored classification input. The server stamps the item’s rateGroup and category name from the referenced category on every write; neither is a request field. An id that is unknown or belongs to another organisation returns 422; if the catalogue service is unreachable the write returns 502 and nothing is stored. The category also licenses the physical fields. Supplying bulkingPercent or compactionPercent under a category whose parent is not quarry_materials, or maxTonnes or maxCubicMetres under a non-cartage category, is a 422 — a direct API call cannot leave physics on a labour item. A category change clears rather than refuses. When a write moves the item to a different category, any physics field the new category no longer licenses is set to null — including one the same request supplied explicitly — and the response lists what was cleared in clearedFields. clearedFields covers the physics fields only; code and unit are gone from the item and are not reported. Restoring an item from deleted re-applies the current licence the same way, because a deleted row can outlive a re-parent or a cartage-flag change.

Deleting an item

Deletion marks, it does not remove. The item’s status flips to deleted and its open options are set inactive in the same transaction. Nothing is erased, because a historical estimate resolves its line’s name and category through this row. A deleted item leaves every listing, picker, entry row, summary result and import target by default. It stays readable by explicit id (GET /rate-items/:id still answers 200 with the row and its status) and by a caller passing includeDeleted=true. Restoring is a PUT moving status off deleted. Uniqueness is enforced on name across non-deleted rows. Restoring an item whose name is already held by another active or inactive item returns 409.

Audit history

Returns a paginated, newest-first log of every recorded change to a rate item and to its rate options. Each entry covers one write that moved at least one tracked field. Tracked fields on a rate item: name, categoryId, category, rateGroup, status, bulkingPercent, compactionPercent, maxTonnes, maxCubicMetres. Tracked fields on a rate option: scope, supplierId, locationId, sourceItemCode, condition, basis, basisMultiplier, unit, conversionFactor, costRate, provenance, validFrom, validTo, status, needsReview. A write that changes nothing in the tracked field set writes no audit row. The history therefore contains only meaningful changes and not every database touch. History rows for a rate option outlive the option itself. A deleted option is hard-removed, but its audit rows remain and remain reachable through the owning item’s history endpoint. The endpoint accepts the same projectId query parameter as GET /rate-items/:id. A project-only item is a 404 unless ?projectId names its project. Standard start and length pagination query parameters apply. The optional actor query parameter filters by who made the change. actor=own keeps changes by the company’s own people (actorOrgId is null). actor=<contractor company id> keeps that contractor company’s changes. Leave it out for everyone. Any other value returns 400. The library-wide GET /history accepts the same parameter.

Rate options

A rate option is one purchasable line: this item, from this source, at this location, under this condition, in this unit, at this cost. An organisation’s own price has no supplier, so it carries no supplier item code or name. A library or project option write (POST /rate-items/:id/options, PUT /rate-options/:id, POST /project-rates/replacements, POST /project-rates/project-only, PUT /project-rates/:optionId) that sends a non-null sourceItemCode or sourceItemName on an organisation-scope option returns 422 with code: "supplier_fields_on_internal_rate". The issues array names each offending field. A database check constraint enforces the same rule, and restoring a history snapshot clears both fields on an organisation-scope option.

Factor orientation

conversionNumeratorUnit and conversionDenominatorUnit record the direction of a conversionFactor. A factor of 6 on a pipe option priced per length means 6 metres per length — the orientation makes that explicit. The two fields are a pair: both are present or both are null. A half-present pair is treated as corrupt by the pricing domain and the option is excluded from cost calculations entirely. The service enforces this with a database constraint, so the pair is always consistent. The orientation fields are read-only on the API. You cannot supply them in a request body. On create or update, the service derives and persists the orientation for these families: For options that do not fall into one of these families — including disposal options, where a factor is not valid — both fields remain null. A factor whose direction cannot be determined is excluded from pricing rather than priced incorrectly. The metre/each conversion pair is not a supported family. A pipe or conduit priced by the stick should use the length unit; the service derives metre per length for it automatically. Pricing reads a factor with no stored orientation from the option’s unit. Only these conversions exist: A disposal option never converts; its conversion comes from the substance. A metre ↔ each factor is no longer a conversion in either orientation, including a stored one, so a pipe option priced per each with a metres factor is excluded as conversion_missing. Re-price it per length with metres per length. A consuming line in metres or square metres priced from a stock unit orders whole units, rounded up per run.

Working days on weekly and monthly rates

An option priced per week or month, in any group except quarry_materials and disposal, can carry a conversionFactor that states the working days in that period, for example 5 days per week. The service stores it as day per week or day per month. Leave conversionFactor null to follow the project’s default working days, then the company’s. A supplied value must be greater than 0 and no more than 7 for week or 31 for month, with up to 2 decimal places. A create or update outside those bounds returns 422, for example “Days per week must be greater than 0 and no more than 7, with up to 2 decimals.” A monthly option gets the same message with month and 31. An import commit row outside them returns 422 naming the row.

The multiplier basis

A multiplier option tracks the Standard option for the same item, scope, supplier and location — Night at ×1.25 of Standard, for example. It stores no cost of its own; its money is standard.costRate × basisMultiplier, derived on read. A 0 placeholder is not permitted, because a real zero and a placeholder would be indistinguishable to every downstream consumer. The invariant is enforced in the service, not by a database CHECK, because a row-level constraint cannot see a sibling row:
  • Creating a multiplier with no Standard for that item, scope, supplier and location is 422.
  • PUT-ing a fixed option to multiplier with no Standard present is 422.
  • Moving a multiplier to another scope, supplier or location re-validates against the Standard there.
  • A Standard option can never itself be a multiplier — condition = 'standard' requires basis = 'fixed'.
Every transition that removes a Standard’s availability converts its dependants first, in the same transaction: retiring it, expiring it through validTo, deleting it, and PUT-ing its scope, supplier or location all first rewrite each dependant to fixed at its last derived value. The dependants keep pricing at the number they were already showing.

Changing a condition is a re-key, not a rename

A condition token discriminates the one-live-option index and, from the pricing rules engine, the day-rate loading unique key. Editing it moves the row to a different index slot and orphans anything keyed on the old token. PUT /rate-options/:id therefore refuses a condition change with 422, naming the endpoint that does it properly:
The re-key is a value migration with a mandatory preview:
The dry run returns two dependant lists — mutable dependants (rate options, and loadings on draft schedule revisions), which the commit re-keys in the same transaction, and immutable dependants (loadings on current or superseded revisions), which keep the old token because rewriting them would edit a signed document. It also returns a collision report: a target token that would leave an owner holding two rows for the same slot and condition is 422, as is re-keying away from standard on an option whose siblings depend on it (reserved_standard). The commit is 409 if a dependant appeared or moved since the preview — a re-key committed against a stale plan would silently skip an owner the user never saw.

Ordering

GET /rate-items/:id/options returns Standard first, then the remaining condition tokens alphabetically, then source and id. That coupling is deliberate: multiplier options derive their cost from Standard, so it leads rather than sorting among the tokens it anchors. includeInactive=true opts into history.

Companions

Reverse-discovers the active fixed-charge options configured against one parent option — a call-out fee beside an hourly rate, for example — including each child’s own item, supplier, location, and frozen pricing envelope. A companion lives on its own catalogue item, so it is not reachable by listing the parent item’s options.

Retiring an option

Options are retired, never deleted, so their history stays intact. To withdraw an option from sale, set status to inactive with PUT /rate-options/:id. Retiring is reversible, and it is also what item deletion does to an item’s options. There is no DELETE /rate-options/:id route; a request to it returns 404.

Rate summaries

Returns each non-deleted item with its live option count, its distinct sources, and a cost range normalised to the item’s modeUnit (or, under a sources or units filter, to the matching options’ mode unit).
modeUnit is the most common unit among the item’s live options; it can be a plurality rather than an absolute majority. Standard-option count breaks ties, then earliest validFrom, then alphabetical unit code. It is null for an item with no live options. It is derived and never written on the item. suppliers lists the distinct option owners across the item’s active options, Standard first. Each entry has id (the supplier’s party UUID, or null for the organisation’s own prices) and name (the resolved display name, or null when the id could not be resolved). locations lists the distinct locations across the same options in the same Standard-first order. Each entry has id (a locations.id) and name (the resolved display name, or null when unresolved). Both lists are built from the same ordered rows so they agree about which options they describe. An option with no location applies to all locations and contributes nothing to locations. An item priced only through all-locations options returns an empty locations array. hasAllLocations is true when any live option has a null locationId. Read it alongside locations: an item priced at all locations and at a named place lists only the named place in locations, so hasAllLocations is the only signal that the all-locations price exists. costRate and excludedOptionCount are computed from the options that match the active sources and units filters. With neither filter, every live option contributes. Options outside the filter are still returned in options, but they do not count towards the range or the excluded count. costRate.unit is the mode unit of the options that contribute to the range: modeUnit with no filter, or the matching options’ own mode unit under a filter. It is authoritative, so render it rather than inferring the unit from modeUnit or the filter inputs. stepAmountRange is { min, max } over the pickable step amounts of the item’s matching options that are priced by steps, or null when none are. Steps are lump sums, not prices per unit, so a stepped option never contributes to costRate and is never counted in excludedOptionCount. When summaries are sorted by costRate, an item priced only by steps sorts last in both directions.

Options array

Each entry in options carries the complete option row — the same fields that GET /rate-items/:id/options returns for that option — alongside the pricing rule metadata. The two key sets are disjoint, so neither overwrites the other. The option row has the same field set as its entry in GET /rate-items/:id/options, including source, scope, provenance, pricing, quote, audit, and resolved supplier/location fields. validFrom and validTo are serialised as ISO 8601 strings. See Rate options for the per-option contract. Each option keeps its nested pricing block from the full option response. The summary also includes the same metadata as flat fields: version, sourceFingerprint, ruleKind, ruleShape, fromUnitPrice, stepCount, stepAmountRange, matrixPriceRange, minimumQuantity, minimumAmount, modifiers, and rules. See Pricing rules for the rule shape. Options are ordered Standard-first within each summary row, then alphabetically by condition token, then supplier (organisation-owned first), then by id. This is the same order GET /rate-items/:id/options uses.

Bulk material density range

For items in the quarry_materials group, each summary row includes a loose-density range derived from its live priced options. The service reads conversionFactor as t/m³ when the stored orientation is tonne per cubic metre, uses its reciprocal for cubic metre per tonne, and treats a factor with only half its orientation as unreadable. A legacy factor with neither orientation field is read as t/m³. A range keeps differing supplier densities available to the calculator. The estimator chooses the supplier and exact density later through the rate option on the work-item line.

Query parameters

Supports search, category, categoryId, categoryIds, rateGroup, units, sources, sortBy (name, category, costRate, companyLocation; defaults to category), sortDir (asc, desc), and standard pagination. categoryId takes precedence over category and is the one to use: category matches the denormalised name that the reconcile can rewrite. A malformed categoryId is a 400. In uuid mode, search matches the item name and option sourceItemCode and sourceItemName values only. categoryIds is a comma-separated list of category UUIDs. It takes precedence over both categoryId and category. A malformed value is a 400. rateGroup accepts any of the six group tokens, or null for the unresolved review queue — items whose stored category no longer resolves. An unrecognised or absent value applies no filter and never returns 400. units is a comma-separated list of unit codes (matching ^[A-Za-z0-9_]+$, up to 50 tokens). Only items whose modeUnit (the most common unit among the item’s live rate options) matches one of the supplied codes are returned. An item with no live options has no modeUnit and matches no unit filter. The filter is applied before totalCount and the page slice, so the count always describes the filtered set. An empty string is treated as absent. A malformed value is a 400. When active, only options priced in one of the supplied units count towards costRate. sources is a comma-separated list whose members are either the literal token organisation (for internally owned prices) or a supplier’s party UUID. Only items that have at least one live option owned by one of the supplied sources are returned. An unpriced item has no options and therefore never matches. The filter is applied before totalCount and the page slice. An empty string is treated as absent. A well-formed token that does not match any option returns an empty page rather than a 404. A malformed value is a 400. When active, only options owned by one of the supplied sources count towards costRate. Both units and sources are optional and additive. They AND with every other active filter. In every sort mode, ties fall back to item name and then id, ascending, so the order is total. Ties on costRate.min, category or company and location are common, and without the tie-break offset paging over them is nondeterministic. companyLocation sorts by the first owner and first place shown in each item’s Company — Location cell. The organisation’s own prices sort first, then suppliers by name, then suppliers whose id did not resolve. Within an owner, an all-locations price (hasAllLocations) leads named places. Unpriced items sort last in both directions.

Unresolved count

unresolvedCategoryCount is computed independently of any active rateGroup, category, categoryId or search filter, so it stays stable as a user changes filters. It suits a persistent review indicator.

Rate sites

Returns the distinct supplier sites that hold a current Rates Library price, as { items: [{ supplierId, locationId }] }. The project setup step uses it to measure each site’s road distance from the project and count the suppliers within the chosen search distance. A row comes from a current option (status active, open validTo) with scope = 'supplier', on an active Rates Library item. rateGroup is optional: one of the six rate group tokens, absent for every group, and an unknown value returns 400. The company’s own prices and every project price are left out. locationId: null is a supplier price for all of that supplier’s sites. It is returned as one row per supplier, not expanded. Pass those suppliers as partyIds to api-global’s location-routes resolve to expand them. Rows are ordered by supplier, with each supplier’s all-locations row first. Any authenticated caller with an organisation can read it, the same as GET /rate-summaries.

Pricing rules

A rate option with a plain price needs no pricing rule; no row means simple. A rule set makes an option’s price depend on quantity or condition: quantity bands, a two-axis matrix, a fixed charge, a cost-plus percentage, additive and multiplicative loadings, and minimum quantity or minimum amount floors.
Rules are owner-discriminated: an owner is a rate option, a day rate schedule revision, or a day rate schedule line. One table and one validator serve all three, so a schedule’s night loading is validated by the same code as a supplier’s band schedule. A rule set is saved whole. The PUT replaces an owner’s complete set and carries the owner’s canonical version token; a mismatch is 409, so two editors cannot interleave partial saves. Slots — base, minimum_quantity, minimum_amount, modifier_additive, modifier_multiplicative — are independent rows and resolve independently, most specific first, so a revision-level cost-plus and a line-level minimum both apply to the same charge. GET /pricing-rules/condition-tokens returns the organisation’s condition vocabulary for suggestion in an editor. Tokens are free text by design; the vocabulary is a suggestion, not an allow-list. Owner mutability is enforced here, and only here. A rule write against a current or superseded day rate revision is 409 published revision is immutable — create a new revision. A rule write whose rate option is not live — inactive, or carrying a validTo — is 409 Inactive rate option pricing rules are immutable, because a past tender reads those rules. Since deleting a rate item inactivates its options, that item’s option rules become uneditable until the item is restored; restore first, then edit.

Project rate context and rules

These replace the old region string match with an ordered list of rate rules and a pricing-location entry. A rule is scoped to a rate group, category, or item. Current rules use ruleKind: "source" to select an organisation or supplier price at a named location or across all locations, or ruleKind: "policy" for a radius-based choice. Earlier supplier, location, and source_location rule kinds are retired from this Rates API contract. A source that later becomes inactive or unreadable remains visible in the rule-set read with source.unavailable: true and matches no option. An All-locations rule (locationId: null) matches only the owner’s prices that carry no locationId — prices that genuinely apply everywhere. 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 is unsatisfiable and the next entry in the ordered list is tried. A place rule (locationId set) is unchanged: it matches only that named location. Migration 0048 rewrote stored supplier and location rules as source rules and retired providerKind; migration 0054 dropped its column. The current read returns a hydrated source object instead of the retired pointer fields. A location target’s owner is checked against partyId on write and read from api-global again on later reads.

Ordered rule set

Rules and the pricing location share one ordered list. Each entry carries a 1-based position from its place in the submitted array. The estimate prices a line against the first matching entry in that order. GET /project-rate-settings returns exists: false at HTTP 200 for a virgin project. It returns rules (each with a position field) and a context object that includes the pricing location’s own position in the shared list. PUT /project-rate-settings/rule-set is the only route that writes rules or the pricing location. It replaces the whole ordered list — rules and the location — in one atomic call. Entry order in the entries array determines position values 1..n. The request body requires: Each location entry has: Each rule entry has: Two rule entries with the same scopeKind and scopeRef return 422 duplicate_rule_scope. The response on success is the same payload as GET /project-rate-settings. Source rules are returned with source: { partyId, partyName, locationId, locationName, unavailable }; policy rules have source: null. Each source rule also carries an evidence object (or null when no cached route is available) with the driving distance from the project to the rule’s source location: For a source rule with a named location (locationId set), evidence reflects that specific place’s route when cached. An All-locations source rule (locationId: null) and every policy rule always return evidence: null: neither names a single source place whose distance could be displayed. PUT /project-rate-settings/rule-set warms routes for named-place source rules only, bounded to 25 places per save and prioritised by straight-line distance, before writing the transaction. A rule set containing only All-locations source rules warms no source routes. A warming failure is logged but does not block the save; affected named-place rules show evidence: null until those routes are warmed later.

Derive context

POST /project-rate-settings/derive-context previews what an auto location entry would derive to — the nearest routed active internal location, or a named unresolved reason — without writing anything. Route-cache warming in api-global still happens. dryRun must be true. When candidates exist but none is geocoded the response records unresolvedReason: "no_coordinates" rather than a bare null. When the project exists but the caller does not have access to it, this route returns 403.

Conflict responses

PUT /project-rate-settings/rule-set returns 409 in these cases:

Project rates

Project rates hold fixed prices for one project. A replacement prices an existing Rates Library item for that project. A project_only item and its prices exist only within that project until a member or admin promotes the item.

List project rates

GET /project-rates requires projectId. It accepts estimateId (optional, see below), scope (organisation or supplier), supplierId (a party UUID), status (current by default or all), search, start, and length. Search matches the item name, supplier code, supplier item name, and quote reference within the named project. scope and supplierId are independent filters: use scope=organisation to list internally priced items, or supplierId=<uuid> to list a specific supplier’s items. Estimate-scoped reads. When a project has more than one estimate, each estimate holds its own copy of that project’s rates. Pass estimateId to read the rates for a specific estimate. Omitting estimateId returns the project’s default (primary) estimate rates, which is the only behaviour available until a project has been backfilled and a copy exists. Each row identifies its kind, item, source and location, condition, unit, conversion factor, cost, quote details, provenance, notes, status, validity dates, and audit fields. sourceItemName stores the supplier’s wording for display and reconciliation; it does not affect identity, matching, or pricing. A replacement also carries library, the current library price range for the same condition. An imported row also carries sourcePosition and sourceImportId, which keep the supplier quote’s own line order. sourcePosition is the row’s 1-based place in the import commit’s rates[], the order the document was read in. sourceImportId is shared by every option one import commit writes; positions are only comparable within one sourceImportId. Both are null together, on rows not written by an import and on rows imported before these fields existed. Importing a newer quote over an existing price re-stamps both. The response includes pendingRepriceCount and:
When rows are pending, the block reports the highest attempt count, most recent failure, and latest attempt time.

Create a replacement rate

POST /project-rates/replacements records a project price for an existing library item. It requires projectId, rateItemId, scope, supplierId (required when scope is supplier, null when organisation), locationId, unit, costRate, and provenance. Optional quote, conversion, supplier-item, and notes fields describe the price. A duplicate item, scope, supplier, location, and condition slot returns 409 current_exists with the existing option id.

Create a project-only rate

POST /project-rates/project-only creates a project-owned item and its first price in one transaction. It requires the replacement fields plus name and categoryId. A matching library item returns 409 library_item_exists. A duplicate project-only name returns 409 project_name_taken.

Update a project rate

PUT /project-rates/:optionId?projectId=<uuid> updates a current rate. You can change price, unit, factor, supplier code and name (supplier prices only), quote fields, provenance, and notes. A project-only rate can also change its item name and category. Scope, supplier, location, condition, project, and item remain fixed. Omitting a nullable field leaves it unchanged. Sending null, or a blank supplier item name, clears it. A retired rate returns 409 rate_retired. A project rate priced by steps has no cost of its own. Changing its costRate returns 422 stepped_rate_cost_edit; its steps are edited on the rate option itself. A cost sent for a project rate priced by a rate table supersedes the table: the cost becomes the price and the table is removed in the same transaction. An update that sends no costRate keeps the table.

Delete a project rate

DELETE /project-rates/:optionId?projectId=<uuid> retires the option. It remains readable with status=all, so existing estimate references stay resolvable. Deleting the last current price on a project-only item also marks that item deleted.

Promote a project-only rate

POST /project-rates/:optionId/promote?projectId=<uuid> moves the item into the Rates Library while retaining its item id. It creates a library option from the project price. The project option remains and becomes a replacement; estimate lines do not move and this operation does not trigger re-pricing. Promotion requires a signed-in member or admin. not_project_only, name_conflict, and rate_retired report invalid promotion states. Promoting a rate priced by steps copies its steps onto the new library option.

Automatic re-price

Each create, update, and delete commits a re-price outbox row with the rate change, then asks the Estimate API to re-price the project’s affected work-item lines. The write succeeds even if delivery fails. The write response’s reprice.status is: When a parent rate moves to another supplier, companionsRemoved lists fixed-charge companion lines removed during re-pricing.

Deliver pending re-prices

POST /project-rates/reprice-pending?projectId=<uuid> retries up to 500 pending rows, reduced to the latest change for each item and option. A frozen or missing estimate marks its rows delivered and skipped. A successful response includes delivered, pending, companionsRemoved, and pendingReprice. If the Estimate API cannot be reached or refuses the operation, the endpoint returns 502, records the attempt, and leaves the rows pending. A companions_unavailable error names the blocking work item and line in pendingReprice.lastError.

Writes to a frozen estimate

Project rates lock per estimate. Before a user-facing project-rate write, the service reads the target estimate’s state from the Estimate API with the caller’s token. A write to a frozen estimate is refused and nothing is stored. These routes apply the check:
  • POST /project-rates/replacements
  • POST /project-rates/project-only
  • PUT /project-rates/:optionId
  • DELETE /project-rates/:optionId
  • POST /project-rates/:optionId/promote
  • PUT /project-rate-settings/rule-set
  • POST /project-rate-imports
The freeze rule matches the Estimate API’s (see Frozen estimate writes). Tenders in draft or review that have not been hidden as not awarded, and Delivery estimates in draft, are editable. Any other estimate returns: The message names the state and the way out, for example “This tender has been submitted, so its rates are read-only. Please use an alternative estimate to make changes.” If the Estimate API cannot be reached or returns an unreadable answer, the write is refused with 502 and nothing is stored. An estimate the Estimate API does not know returns 404. The estimate-rates service-login routes and re-price delivery are not checked: they are how a frozen estimate legitimately receives its rates.

Authentication

Any authenticated organisation member can list, create, update, retire, and retry project rates. Promotion requires a signed-in member or admin; a service token receives 403.

Estimate rates

These routes are service-only. They are called by api-estimate under its estimate-rates service login and are not accessible to user tokens or to other service logins.

Backfill project estimates

POST /estimate-rates/backfill stamps each named project’s existing estimate-less rate rows with the supplied estimate id and records that estimate as the project’s default. The default is what GET /project-rates?projectId=<id> (without estimateId) returns. The request body contains a projects array of up to 200 { projectId, estimateId } pairs. Each project may appear once. Each project in the response carries: The route is idempotent. Calling it again for a project that is already stamped returns stamped with zero counts.

Update the default estimate

PUT /estimate-rates/defaults updates the project’s default estimate — the estimate that GET /project-rates?projectId=<id> (without estimateId) returns and that the stamp trigger targets on estimate-less writes. api-estimate calls this after promoting an estimate to primary (make-primary or award) so that bare reads and writes follow the new primary. The request body contains a projects array of up to 200 { projectId, estimateId } pairs. Each project may appear once. The whole call runs in one transaction; a project belonging to another organisation refuses the entire call with 409 project_not_in_organisation and nothing is written. Each project in the response carries: The call is idempotent. A repeat with the same estimate id returns unchanged. Moving the default re-attributes no rows (every project row already carries its own estimate_id after migration 0059’s validated contract), triggers no re-price, and bumps no rate-set version.

Copy estimate rates

POST /estimate-rates/copy copies all project rates from a source estimate onto a target estimate within the same project. This is used when duplicating an estimate to give the copy its own independent rate set. The request body requires sourceEstimateId, targetEstimateId, projectId, and correlationId (api-estimate’s copy job id, up to 128 characters). Source and target must differ. The response carries: The copy is refused with 409 copy_target_not_empty if the target estimate already holds project rates of its own. A replay with the same correlationId returns 200 and the stored id maps without writing anything. Any other request for a target already copied into returns 409 copy_target_taken.

Discard an abandoned copy

POST /estimate-rates/discard soft-deletes the project rates belonging to a copy whose target estimate was never finished. api-estimate’s sweeper calls this when it abandons a copy job that has been pending for more than 24 hours. The request body requires projectId, targetEstimateId (the abandoned copy’s target), and correlationId (the same job id used in the original copy call). The route acts only on a target that this service copied into under that correlationId; any other value returns 404 copy_not_found. Discarding the project’s current default estimate returns 409 default_estimate. The response carries: The call is idempotent. A replay returns 200 with zero counts. A later copy attempt into a discarded target still returns 409 copy_target_taken.

Restore estimate rates

POST /estimate-rates/restore replaces the working estimate’s project rates with those from a saved version. In one transaction, every live project row on the target estimate is soft-deleted and the source estimate’s rows are copied in with new ids. The target’s pricing-location settings are set to match the source’s. If the source has no live settings row (meaning the saved version had no explicit pricing location — it was deriving automatically), the target’s settings row is soft-deleted rather than hard-deleted. The target then reads as having no saved settings, the same as the source. The request body requires sourceEstimateId, targetEstimateId, projectId, and correlationId (api-estimate’s restore job id, up to 128 characters). Source and target must differ. Unlike a copy, the same target estimate can be restored into multiple times using different correlation ids. The response carries: A first restore returns 201. A replay of the same (targetEstimateId, correlationId) pair returns 200 with the stored id maps and writes nothing. Replaying the same correlation id with a different source or project returns 409 restore_correlation_reused. The restore does not enqueue a ProjectRatesChanged event and does not write a re-price outbox row. Error codes:

Authentication

All routes in this group require the estimate-rates service login minted by api-estimate. Any other caller receives 403 estimate_rates_caller_required. Any other route accessed with this login receives 403 estimate_rates_only.

Day rate schedules

A day rate schedule is a contractual sell document: a schedule holds revisions, a revision holds lines, and a line carries a code, description, unit and day rate. Schedules can inherit from another schedule’s pinned revision at a ±% adjustment, and a rebase reviews what the base changed. Key semantics:
  • A revision freezes at publish. current and superseded revisions are immutable: line POST, PUT and DELETE all return 409. The one carve-out is PUT /day-rate-schedule-lines/:id/mapping, which records the internal rate-item mapping — analysis, not contract text — and is permitted on a published revision.
  • One draft at a time. POST /day-rate-schedules/:id/revisions requires sourceRevisionId explicitly and is 409 if a draft already exists.
  • Publishing requires a later effective date. A revision cut from another copies its source’s effectiveFrom; set the draft’s own date through PUT /day-rate-schedule-revisions/:id before publishing, or the publish is 422.
  • DELETE /day-rate-schedules/:id archives. It answers 204, is idempotent, and returns 409 from no arm. Every revision, line, mapping, pricing rule and existing pin is preserved; the schedule leaves default listings, stays readable by id and through an explicit archived filter, and all writes against it are then 409. The archived name stops reserving an active identity, so a new schedule may take it. Use POST /day-rate-schedules/:id/restore to make an archived schedule active again; restore returns 409 if another active schedule has since taken its name.
  • An archived base still serves rebases. An active dependant can still read a retired base’s published history and rebase from it; starting new inheritance from an archived base is blocked.
  • A cost_plus line has no number. It carries a percentage over internal cost and renders as cost + 25%; the schedule stores no money for it.

Schedule fields

List and get schedules

The collection response contains items, totalCount, start, and length. Each item contains its revision history in descending revision-number order, currentRevision, draftRevision, supersededRevisionCount, derivation, and needsBaseReview. Results are ordered by name, then id. Collection filters do not affect direct lookups, so an archived schedule remains readable by id. A direct lookup returns 404 when the schedule does not exist or belongs to another organisation.

Create a schedule

An organisation admin supplies name, lineAuthority, and effectiveFrom. clientId, contractRef, termsText, and an inheritance object are optional. The service creates revision 1 as a draft and snapshots the schedule, client, and organisation identity. If identity resolution is unavailable, the request returns 502 without creating the schedule. To start from another schedule, supply:
The base must be a published revision of an active schedule. The new draft materialises its lines at the adjustment and records the pinned base revision. adjustmentPct must be greater than -100, no greater than 1000, and have at most three decimal places. Flat and fixed amounts are adjusted and rounded half-up to cents. Cost-plus lines keep a null amount; their rules, along with the other revision and line pricing rules, are copied unchanged. The pin chain cannot contain a cycle or exceed depth 10. The complete schedule, revision, adjusted lines, and pricing-rule copy is one transaction. Inherited lines start without internal rate-option or activity mappings.

Duplicate a schedule

This creates a new schedule and revision 1 draft from an explicitly selected published revision of :id. copy creates an independent schedule with no base pin. It preserves line mappings and applies the requested adjustment to flat and fixed amounts. inherit pins the selected revision, records the adjustment, and leaves copied lines linked to their base provenance for later review and rebase. Both modes copy the published source’s dates, terms, line authority, contract reference, lines, and pricing rules, but snapshot the target name, client, and current organisation identity. It returns 201 Created.

Update, archive, and restore a schedule

The PUT body may change name, clientId, contractRef, or lineAuthority; it must contain at least one field. Existing revision snapshots do not change. New revisions capture the updated identity. DELETE archives rather than physically deleting. Archived schedules remain readable but reject writes, and repeated deletes still return 204. Restore is also idempotent: calling it on an active schedule returns 200 without changing the row. Restore requires the admin role; a member receives 403. Restoring does not change contractual revisions, lines, mappings, or pricing rules. A successful response contains the schedule identity and a dependants array of active current or draft revisions directly pinned to the schedule. These dependants are informative and do not block restoration. Each dependant contains: A restore can return two 409 responses. Branch on code, not the message: existingId and existingStatus are absent from the unstable response because there is no single blocking schedule to identify.

Revision fields

Create and edit a revision

To create a draft, an organisation admin supplies sourceRevisionId. The source must be a current or superseded revision of the same schedule. The service copies its dates, terms, inheritance state, lines, mappings, and pricing rules, but takes fresh identity snapshots. A draft source returns 422; an existing draft returns 409. A concurrent identity change also returns 409, and the caller should retry. The revision PUT may change effectiveFrom, effectiveTo, or termsText and must contain at least one field. Only a draft is editable; a published revision returns 409. An invalid effective-date range returns 422.

Publish a revision

Publishing changes a draft to current and supersedes the previous current revision. The new effectiveFrom must be later than the current revision’s, and its range must not overlap another published revision except the current one it supersedes. The service closes an open-ended current revision on the day before the new revision begins. It also validates that each line’s rate kind agrees with its pricing rules. Lifecycle or rule-agreement conflicts return 409; invalid dates return 422. Published revisions are immutable at the database boundary. Create another draft to change their dates, terms, contractual lines, or pricing rules.

Schedule authentication

Derivation labels

The derivation field describes the displayed revision:
  • Client-defined lines means lineAuthoritySnapshot is client_defined.
  • Independent · from YYYY-MM-DD means the revision has no base revision.
  • An inherited revision names the base schedule, its signed adjustment, and the pinned revision number, for example Civil Rates +5% · pinned Rev 3.

Revision lines

A line is one contractual sell item owned by a revision. flat and fixed both carry an explicit contractual amount. A cost_plus line carries no dayRate; its sell is resolved from a standard base pricing rule on the line, falling back to the revision’s standard base rule.
These routes write draft revisions only. POST creates a line and returns 201; PUT requires at least one writable line field; DELETE returns 204 and also removes the line’s pricing-rule state. The revision id and line id must belong together or the API returns 404. Published contractual content is immutable at the database boundary. Mapping is the deliberate exception because it records internal cost analysis rather than changing the issued document.

Line mapping

The strict request body accepts exactly one of these shapes:
The empty object clears the mapping. The service establishes line visibility before resolving a supplied target so a foreign line cannot become an id oracle. A rate option must belong to the caller’s organisation. An activity is resolved through the Activities API. Tiered and matrix pricing rules cannot be mapping targets because a day-rate margin read has no quantity or matrix inputs; the API checks both a selected modifier and its matching Standard option. An option priced by steps or by a rate table has no cost to measure a day rate against, so mapping one returns 422 stepped_rate_not_mappable or 422 rate_table_not_mappable. Mappings remain writable after publication. Deleting a mapped rate option clears its pointer through the database foreign key; activity mappings are soft references and can become unavailable in place.

Revert an inherited line

On a draft revision, this restores the base line’s rateKind and its dayRate after applying the revision’s adjustmentPct, then clears rateOverridden. The line must carry derivedFromLineId; otherwise the API returns 422.

Review and rebase inheritance

An inherited non-superseded revision reports needsBaseReview: true when the pinned schedule has a newer current revision that is not recorded in baseAckRevisionId. You can acknowledge that base without changing contract content, or preview and apply a three-way rebase into a new draft.

Preview a rebase

This authenticated read takes an inherited schedule’s current revision as ours, its pinned revision as old base, and the base schedule’s current revision as new base. It writes nothing. The response includes scheduleId, sourceRevisionId, oldBaseRevisionId, targetBaseRevisionId, adjustmentPct, line buckets, and pricing-rule ruleBuckets. Every relevant line appears in exactly one bucket: Line outcomes include the stable lineKey, display fields, old-base, new-base, current, and resulting amounts, the override marker, and a typed outcome. Mappings on retained lines remain the dependant’s mappings. Pricing rules are merged separately at revision and line scope. Their buckets are adopted, retainedOverrides, local, removed, and unchanged. Each entry identifies its scope, line key when applicable, slot, condition, the four old/new/current/result rule values, and the outcome. Rule provenance is used to distinguish a local override from a rule that merely equals its old base. Preview requires an inherited current revision and a current revision on the base schedule. It remains available when that base schedule has since been archived, because existing pins retain access to published history.

Apply a rebase

An organisation admin supplies: The operation repeats the merge under schedule locks and creates a new draft with the resulting lines and pricing rules. It returns 201 with the same buckets plus revision. An existing draft returns 409. An unknown removal key returns 422; duplicate removal keys fail schema validation. A duplicate line code in the proposed result also returns 422 without creating a draft. The supplied target revision is a concurrency token. If the dependant current revision, source identity, or base current revision moved after preview, the request returns 409; preview again rather than applying stale buckets.

Acknowledge a base

Use acknowledgement when the user reviewed a newer base but does not want a new draft or changed rates. The route updates only baseAckRevisionId and returns the refreshed revision summary. It is valid on published contract content because acknowledgement is review metadata, not a contractual edit. Repeating the same acknowledgement is idempotent. A draft base or a revision from a different base schedule returns 422.

Live margins

This read returns every line in sortOrder, then id order, with live analysis: The response also contains revisionId, mappedLines, and totalLines. Nothing from this calculation is persisted. For a rate-option mapping, the selected option identifies the item, source, and location dimension. Cost comes from its live Standard option. The service rejects inactive or expired options and items, rules that require inputs, currency mismatches, missing effective costs, and unit conversions it cannot perform. It normalises supported costs with the domain library and rounds money half-up to cents. For an activity mapping, the service reads up to eight activities concurrently. The activity must be active and its unit must equal the line unit. A missing activity makes only that line unavailable; it does not fail the revision read. For cost_plus, the line’s standard base cost_plus rule takes precedence over the revision rule. The service computes the sell from expected cost and the rule percentage, then computes margin. A zero sell has no defined margin.

Export

One endpoint, one export payload type, two response shapes: Content comes from the revision’s snapshots, never from live lookups, and the payload type carries no cost field — one server-side builder is what makes that a structural guarantee rather than a rendering convention.

Rate imports

Rate imports expose a direct analyse-and-commit API as well as the asynchronous workflows used by the Rates page. The selection-first workflow uploads and parses a spreadsheet, analyses either confirmed blocks or one selected region, then starts extraction before commit. The whole-document survey workflow uploads and surveys the document before starting extraction and commit. Every endpoint requires a signed-in member or admin of the organisation.

Analyse a workbook

Accepts bounded spreadsheet cell data and returns detected rates for review. It does not change the catalogue. The request body is limited to 5 MB, and the route accepts 10 requests per minute. A successful response contains the original file name, up to 20 warnings, up to 2,000 detected rate rows, an unresolvedCategories array, and an unresolvedUnits array. The service can annotate a row with existingRateItemId and existingRateName as a review suggestion. The reviewer can accept that exact item or require a new one; the suggestion is not an irreversible match. unresolvedCategories lists distinct category names that are absent or ambiguous in the catalogue. unresolvedUnits keeps unit problems separate. Each unit entry contains unit, category, reason (unknown or not_allowed), and the category’s allowed units. Map an unresolved category when a row will create an item; correct or choose a permitted unit for an unresolved unit. Mapping the same category does not make an incompatible unit valid. For rows whose document category does not match any catalogue category and whose unit allows classification, the response may include suggestedCategory — a catalogue category name the model considers likely. The suggestion is advisory: the reviewer can accept it, choose a different category, or leave the row for manual resolution. It is absent for rows the model could not classify, for rows already bound to an existing rate item, and for rows whose document category already resolved. For rows in the quarry_materials group with missing bulkingPercent or compactionPercent, the response may also include suggestedMaterialProperties with an optional bulkingPercent, optional compactionPercent, and a required basis that explains the estimate. The service returns these suggestions only when the corresponding ordinary field on the row is absent, and only after verifying the resolved category belongs to the quarry_materials group. A provider failure or unusable AI response returns 502 without changing catalogue data.

Commit reviewed rates

Two endpoints share the same commit implementation. Use POST /api/rates/rate-imports to save imported prices to the Rates Library. Use POST /api/rates/project-rate-imports to save them as project-scoped prices. The body is the same for both routes. The optional projectId field (a GUID) selects which project the prices belong to:
  • POST /api/rates/rate-imports must be called without projectId (or with it set to null). Supplying a projectId on this route returns 422.
  • POST /api/rates/project-rate-imports must be called with a non-null projectId. Omitting projectId on this route returns 422.
This pairing means a client built before project imports existed keeps working: its calls to /rate-imports never carry projectId and reach only the library, while a client that supplies projectId must use the /project-rate-imports route to confirm the intent explicitly. When projectId is present, the project must be accessible to the caller. An unknown or unavailable project returns 404. Imported options are written with that projectId and become project-scoped prices. They are visible in GET /project-rates and do not appear in the Rates Library listing. An item whose existingRateItemId belongs to a different project is refused with 422 and the whole commit is rolled back. After a successful project import, the response includes the standard counts plus a reprice block (the same shape returned by project-rate writes):
reprice is absent on library imports. A project import response also carries replacedRateCount: the number of placeholder prices the commit retired (see Placeholder replacement). The commit contains the original fileName and 1–2,000 reviewed rows. Each row requires name, category, unit, costRate, confidence, sourceSheet, sourceRow, scope, supplierId (required when scope is supplier, must be absent or null when organisation), and locationId (a location UUID, or null for all locations). The locationId key must be present: a row that omits it is refused with 422 location_required, naming the first such row, and nothing is written. Optional row fields are code, sourceItemName, supplier, condition, conversionFactor, existingRateItemId, bulkingPercent, compactionPercent, maxTonnes, maxCubicMetres, banding, suggestedCategory, suggestedMaterialProperties, replacesRateOptionId (project imports only; see Placeholder replacement), and rateGroup (accepted but ignored; classification is derived from the catalogue). Two optional top-level fields link the written options to the imported file:
  • importFileId is the kept copy of the file, as the completed parse or survey poll returned it. It must be a live file of the caller’s organisation, otherwise the commit returns 422 import_file_not_found. Every option the commit writes stores it as source_import_file_id, on both routes.
  • sourceDocumentId is the api-documents project document holding the file. Project imports only: a Rates Library import that sends it returns 422 source_document_requires_project. Every option the commit writes stores it as source_document_id, so the project’s Tender check can read the quote the prices came from.
When either field is absent, an updated option keeps the link it already had. suggestedCategory and suggestedMaterialProperties are extraction metadata accepted by the shared row schema. They do not provide server-side fallback values during commit. The review screen copies accepted suggestions into the ordinary category, bulkingPercent and compactionPercent fields before submission. API clients must also submit their reviewed decisions in those ordinary fields; a suggestion does not bypass catalogue or unit validation. suggestedMaterialProperties carries AI-suggested values for missing bulk-material physics on rows whose resolved or suggested category belongs to the quarry_materials group. It is an object with three fields: The review screen pre-fills suggested percentages only for new quarry_materials items and only where the ordinary field is absent or null. A value explicitly supplied by the file — including zero — takes precedence. At commit, the server validates the selected category’s permitted physical properties and stores the ordinary percentage fields for new items. Imports updating an existing item retain its stored physical properties. The basis remains review metadata and is not stored on the rate item. The top-level uploadType is optional and defaults to price_list; it also accepts project_quote and invoice. existingRateItemId has three distinct states: a UUID binds the option to that exact organisation-owned item, explicit null forces creation, and omission uses the service’s default matching. banding represents a quantity-based price schedule. It contains tierBehaviour (whole_quantity or progressive) and 2–40 bands; each band contains a non-negative minQuantity with at most 3 decimal places, a non-negative unitPrice with at most 2 decimal places, and the document’s 1–40 character label. The first threshold must be 0 and later thresholds must be strictly ascending. When present, the commit uses the first band’s unit price as the option’s representative cost and writes the schedule as a tiered pricing rule. The document labels are review evidence and are not stored on that rule. Extraction assembles banding for both whole-document surveys (including PDFs) and selection-first spreadsheet paths when the reader returns qualifying quantity-band headings. The top-level analysisId field is optional. When it identifies a confirmed-block analysis, the service saves that selection geometry against this document’s shape so it can be offered as a suggestion on future parses of the same layout. A region analysis is not saved as a template. The confirmed-block save runs after the transaction and does not fail the commit if it cannot write. Survey-path commits that carry no analysisId do not trigger a save. The row’s code field becomes the option’s sourceItemCode: the supplier’s own code for that line. It is not stored on the rate item. On an organisation-scope row, the code still helps match the row to its item but is not stored. The optional sourceItemName field becomes the option’s sourceItemName: the supplier’s own name for the item exactly as it appears on their document. On upsert, the document’s value wins only when it is present; an absent sourceItemName in the commit row leaves any previously stored name unchanged rather than clearing it. An organisation-scope row stores no sourceItemName; the importer drops it without an error. Every imported option lands against a real owner. The importer maps reviewed rows to a real scope and, when scope is supplier, a real supplierId (a parties.id in the Global API). locationId is either a real locations.id or null for the all-locations target. It never invents a synthetic owner to hang a price on. Imported options are always fixed basis. The optional categoryMappings array resolves category names to catalogue categories at commit time. Each entry provides a from (the name as it appears in the rows) and a toCategoryId. Rows with no matching entry fall back to exact category-name resolution. Duplicate from entries return 422. An unknown mapped id is a resolution failure and blocks the import only when that row would create an item; the response names every unresolved creating row and nothing is committed. Before any mutation, the service checks rows that resolve to the same item for duplicate price slots. Two rows share a price slot when they agree on the resolved item, scope, supplier (supplierId), location (locationId), and condition. Rows in the same slot would compete to write the same database row; the service returns 422 naming the clashing rows rather than silently keeping one. To resolve the conflict, change the condition or location, point one row at a different rate item, or remove the duplicate row. Rows that differ by condition (for example standard and night) or by location occupy different price slots and are valid. The operation is atomic. For each row:
  • A UUID in existingRateItemId adds the option to that exact item. If the row’s category resolves uniquely (or through categoryMappings), that answer also moves the item to the chosen category; an unknown or ambiguous category falls back to the item’s stored category. The move is refused atomically if it would leave incompatible physics fields or live prices behind. Prices replaced by this import, and multiplier prices that inherit from a replaced Standard price, are assessed using their incoming values rather than their old ones. Explicit null creates a new item even when analysis suggested a match. Sending null for a name already held by an active or inactive item returns 422 — a rate item’s name is its identity and two non-deleted items cannot share one.
  • When existingRateItemId is omitted, the service first tries to match on (scope, supplierId, sourceItemCode, normalised name) by looking through the current options for that owner. If that finds nothing (for example the row is code-less, or no existing option carries that code at that source), the service falls back to a normalised name match against active or inactive items directly. Because the name uniqueness index guarantees at most one non-deleted item per normalised name, both arms always agree when they both find a result.
  • A row that does not reuse an item creates a new active item and an option, so its category must resolve and the imported unit must be permitted by it.
  • Repeated default identities in one request reuse the item created by the first row.
A successful commit returns 201 Created with importedCount, createdItemCount and matchedItemCount.

Placeholder replacement

A placeholder is a current project price typed in by hand (provenance: manual). A supplier quote imported into the project replaces the placeholders on the item it is priced onto. Poll. On a project poll (GET /api/rates/rate-imports/jobs/:jobId?projectId=...), a row that matched nothing by name or by supplier wording is checked against the project estimate’s own items before the AI suggestion. An item with a name similarity of 0.5 or more and a category that allows the row’s unit becomes the suggestion with matchSource: "project-rate". Its confidence is capped at 0.8, so it always needs review. On a project poll, every matched row also carries placeholderRates: the placeholders on the matched item in this estimate. targetRateOptionId (a uuid) on the poll aims one row at a specific project price, for Update from import. It requires projectId; on a library poll it returns 400. The row already matched to that price’s item is chosen, otherwise the row whose name reads most like it (never a row bound to another item by exact name). That row returns replacesRateOptionId. Commit. On POST /api/rates/project-rate-imports, a row priced onto an existing item retires that item’s placeholders in this estimate that have the row’s condition and either no supplier or the row’s supplier, plus the price replacesRateOptionId names. Each is retired as DELETE /project-rates/:optionId retires a price, with a deleted re-price row, in the same transaction. The response carries replacedRateCount.

Whole-document survey import flow

The current Rates page sends every PDF through the whole-document survey flow before any parse request is made. A spreadsheet always uses the selection-first parse and region-analysis flow. Both paths move extraction outside the HTTP request window. All endpoints require a signed-in member or admin.

1. Upload the file

Send fileName (1–300 characters) and an optional contentType (defaults to application/octet-stream). The response contains uploadId, a presigned url, and expiresIn; upload the bytes directly to that URL. The organisation comes from the token and forms the storage key prefix.

2. Start and poll the survey

Send the uploadId and fileName. The POST stores a queued survey and returns 202 { surveyId }; the worker reads and deletes the uploaded object, then analyses its structure. Legacy callers may send bounded sheets with fileName instead of an upload id. The POST returns 422 when no suitable AI provider is configured, 429 after ten requests in one minute, and 502 if the work cannot be queued. Poll until the survey status becomes terminal: A complete response always includes status: "complete" and importFileId (the kept copy of the file to pass to the commit, or null when none was kept), along with the persisted sheets, detected blocks, supplier/location/category candidates, unclassified headings, and warnings. Candidate matches are resolved against the caller’s live catalogue on each complete poll because the worker has no user token. If that lookup is unavailable, candidates remain unresolved and the response includes a warning instead of failing the survey.

3. Start and poll extraction

Job creation accepts surveyId, fileName, 1–20 sheets, and optional vocabulary. Exactly one of the following must be present and non-empty:
  • blocks — up to 60 uniquely identified bounds-only blocks (the legacy path, re-typed by the model).
  • confirmedBlocks — up to 60 uniquely identified confirmed blocks that the canvas has already assigned full column roles. Used exactly as given: no re-typing, no confidence test, and no model call during extraction.
Sending both or neither returns 400. Each confirmed block carries the following fields: headerGroups is valid only for a crosstab. Its row must exist in the sheet and come before firstDataRow. The group meaning must not collide with pivotMeaning or an assigned column role. Every span must stay inside the sheet, spans must not overlap, and together they must cover every pivot column. Violations return 422 with header_group_not_applicable, header_row_out_of_range, meaning_collision, span_out_of_range, span_overlap, or span_gap as applicable. dismissedCells is optional. It lets the caller record priced cells the reviewer has decided are not rates. Each entry carries sheet, row (1-based), column (0-based), and an optional reason. Up to 500 cells may be dismissed. When confirmedBlocks is present, the service validates the selection and dismissals before queuing any block, returning 422 with a machine-readable code when a rule is violated. The request body is limited to 5 MB and the route allows ten requests per minute. It returns 202 { jobId }. The job poll reports queued, running, complete, failed, or cancelled, plus each block’s pending, running, done, failed, or empty state, attempt count, rate count, and any error. rates is withheld until the job is terminal so a partial extraction cannot be mistaken for final output. The terminal response also includes dismissedCells — the canonical list of cells dismissed in this job, in sheet/row/column order. Project-scoped review. Pass ?projectId=<uuid> on the job GET to widen item matching during review. Without projectId, the reviewer sees only Rates Library items as match candidates. With it, the response also suggests items belonging to that project and excludes items that belong to other projects. The projectId query parameter affects only the suggestions on each rate row (existingRateItemId, and the placeholder fields); it does not change the extracted data. Before queuing any blocks, this survey/legacy path validates the confirmed selection against the sheets submitted in the same request. A validation failure returns 422 immediately with a machine-readable code in the response body rather than accepting the job and surfacing the problem later as a failed block. One such code is header_row_missing: a crosstab block must have a headerRow because its pivot labels come from that row; submitting one without a header row is refused at this point rather than after extraction runs. The selection-first flow below has the stronger boundary: it validates against the server-held parse record and does not accept a client-supplied grid. Retry one failed block without repeating its successful siblings:
The retry returns 202 { jobId }; a block not in failed state returns 409.

Optional: settle catalogue records and re-read

This endpoint is shared by the survey and selection-first workflows, but it is not a prerequisite for the first extraction. The current survey client starts an extraction job as soon as the survey completes, with no entity call and no vocabulary. On the review screen, ordinary record creation and mapping settle the affected rows in place. If the reviewer changes what a detected heading represents and chooses Read the rates again, the client resolves those decisions here and starts another extraction with the resulting vocabulary. Send exactly one run identifier — surveyId for a survey or analysisId for a selection analysis — plus an idempotencyKey, the candidates being decided, and the decisions. Each array accepts up to 200 entries. For a create decision, fields depends on the candidate kind:
  • A source requires name. sourceKind is supplier by default and also accepts internal; addressLine1, city, region, and two-letter countryCode are optional.
  • A location requires name and kind (office, yard, depot, branch, quarry, or other); its two-letter countryCode is optional. Its candidate must carry parentCandidateId referencing a source candidate whose decision successfully maps or creates the parent source.
  • A category requires name, parentGroup, and 1–40 allowedUnitCodes.
The response contains resolved and failed entries. Each failed entry is { candidateId, kind, name, code, message }. Two codes ask the person to act: A completed key replays its stored result; a fresh in-flight key returns 409 with Retry-After: 2; a key whose heartbeat is older than 60 seconds is resumed.

4. Commit

Send the reviewed terminal rows to POST /api/rates/rate-imports to save to the Rates Library, or to POST /api/rates/project-rate-imports with projectId to save as project prices. See the commit contract for the full body reference. The complete selected set is still one atomic transaction.

Selection-first flow

The selection-first API parses document structure deterministically, then accepts either confirmed blocks or one plain region for analysis. The current Rates page uses the region arm. Confirmed-block clients can reuse saved geometry from a previous import; the region canvas does not display or apply those suggestions.

Start and poll a parse

The POST accepts fileName and uploadId and returns 202 { parseId }. Poll until the parse status is terminal: A complete response includes:
  • sheets — the parsed grid.
  • importFileId: the kept copy of the file to pass to the commit, or null when none was kept.
  • blocks — the detector’s proposed rate blocks.
  • uncovered — priced cells no proposed block covers.
  • gridUsability — whether the grid is selectable.
  • suggestedSelection — a previously confirmed selection for this document shape, or null if none is stored. When present, it contains selectionId, nullable label, lastUsedAt, nullable sourceScope (organisation or supplier), nullable supplierId (a party UUID, null for organisation scope or when the source was never learned), blocks, and dismissedCells. The two identity fields (sourceScope and supplierId) converge independently. A commit naming multiple suppliers yields sourceScope: null for that template but does not make it permanently ambiguous; permanence begins only when a stored single-supplier ID later collides with a different incoming single-supplier ID. Different non-null file labels permanently clear label. A shared filename may therefore remain after source ambiguity, and a single source may remain after label ambiguity. A confirmed-block client may apply the saved blocks, but must submit them through POST /rate-imports/analyses so validateConfirmedSelection checks them against the current grid. A moved column returns column_out_of_range; a document that gained rows returns uncovered_cell. The current region canvas leaves this field unused because confirmed blocks do not define one rectangular column range.

Start and poll an analysis

The POST accepts parseId and exactly one selection shape:
  • blocks contains 1–60 confirmed blocks and may be accompanied by dismissedCells.
  • region contains one rectangle: sheet, 1-based inclusive firstRow and lastRow, and 0-based inclusive firstColumn and lastColumn.
The service validates either selection against its own parse record and returns 202 { analysisId }. Confirmed blocks that fail coverage or column-range validation return 422 with a machine-readable code. An out-of-bounds region also returns 422; a region over 20,000 cells or 450,000 prompt characters returns 422 with region_too_large. Sending dismissedCells with region, or sending both selection shapes or neither, returns 400. Poll until the analysis status is terminal. A complete response contains the kept blocks, entity candidates, undecided headings, and blocks excluded with their priced-cell counts.

Confirm catalogue records for confirmed blocks

For a confirmed-block analysis, send the candidates and decisions to POST /api/rates/rate-imports/entities using the optional settlement and re-read contract. Name the run with analysisId, not surveyId, and include each undecided heading in unclassifiedIds with its corresponding classification. The current region path skips this endpoint and proceeds directly to job creation; its catalogue settlement happens on the review screen.

Create a job from an analysis

Pass analysisId and parseId to POST /api/rates/rate-imports/jobs. The server reads the grid from the parse record and the selection from the analysis record; the caller sends no grid. A confirmed-block job extracts the kept blocks. A region analysis has already made its single extraction call, so job creation stores those rates in an immediately complete block without queueing another worker. It returns 422 if the stored region or rates cannot be read, or if the analysis found no rates in the selected area.

Commit and save the selection

Pass the reviewed rows to POST /api/rates/rate-imports (Rates Library) or POST /api/rates/project-rate-imports (project prices), with analysisId included in either case. After a successful confirmed-block commit, the service saves the selection against this document’s shape so future parses of the same layout receive a suggestedSelection. Region selections are not saved as templates. The confirmed-block save is per-organisation and keyed by document shape, not by supplier; it runs after the transaction and never fails the commit.

Kept import files

When a parse or survey reads an uploaded file, the file service keeps a copy and api-rates records it. The commit links each option it writes to that record. These two routes let api-estimate’s Tender check find and read the file a price came from without copying it into the project’s documents. Both run with the caller’s own token, so every read is confined to the caller’s organisation. Any authenticated caller with an organisation can use them.

Look up files by option

The body is { optionIds }, 1 to 2,000 rate option ids. The response is { items: [{ importFileId, fileName, contentType, optionIds }] }: one entry per live kept file, sorted by file name, listing which of the requested options were priced from it. Options with no kept file, or whose file is deleted, are left out. An invalid body returns 400.

Read a file’s text

The response is { importFileId, fileName, pages: [{ label, text }], truncated }. The text comes from the newest parse of the file, or the newest survey when there is no parse. It is the content that parse stored, not the original bytes: one line per row, with non-empty cells joined by |. Each page or sheet is one entry, labelled with its sheet name or Sheet N, and empty pages are left out. The text is capped at 300,000 characters and 500 pages. When the cap is reached, the response holds the start of the file and truncated is true. A file that does not exist, belongs to another organisation or has been deleted returns 404. An invalid id returns 400.

Companion option response details

A fixed companion option belongs to its own catalogue item and shares the same scope, supplier and location as its parent option. Its fixed base pricing rule references the parent by companionOfOptionId, allowing an estimator to materialise a supplementary charge such as a call-out fee alongside a primary selection.
Returns the active fixed companion options associated with the rate option identified by :id. The response includes each companion’s item detail, option detail, and a captured pricing envelope. The endpoint is scoped to the caller’s organisation. A 404 is returned when the parent option does not exist or belongs to another organisation. When the parent option is inactive or has a validTo date set, the response returns an empty companions array. The parent is no longer a selectable catalogue option, so no new companions are offered. Companions that have already been materialised onto an estimate are preserved and managed by the estimate service — they are not removed by this endpoint. A companion is included in the response only when:
  • the companion option is active and has no validTo date;
  • the companion’s scope, supplier and location match the parent option’s exactly; and
  • the companion’s supplier and location are both resolvable and active in the Global API.
A companion option whose supplier or location is inactive or missing is excluded rather than returning an error.

Response shape

The pricing.envelope is a point-in-time capture of the companion’s pricing rules at the moment of the request. It is the immutable bundle consumed by the estimate service when materialising a companion charge. location is null when both the parent and companion apply to all locations. The option block in the companions response does not include sourceItemName. That field is display and reconciliation data; the companions block is the pricing contract consumed directly by the estimate service, which rejects any unlisted key.

Day-rate archive and export constraints

Archive responses

Archives the schedule. Archiving is a soft operation: the schedule, all its revisions, lines, mappings, and pricing rules remain readable. The scheduleStatus field on the schedule changes from active to archived. An archived schedule is read-only. Mutation endpoints return 409 Conflict when the archived schedule is their target. An active dependant can still rebase from the archived base’s published history. The endpoint is idempotent: archiving an already-archived schedule returns 204 without error.

Export limits and responses

Generates a client-facing export of a schedule revision. The caller must be an authenticated member of the schedule’s organisation; no admin role is required. The request body selects the output format:
or
XLSX format returns a spreadsheet file (Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) with a Content-Disposition: attachment header. The file is limited to 25 MB after generation. PDF format returns the shared JSON export payload (Content-Type: application/json) consumed by the browser print renderer. The payload includes the schedule and revision identity snapshot, all rate lines with rendered rate text, pricing rules described as human-readable strings, and the revision’s terms text. Both formats set Cache-Control: private, no-store and include all lines in sortOrder ascending, id ascending order. The shared payload is cost-free: it contains contractual rates and rules but never raw cost or margin values.

XLSX workbook structure

The workbook contains two sheets:
  • Day Rates — schedule and revision metadata, then one row per rate line with its code, description, unit, rate or rate text, and a display summary of its rules. Schedule-wide rules, terms, and the document footer follow the line table. The footer reads Generated YYYY-MM-DD · Revision <uuid>. The date is the calendar day in the organisation’s configured time zone (from GET /api/global/organisation-settings → timezone), falling back to Pacific/Auckland when the setting is absent or unreadable.
  • Rules — a lossless flat list of every schedule-wide and line rule, including continuation rows when a rule exceeds Excel’s cell limit.
Text cells are protected against spreadsheet formula injection: values beginning with =, +, -, or @ are prefixed with a single quote. Cell content is capped at Excel’s 32,767-character limit without splitting Unicode surrogate pairs.

Rate-option pricing and write details

Every rate option response includes a pricing object that summarises the effective pricing rules for that option. The service merges the option’s owned rules with rules inherited from its Standard option where applicable.

Response pricing summary

Inline pricing rules on create and update

POST /rate-items/:id/options accepts an optional pricingRules array. The option and its rules are created in one transaction; no version is needed because the option has no rule set yet. PUT /rate-options/:id accepts an optional pricingRules and version pair. When supplied, the option’s owned pricing rules are atomically replaced as part of the same request. Both fields must be present together; sending one without the other returns 422. Any signed-in member or admin who can update the option can also replace its pricing rules. A multiplier option cannot own a base pricing rule. If the option is switching basis from fixed to multiplier and the current owned rules include a base rule, the update is rejected with 422. Clear the base rule with an inline pricingRules replacement in the same request as the basis change. Must-pick options. An option priced by steps (a stepped base) or by a rate table (a matrix base) is must-pick: the estimator chooses a step or row on the line, and the option’s regular cost never applies. A fixed must-pick option therefore carries no costRate. Create one in a single request by sending pricingRules and omitting costRate, or convert an existing option with a PUT that sends both. The guards are: A step is never removed, because an estimate line stores its pick by the step’s key. Mark it retired: true instead. A stepped base cannot carry a minimum or a modifier. A rate import skips any row that would overwrite the cost of an existing must-pick option and reports it with “This rate is priced by steps. Please change it in Rates.” or “This rate is priced by a rate table. Please change it in Rates.” Pricing a must-pick charge with nothing chosen returns 422 with code rate_table_row_not_chosen or step_not_picked and a plain-language message.

Rate-option permissions

Pricing-rule contract details

Pricing rules attach conditional price computation logic to a rate option, a day-rate schedule revision, or a day-rate schedule line. Each rule is keyed by ownerType, ownerId, slot, and condition. Rules owned by a rate option can be written by any signed-in member or admin. Rules owned by a day-rate schedule revision or line require the admin role.

Owner types

Slots and rule kinds

Rule kinds

simple — A single unit price with no banding. Slot: base. tiered — Quantity-banded pricing. Slot: base. Only allowed on rate_option owners. matrix — Two-axis banded pricing. Slot: base. Only allowed on rate_option owners. fixed — A fixed lump-sum amount. Slot: base. cost_plus — A percentage markup on the underlying cost. Slot: base. Not allowed on rate_option owners. stepped: user-named lump-sum steps, one of which the estimator picks on the line. Slot: base. Only allowed on rate_option owners, and cannot be combined with a minimum or a modifier. The option carries no costRate. Each step has a key (the stable identity a line’s pick stores: letters, digits, - and _, starting with a letter or digit, up to 64 characters, unique in the rule), a label (up to 80 characters, distinct among pickable steps ignoring case), an amount (a lump sum of at least 0, to the cent), and optional retired: true. A retired step is no longer offered but still prices a line that already picked it. minimum — Sets or suppresses a minimum charge. Slots: minimum_quantity, minimum_amount. Either value (the minimum as a number) or suppress: true must be present; exactly one must be provided. modifier — Adjusts the effective price. Slots: modifier_additive, modifier_multiplicative.

Versioning

Every rule set has a SHA-256 version computed from the canonical JSON of the sorted rules. All write endpoints require the caller to supply the current version as a write-conflict guard. If the version does not match the stored rules, the request returns 409 Conflict. Retrieve the current version with GET /api/rates/pricing-rules?ownerType=...&ownerId=....

Get rules for an owner

Returns the current rule set for the specified owner. The response includes ownerType, ownerId, version, and rules. Requires an authenticated caller with an organisation. Returns 404 when the owner does not exist or belongs to another organisation.

Replace rules for an owner

Atomically replaces all rules for the specified owner. The request body must include version (the current rule-set SHA-256) and rules (the full replacement rule list). A rate-option owner requires a signed-in member or admin. A day-rate revision or line owner requires the admin role.

Replace rules for multiple owners

Atomically replaces rule sets for up to 500 owners in a single request. The body is an array of objects, each with ownerType, ownerId, version, and rules. No owner may appear more than once in the batch. Owners are locked in ascending ownerId order to prevent deadlocks. A batch is held to its strictest owner: rate-option owners only need a signed-in member or admin, and any day-rate owner requires the admin role. The operation is all-or-nothing: a version mismatch or validation failure for any entry rolls back all writes.

Revalidate rules

Validates a rule set against domain constraints without writing. Accepts either a stored owner reference (ownerType + ownerId) or a candidate rule list (ownerType + rules). Returns { valid: boolean, errors: [...], version?: string }. When ownerId is supplied, version contains the current stored version.

List condition tokens

Returns all distinct condition tokens in use across rate options and pricing rules for the caller’s organisation. The response has the form { tokens: string[] } with tokens sorted alphabetically.

Condition re-key response details

A condition token is shared across rate options and the pricing rules and day-rate schedule loadings that reference those options. Renaming a token requires migrating every dependent record atomically. The condition re-key endpoint handles this migration.

Two-step flow

The migration uses a dry-run / commit pattern to prevent partial writes when the preview becomes stale between steps. Step 1 — dry run
Request body:
The response describes what the migration will change: Step 2 — commit
The planHash must match the hash from the dry run. If the plan has changed since the dry run (because another writer modified the dependent records), the endpoint returns 409 Conflict with the message Condition re-key preview is stale; run the dry run again.

Collisions

A collision blocks the commit. Collisions are reported in the dry-run response before any write is attempted.

Condition token format

Condition tokens must match ^[a-z0-9]+([ _-][a-z0-9]+)*$ and be at most 40 characters. The endpoint normalises the input: it trims whitespace and lower-cases the token before validation.

Authentication

Requires a signed-in member or admin. Returns 403 for a service token. Returns 404 when the rate option is not live, does not exist, or belongs to another organisation.

Day rate schedule imports

The same two-step shape for a client’s Schedule of Rates. An imported schedule is always client_defined: its lines are the client’s contractual text, usable before any of them is mapped to an internal rate item.