← Back to Home

API Documentation

Connect your own software to Aquaticus. Read and update projects, clients, estimates, inventory and pricing profiles with an API key created in the app.

Last updated: September 12, 2026

Getting Started

The Aquaticus API is a JSON REST API served at https://api.aquaticus.tech. It is the same API the Aquaticus app uses, so everything you can see in the app you can also read — and, with the right permission, change — from your own code.

  1. Create an API key. In the app open Settings → API Keys and press Create API key. Give it a name and tick the sections it may access. Only the account owner or an Admin can do this.
  2. Copy the key. It is shown exactly once, right after creation. Aquaticus stores only a hash of it, so if you lose it you have to create a new one.
  3. Send it as a bearer token on every request.

Example

curl https://api.aquaticus.tech/projects \
  -H "Authorization: Bearer aq_YOUR_KEY_HERE"

Response

[
  {
    "id": 41,
    "name": "Henderson Residence",
    "clientId": 7,
    "variables": { "shape": "rectangle", "length": 32.0, "width": 16.0 },
    "createdAt": 1757203200000,
    "lastUpdatedAt": 1757635200000,
    "tags": ["gunite", "2026"],
    "isInProgress": true
  }
]

Tip: Start with a key that only has View permissions and add Edit or Delete per section once your integration needs them. A key can be deleted at any time from Settings → API Keys; every request made with it fails from that moment on.

Authentication & API Keys

Every endpoint in this reference requires an API key in the Authorization header:

Authorization: Bearer aq_AbCdEfGh0123456789AbCdEfGh0123456789AbCd

A key is the string aq_ followed by 40 letters and digits. The app lists each key by its first characters (for example aq_AbCdEfGh) so you can tell them apart; that prefix on its own cannot authenticate.

Whose data a key sees

A key belongs to your company account, not to the person who created it. Requests made with it act as the account owner: they see every project, client, estimate and inventory item of the company, and anything they create is owned by the company. Deleting the admin who created a key does not revoke the key — delete the key itself.

The account owner must have a verified email address and an approved Aquaticus account, exactly as for using the app. Otherwise every request is refused with 403 EmailNotVerified or 403 AlphaAccessRequired.

Managing keys

  • Keys are created, listed and deleted in the app under Settings → API Keys, by the account owner or a member with the Admin role.
  • An account can hold up to 50 keys. Names are 1–100 characters.
  • The app shows when each key was created, by whom, and when it was last used (updated at most once a minute).
  • Key management itself is not available through the API: a key cannot list, create or delete keys.

Authentication failures

StatuserrorMeaning
401UnauthorizedNo bearer token, or the key is unknown (mistyped, or deleted).
403ApiKeyPermissionDeniedThe key is valid but lacks the permission this request needs. The description names the missing permission, e.g. This API key does not have 'delete' permission for 'Clients'.
403ApiKeyEndpointForbiddenThe endpoint is outside every section a key can be granted (account settings, team members, sessions, API keys, the AI assistant). Use the app instead.
403EmailNotVerified / AlphaAccessRequiredThe account behind the key is not yet verified or approved.

Permissions

When you create a key you choose, per section, whether it may View, Edit and Delete. The section is decided by the URL and the action by the HTTP method, so you can tell from this reference alone what a request needs — every endpoint below is labelled with it.

Sections

SectionCoversPaths
Projects and Model Projects, their pool model (variables), project history, project tags, calculations, the formula JavaScript, the scratch model (/tools) and spa presets. /projects, /project/…, /calculate…, /formulas/js, /formulas/jscode, /tags, /tag/…, /tools, /spa-presets…
Clients The people and companies projects are built for. /clients, /client/…
Tax and Markup Profiles Tax profiles and markup presets used by estimates. /tax-profiles…, /markup-presets…
Estimates and Templates Estimates, change orders, templates, sections, line items, estimate tags and formula validation. /estimates…, /templates…, /sections/…, /line-items/…, /estimate-tags…, /formulas/validate, /formulas/variables
Inventory Inventory items, categories, notes, vendor prices and suppliers. /inventory…, /suppliers…

Actions

HTTP methodPermission needed
GET, HEADView
POST, PUT, PATCHEdit — including duplicating, reordering, rolling back history, setting a default, refreshing prices and CSV import.
DELETEDelete — any DELETE request, also on sub-resources such as a section, a note or a vendor price.
POST /calculate, POST /formulas/validateView — these two only compute and never write, so a read-only key can use them.

Rules applied when a key is created

  • Edit or Delete includes View. A section you may change you may also read. The app ticks View for you.
  • Estimates include View on Tax and Markup Profiles and on Inventory. An estimate references tax profiles, markup presets and inventory items, so a key that can read estimates can also read those — never write them unless you grant that separately.
  • A key must have at least one permission.

Example: a key with Estimates: View, Edit and nothing else can list and update estimates, add line items, list tax profiles, markup presets and inventory items — but gets 403 ApiKeyPermissionDenied on DELETE /estimates/{id}, on POST /inventory and on GET /projects.

Conventions & Errors

Requests and responses

  • Base URL: https://api.aquaticus.tech. There is no version prefix.
  • JSON everywhere. Send Content-Type: application/json with a body. Responses are JSON unless stated (PDF and CSV downloads, the formula JavaScript).
  • Identifiers. Projects, clients and history entries use integer ids. Estimates, templates, sections, line items, tags, tax profiles, markup presets, inventory items, categories, prices and suppliers use UUID strings.
  • Timestamps (createdAt, updatedAt, lastUpdatedAt, validUntil, …) are Unix epoch milliseconds.
  • Omitted defaults. Responses leave out fields that hold their default value — a null, an empty string or list, 0, or a default boolean. Treat a missing field as its documented default rather than as an error. Objects whose fields have no defaults (clients, history entries) always carry every field.
  • Unknown fields in a body are rejected with 400 BadJsonBody. Send only the fields listed for the endpoint.
  • Body size is capped at 10 MB (413 PayloadTooLarge).
  • Rates and percentages in tax profiles, markup presets and line-item overrides are fractions: 0.0875 means 8.75%.
  • Success codes. Most reads and updates answer 200. Creates in the estimate and inventory areas answer 201; creating a project or client answers 200. Deletes answer 204 (no body), except deleting a project or a client (200).
  • Pagination. Every list endpoint accepts ?limit= (how many to return, at least 1) and ?offset= (how many to skip, default 0), independently of each other. Without limit a list returns everything, except GET /estimates (50) and GET /inventory (100). A page shorter than limit is the last one.

Error shape

Every error carries a stable error code and a human-readable description:

HTTP/1.1 404 Not Found
{
  "error": "NotFound",
  "description": "Project 42 not found"
}

The full list of codes is in the Error Reference. A malformed id in a URL (for example a UUID that does not parse) currently answers 500 rather than 400; validate ids on your side.

Projects and Model

A project is one job: a name, a client, tags and the model — the variables object describing the pool (shape, dimensions, depth profile, structure, steel, hydraulics, features and spa). The model is what the calculators run on and what estimates snapshot. Its full field reference is in The Pool Model below.

Permission section: Projects and Model.

EndpointPermissionDescription
GET/projectsViewList all projects (?limit=, ?offset=)
GET/projects/{clientId}ViewList a client's projects (?limit=, ?offset=)
GET/project/{id}ViewGet one project
POST/projectEditCreate a project
PATCH/project/{id}EditUpdate name, model, tags or status
POST/project/{id}/duplicateEditCopy a project
DELETE/project/{id}DeleteDelete a project and its history
GET/project/{id}/export-pdfViewProject report as PDF
GET/project/{id}/historyViewChange history (?limit=, ?offset=)
POST/project/{id}/history/{historyId}/rollbackEditRestore an earlier version
GET/calculate/{projectId}ViewRun the calculators on a saved project
POST/calculateViewRun the calculators on an ad-hoc model
GET/formulas/jsViewCalculator JavaScript + formula descriptions
GET/formulas/jscodeViewCalculator JavaScript only
GET/tagsViewTag vocabulary (?limit=, ?offset=)
POST/tagEditAdd a tag to the vocabulary
DELETE/tag/{tag}DeleteRemove a tag from the vocabulary
GET/toolsViewThe account's scratch model
POST/toolsEditSave the scratch model
GET/spa-presetsViewSaved spa configurations (?limit=, ?offset=)
POST/spa-presetsEditSave a spa preset
GET/spa-presets/{id}ViewGet a spa preset
PUT/spa-presets/{id}EditUpdate a spa preset
DELETE/spa-presets/{id}DeleteDelete a spa preset

The project object

FieldTypeDescription
idintegerProject id.
namestring2–100 characters.
clientIdintegerThe client the project belongs to. Cannot be changed after creation.
variablesobjectThe pool model. See The Pool Model. Fields left unset are omitted; an empty model is {}.
tagsstring[]Free-form labels, each up to 30 characters. Default [].
isInProgressbooleantrue while the job is active. Default true.
createdAt, lastUpdatedAtintegerEpoch milliseconds. lastUpdatedAt moves on every update and rollback.
GET/projectsProjects · View

All projects of the account, as an array of project objects, optionally paged with ?limit= and ?offset=. GET /projects/{clientId} returns the same shape filtered to one client; an unknown client yields [].

POST/projectProjects · Edit

Create a project for an existing client. Answers 200 with the created project and writes a created entry to its history.

FieldTypeNotes
namerequiredstring2–100 characters.
clientIdrequiredintegerMust exist, else 404 NotFound.
variablesrequiredobjectThe model; {} is allowed. When shape is omitted or a rectangle, oval or kidney, missing length/width default to 30 × 15 ft; a circle gets radius 15 ft.
tagsstring[]Optional, each ≤ 30 characters.
POST /project
{
  "name": "Henderson Residence",
  "clientId": 7,
  "tags": ["gunite", "2026"],
  "variables": {
    "shape": "rectangle",
    "length": 32,
    "width": 16,
    "depthProfileType": "standard",
    "shallowDepth": 3.5,
    "deepDepth": 8,
    "shallowEndLength": 10,
    "deepEndLength": 8
  }
}
PATCH/project/{id}Projects · Edit

Partial update. Send only the fields to change; each one you send replaces the stored value (the model and the tag list are replaced whole, not merged). Every call bumps lastUpdatedAt and writes an updated history entry.

FieldTypeNotes
namestring2–100 characters.
variablesobjectThe complete new model.
tagsstring[]The complete new tag list.
isInProgressboolean
DELETE/project/{id}Projects · Delete

Removes the project and its whole history. Answers 200 {"success": true}, or 404 when there is no such project. Estimates bound to the project keep their snapshot of the model.

GET/project/{id}/export-pdfProjects · View

The project report as application/pdf (an attachment named aquaticus_project_<name>.pdf). The calculators run server-side, so the report always reflects the stored model.

QueryDefaultNotes
sectionsallComma-separated subset of project, pool, depth, spa, features, dig, concrete, rebar, hydraulics.
drawingsplan,profileplan needs the pool section, profile needs depth.
detailbothComma-separated subset of rebarDetail, cages. When present, only the listed blocks are printed.
unitsfeet-inchesdecimal prints lengths as decimal feet.
GET/project/{id}/historyProjects · View

Every change to the project, newest first (page with ?limit= and ?offset=). Each entry has id, projectId, userId, userEmail, userName, action (created, updated or rollback), the model before and after (previousVariables, newVariables), previousName/newName, previousTags/newTags and createdAt. Changes made with an API key are attributed to the account owner.

POST /project/{id}/history/{historyId}/rollback (Edit) restores the state before that entry (or the initial state for a created entry), appends a rollback entry and answers with the project. 400 InvalidHistoryEntry when the entry belongs to another project.

POST/calculateProjects · View

Runs every calculator on the model in the body and returns the results as one flat JSON object; nothing is stored. GET /calculate/{projectId} does the same for a saved project. Unlike saving a project, calculating validates the shape strictly (400 InvalidParameter when, say, a rectangle has no length).

The result keys are defined by the formula set and include, among others, perimeter, floorArea, wallArea, totalSurfaceArea, averageDepth, volume, maxGallons, digVolume, gravelTons, concreteFloorYards, concreteWallAndCornersYards, concreteYards, rebarFeet, gpmMainFilterLoop, numberOfSkimmers, numberOfReturns, pipeSizeMainFiltrationSuction and pipeSizeMainFiltrationReturn. GET /formulas/js returns the calculator source together with a description object documenting each output (name, formula, inputs, unit, category, example).

POST /calculate
{
  "shape": "rectangle", "length": 32, "width": 16,
  "depthProfileType": "standard", "shallowDepth": 3.5, "deepDepth": 8,
  "shallowEndLength": 10, "deepEndLength": 8,
  "wallThickness": 8, "floorThickness": 6, "turnoverHours": 8
}

200 OK
{
  "perimeter": 96,
  "floorArea": 561.4,
  "volume": 2718.7,
  "maxGallons": 20336,
  "concreteYards": 31.3,
  "gpmMainFilterLoop": 42.4,
  "numberOfSkimmers": 2,
  "rebarFeet": { "totalFeet": 3241.0, "bar3": 0, "bar4": 3241.0, "bar5": 0, "bar6": 0 },
  "…": "…"
}
GET/tagsProjects · View

The reusable tag vocabulary as an array of strings. POST /tag adds one — the body is the tag as plain text (Content-Type: text/plain), and re-adding an existing tag is a no-op. DELETE /tag/{tag} removes it from the vocabulary without touching projects that already carry it. Both answer {"status": "ok"}.

GET/spa-presetsProjects · View

Saved spa configurations, sorted by name. A preset is { id, userId, name, notes, config, createdAt, updatedAt } where config is a spa object. Create with POST /spa-presets (name 2–255 characters, optional notes, config with at least depth) → 201; update with PUT /spa-presets/{id} (any subset of name, notes, config — config is replaced whole); remove with DELETE /spa-presets/{id} → 204. Projects keep a copy of the spa, so deleting a preset never changes a project.

The Pool Model (ProjectVariables)

The variables object of a project — also the body of POST /calculate and POST /tools, and the projectVariableSnapshot of an estimate. Every field is optional; send only what you know. Fields you leave out are absent from responses too.

Units: thicknesses, beamHeight, uBarSpacing, a feature's heightFromBeam and thickness, and a spa's raiseHeight are in inches. Every other length is in feet. Percentages are whole numbers (20 = 20%). The calculators convert for you; never pre-convert.

Validation: no numeric field may be negative. Saving a project checks that much and the spa rules below; calculating additionally requires the dimensions that match shape (see Shape and dimensions).

Shape and dimensions

FieldTypeUnitDescription
hasPoolbooleanDefault true. false means a spa-only project; then spa is required.
shapestringrectangle, circle, oval, kidney or custom.
length, widthnumberftRequired by the calculators for rectangle, oval and kidney; must be absent for a circle.
radiusnumberftCircle only; must be absent for other shapes.
kidneyWidthA, kidneyWidthB, kidneyWidthCnumberftKidney head, waist and foot widths.
customShapeobjectFor shape: "custom": { name, nodes, splitLines, depthRegions } with at least 3 nodes of { x, y, smoothness } (feet; smoothness 0–1 rounds the corner). Area, perimeter and length are derived from the outline.
spaobjectSee Spa.

Depth profile

FieldTypeUnitDescription
depthProfileTypestringstandard (shallow → slope → deep), vShaped (shallow edges, deep centre) or uniform (flat).
shallowDepth, deepDepthnumberftStandard profile.
shallowEndLength, deepEndLengthnumberftFlat runs before and after the slope.
secondSlopeBreakLength, secondSlopeBreakDepthnumberftOptional second break in the slope; the length is measured from where the slope starts.
vLeftEdgeDepth, vLeftEdgeLength, vCenterDepth, vCenterLength, vRightEdgeDepth, vRightEdgeLengthnumberftV-shaped profile.
uniformDepthnumberftUniform profile.
customAverageDepthnumberftOverrides the average depth derived from the profile.

Structure and concrete

FieldTypeUnitCalculator defaultDescription
wallThicknessnumberin8
floorThicknessnumberin6
beamHeightnumberin6Bond beam height.
beamThicknessnumberin12Bond beam thickness.
finishThicknessnumberin0.5Interior finish; reduces water volume.
gravelThicknessnumberin6Gravel bed under the floor.
concreteWastePercentnumber%20
overdignumberft0Excavation added on each side of the footprint.
exteriorSurfacesobject[]Exposed exterior wall faces to finish: { label, length (ft), exposedHeight (ft), includeCap }.

Steel

FieldTypeUnitCalculator defaultDescription
steelPatternWall, steelPatternFloorstringMust contain a positive spacing in inches, e.g. "#4 @ 12\" OC" or "12".
steelWastePercentnumber%20
uBarSizenumberbar #44 = #4 bar.
uBarSpacingnumberin12On centre.
topBarSizenumberbar #4
topBarCountnumbercountcomputed
wallCage1Pattern, wallCage1HorizontalBar, wallCage1VerticalBarstring, number, numbergrid, bar #, bar #"12x12", 4, 4First wall cage. wallCage2… and wallCage3… add cages.
floorCage1Pattern, floorCage1HorizontalBar, floorCage1VerticalBarstring, number, numbergrid, bar #, bar #"12x12", 4, 4First floor cage. floorCage2… and floorCage3… add cages.

Hydraulics

FieldTypeUnitCalculator defaultDescription
turnoverHoursnumberh8Time for one full filtration turnover.
minVelocitySuction, maxVelocitySuctionnumberft/s3, 5
minVelocityReturn, maxVelocityReturnnumberft/s6, 8
pipeRunLengthnumberftEquivalent run from the pool to the equipment pad.
maxFlowPerSkimmernumberGPM55
maxAreaPerSkimmernumbersq ft500
maxFlowPerReturnnumberGPM20
perimeterPerReturnnumberft20
customPipeSizeMainFiltrationSuction, customPipeSizeMainFiltrationReturnnumberincomputedOverride the computed main line sizes.

Features

features is an array of steps, benches, sun shelves and ledges:

FieldTypeUnitDescription
typerequiredstringstep, bench, sunshelf or ledge.
lengthrequirednumberftSide to side; must be > 0.
widthrequirednumberftFront to back (tread depth for steps); > 0.
heightFromBeamrequirednumberinDrop from the top of the bond beam to the feature surface; > 0.
depthAtLocationrequirednumberftWater depth where the feature sits; > 0.
countintegerDefault 1; number of risers for steps.
locationstringshallowEnd (default), center, deepEnd, leftWall, rightWall, custom.
includeSteelbooleanDefault: yes for everything but a ledge.
steelPattern, steelBarSizestring, integergrid, bar #e.g. "12x12", 4.
thicknessnumberinLedges only; default 6.
stepConfigurationstringSteps only: straight, corner90, weddingCake or fullWidth.
remainingTreadDepthnumberftTread depth of steps 2+ when it differs from the top step.

Spa

spa describes an attached or freestanding spa. Structural, steel and hydraulic fields left null inherit the pool's value (or the calculator default for a spa-only project). Only depth is required.

FieldTypeUnitDescription
attachmentstringinterior (default; needs a pool) or freestanding.
shapestringDefault circle; same values and dimension rules as the pool (radius for a circle, length/width otherwise, customShape for custom).
depthrequirednumberft> 0.
raiseHeightnumberinHow far an interior spa rises above the pool beam.
commonWallLength, commonWallThicknessnumberft, inWall shared with the pool. The length cannot exceed the spa perimeter.
spillwayLengthnumberftAt most commonWallLength.
insidePoolPercentnumber%0–100; how much of the spa sits inside the pool footprint (default 100).
poolDepthAtSpanumberftDefaults to the pool's average depth.
wallThickness, floorThickness, beamHeight, beamThickness, finishThicknessnumberinInherit the pool's when omitted.
concreteWastePercent, steelWastePercentnumber%Inherit.
wallCage1…3, floorCage1…3, commonWallCagePattern, commonWallCageHorizontalBar, commonWallCageVerticalBarstring / numbergrid, bar #Same layout as the pool's cages; inherit when omitted.
equipmentstringdedicated (default) or shared with the pool (needs a pool).
turnoverHoursnumberhDefault 0.5 for a spa.
minVelocitySuction, maxVelocitySuction, minVelocityReturn, maxVelocityReturnnumberft/sInherit.
jetCountinteger≥ 0.
gpmPerJetnumberGPMDefault 12.
featuresobject[]Benches and steps inside the spa; same shape as pool features.

Complete example

{
  "hasPool": true,
  "shape": "rectangle",
  "length": 34,
  "width": 16,

  "depthProfileType": "standard",
  "shallowDepth": 3.5,
  "deepDepth": 8.0,
  "shallowEndLength": 10,
  "deepEndLength": 8,

  "wallThickness": 8,
  "floorThickness": 6,
  "beamHeight": 6,
  "beamThickness": 12,
  "gravelThickness": 6,
  "finishThickness": 0.5,
  "concreteWastePercent": 20,
  "overdig": 2,

  "steelPatternWall": "#4 @ 12\" OC",
  "steelPatternFloor": "#4 @ 12\" OC",
  "steelWastePercent": 20,
  "uBarSize": 4,
  "uBarSpacing": 12,
  "wallCage1Pattern": "12x12",
  "wallCage1HorizontalBar": 4,
  "wallCage1VerticalBar": 4,
  "floorCage1Pattern": "12x12",
  "floorCage1HorizontalBar": 4,
  "floorCage1VerticalBar": 4,

  "turnoverHours": 8,
  "pipeRunLength": 85,

  "features": [
    {
      "type": "step",
      "length": 8,
      "width": 1.5,
      "heightFromBeam": 12,
      "count": 3,
      "location": "shallowEnd",
      "depthAtLocation": 3.5,
      "stepConfiguration": "straight"
    },
    {
      "type": "bench",
      "length": 10,
      "width": 1.5,
      "heightFromBeam": 20,
      "location": "leftWall",
      "depthAtLocation": 5.0
    }
  ],

  "exteriorSurfaces": [
    { "label": "Raised back wall", "length": 16, "exposedHeight": 2.5, "includeCap": true }
  ],

  "spa": {
    "attachment": "interior",
    "shape": "circle",
    "radius": 4,
    "depth": 3.5,
    "raiseHeight": 12,
    "commonWallLength": 6,
    "spillwayLength": 2,
    "equipment": "shared",
    "jetCount": 6,
    "gpmPerJet": 12
  }
}

Clients

The people and companies projects are built for. Permission section: Clients.

EndpointPermissionDescription
GET/clientsViewList clients (?search=, ?limit=, ?offset=)
GET/client/{id}ViewGet one client
POST/clientEditCreate a client
PATCH/client/{id}EditUpdate a client
DELETE/client/{id}DeleteDelete a client and all their projects

The client object

A client always carries all six fields; unset ones are null.

{
  "id": 7,
  "name": "Henderson, James",
  "email": "j.henderson@example.com",
  "phoneNumber": "+15551234567",
  "address": "418 Palm Ridge Dr, Scottsdale AZ",
  "notes": null
}
GET/clientsClients · View

All clients sorted by name. ?search= filters by name or email (case-insensitive, substring). Page with ?limit= (how many to return, at least 1) and ?offset= (how many to skip, default 0); either works on its own. A page shorter than limit is the last one.

GET /clients?search=henderson&limit=25&offset=50
POST/clientClients · Edit

Answers 200 with the created client.

FieldTypeNotes
namerequiredstring2–40 characters.
emailstringMust be a valid address when given (400 InvalidParameter otherwise).
phoneNumberstring7–15 digits, optional leading +; spaces and dashes are ignored.
addressstring2–500 characters.
notesstringFree text.
PATCH/client/{id}Clients · Edit

Partial update with one quirk: all five keys must be present — send null for the ones you are not changing, or the request is rejected with 400 BadJsonBody. A null leaves the field alone; an empty string clears phoneNumber, address or notes. The same validation as on create applies. 409 EmailExists when another client already uses the email.

PATCH /client/7
{
  "name": "Henderson, James R.",
  "email": null,
  "phoneNumber": "+15559876543",
  "address": null,
  "notes": null
}
DELETE/client/{id}Clients · Delete

Answers 200 with an empty body. Every project of the client, and each project's history, is deleted with it. Check GET /projects/{clientId} first if that matters to you.

Tax and Markup Profiles

Estimates price line items with a markup preset (a material rate and a labor rate) and a tax profile (material and labor tax rates with on/off switches). Each account has one default of each, used wherever an estimate or section does not override it. Permission section: Tax and Markup Profiles. Keys with Estimates · View can read both lists as well.

EndpointPermissionDescription
GET/tax-profilesViewList tax profiles (?limit=, ?offset=)
POST/tax-profilesEditCreate a tax profile
GET/tax-profiles/{id}ViewGet one
PUT/tax-profiles/{id}EditUpdate (partial)
PUT/tax-profiles/{id}/defaultEditMake it the default
DELETE/tax-profiles/{id}DeleteDelete (not the default)
GET/markup-presetsViewList markup presets (?limit=, ?offset=)
POST/markup-presetsEditCreate a markup preset
GET/markup-presets/{id}ViewGet one
PUT/markup-presets/{id}EditUpdate (partial)
PUT/markup-presets/{id}/defaultEditMake it the default
DELETE/markup-presets/{id}DeleteDelete (not the default)

Tax profile

FieldTypeNotes
iduuid
namerequiredstring2–255 characters.
staterequiredstringUp to 120 characters; may be empty.
localitystringUp to 120 characters. Default "".
materialTaxRaterequirednumberFraction, e.g. 0.0875.
laborTaxRatenumberFraction. Default 0.
taxMaterialsbooleanDefault true. When false material lines are not taxed even if a rate is set.
taxLaborbooleanDefault false.
isDefaultbooleanOn create only; the first profile of an account becomes the default automatically. Change later with PUT …/default.
notesstringUp to 10,000 characters.
createdAt, updatedAtintegerEpoch milliseconds.
POST /tax-profiles
{
  "name": "CA – Los Angeles",
  "state": "CA",
  "locality": "Los Angeles County",
  "materialTaxRate": 0.0875,
  "laborTaxRate": 0.0,
  "taxMaterials": true,
  "taxLabor": false
}

201 Created
{
  "id": "5e7c9a02-4b18-49d3-bb61-7f2c8e105d3a",
  "userId": 42,
  "name": "CA – Los Angeles",
  "state": "CA",
  "locality": "Los Angeles County",
  "materialTaxRate": 0.0875,
  "isDefault": true,
  "createdAt": 1757500800000,
  "updatedAt": 1757500800000
}

Updates (PUT /tax-profiles/{id}) accept any subset of name, state, locality, materialTaxRate, laborTaxRate, taxMaterials, taxLabor and notes. Deleting the default profile is refused with 400 BadRequest — make another one the default first.

Markup preset

FieldTypeNotes
iduuid
namerequiredstring2–255 characters.
materialRaterequirednumberMarkup on material lines as a fraction: 0.30 = 30%.
laborRaterequirednumberMarkup on labor lines as a fraction.
colorstringOptional swatch for the app, up to 20 characters (e.g. "#2E86AB").
isDefaultbooleanSame rules as tax profiles.
createdAt, updatedAtintegerEpoch milliseconds.

Updates accept any subset of name, materialRate, laborRate and color. Deleting the default preset is refused with 400 BadRequest.

Estimates and Templates

An estimate is a tree of sections holding line items. Each line item selects an inventory item with a small formula (for example {Excavation Labor} * digVolume) that also yields the quantity, so prices and quantities follow the inventory and the bound project's model. A template is a reusable section tree that can be dropped into any estimate. A change order is a full copy of an estimate linked to the original, so a chain of revisions can be compared.

Permission section: Estimates and Templates. View on it also grants View on Tax and Markup Profiles and on Inventory.

EndpointPermissionDescription
GET/estimatesViewList estimates (filters below)
POST/estimatesEditCreate an estimate
GET/estimates/{id}ViewFull estimate with sections and line items
PUT/estimates/{id}EditUpdate name, client, notes, validity, tags, preferred vendors
DELETE/estimates/{id}DeleteDelete an estimate
PATCH/estimates/{id}/statusEditSet DRAFT, SENT, ACCEPTED or DECLINED
POST/estimates/{id}/duplicateEditCopy as a new, unrelated draft
POST/estimates/{id}/change-orderEditCopy as the next revision of the chain
GET/estimates/{id}/chainViewThe original and all its change orders
GET/estimates/{id}/changesViewDiff against the previous revision
POST/estimates/{id}/bind-projectEditAttach a project and snapshot its model
POST/estimates/{id}/unbind-projectEditDetach the project
POST/estimates/{id}/refresh-pricesEditRe-price a draft from current inventory
GET/estimates/{id}/totalsViewSubtotals, tax, total and margins
GET/estimates/{id}/export-pdfViewPDF (?view=, ?scope=)
POST/estimates/{estimateId}/sectionsEditAdd a top-level section
POST/estimates/{estimateId}/sections/from-template/{templateId}EditInstantiate a template (?parentSectionId=)
GET/templatesViewList templates (?search=, ?limit=, ?offset=)
POST/templatesEditCreate an empty template
GET/templates/{id}ViewTemplate with its whole tree
PUT/templates/{id}EditRename, describe, re-tag
DELETE/templates/{id}DeleteDelete a template
POST/templates/{id}/duplicateEditCopy a template
POST/templates/from-estimate/{estimateId}EditSave an estimate as a template
GET/sections/{id}ViewA section with its subtree
PUT/sections/{id}EditRename, describe, reorder, re-parent
PUT/sections/{id}/overridesEditSet or clear markup/tax overrides
DELETE/sections/{id}DeleteDelete a section and everything in it
POST/sections/{id}/sectionsEditAdd a child section
POST/sections/{id}/sections/reorderEditReorder child sections
POST/sections/{id}/itemsEditAdd a line item
POST/sections/{id}/items/reorderEditReorder line items
PUT/line-items/{id}EditUpdate a line item
PUT/line-items/{id}/overridesEditSet or clear its markup/tax overrides
DELETE/line-items/{id}DeleteDelete a line item
GET/estimate-tagsViewTags for estimates, templates and inventory (?limit=, ?offset=)
POST/estimate-tagsEditCreate (or fetch by name) a tag
GET/estimate-tags/{id}/usageViewWhere a tag is used
PUT/estimate-tags/{id}EditRename or recolour
DELETE/estimate-tags/{id}DeleteDelete a tag
POST/formulas/validateViewCheck a line-item formula
GET/formulas/variablesViewModel variables usable in formulas

The estimate object

FieldTypeDescription
iduuid
namestring2–255 characters.
statusstringDRAFT (default), SENT, ACCEPTED, DECLINED. Leaving DRAFT freezes every line item's cost; only drafts can be re-priced or re-bound to a project.
clientId, clientNameinteger, stringThe client, either set directly or taken from the bound project.
projectId, projectNameinteger, stringThe bound project, if any.
projectVariableSnapshotobjectCopy of the project's model taken by bind-project; formulas evaluate against it.
preferredVendorIds, preferredVendorNamesuuid[], string[]Suppliers whose prices win, in priority order.
notesstringUp to 10,000 characters.
validUntilintegerEpoch milliseconds, optional.
originalEstimateIduuidAbsent on an original; the chain root's id on a change order.
rootSectionIduuidThe invisible root section that carries the estimate-level overrides. Change them with PUT /sections/{rootSectionId}/overrides.
rootMarkupPresetId, rootMarkupPresetName, rootMarkupPresetMaterialRate, rootMarkupPresetLaborRatemixedThe estimate-level markup preset, if set.
rootTaxProfileId, rootTaxProfileNameuuid, stringThe estimate-level tax profile, if set; otherwise the account default applies.
tagsobject[]Each { id, name, color, createdAt }.
totalCostnumberOnly in GET /estimates: the sum of quantity × unit cost over all line items.
sectionsobject[]Only in GET /estimates/{id}: the top-level sections, each with children and lineItems.
creatorName, creatorEmailstringWho created it.
createdAt, updatedAtintegerEpoch milliseconds.
GET/estimatesEstimates · View

Newest-updated first. Query: status (one of the four statuses), search (substring of the name), tagId, limit (default 50) and offset (default 0). Items carry totalCost but no sections.

POST/estimatesEstimates · Edit

Creates a DRAFT with its root section. Answers 201 with the estimate. To price it against a project, follow up with bind-project.

FieldTypeNotes
namerequiredstring2–255 characters.
taxProfileIdrequireduuidThe estimate-level tax profile.
markupPresetIduuidThe estimate-level markup preset. Without one the account's default markups apply.
clientIdinteger
projectIdintegerStored as-is; does not snapshot the model (use bind-project).
notesstringUp to 10,000 characters.
validUntilintegerEpoch milliseconds.
tagIdsuuid[]
POST /estimates
{
  "name": "Johnson Backyard Pool",
  "taxProfileId": "5e7c9a02-4b18-49d3-bb61-7f2c8e105d3a",
  "markupPresetId": "b2f4a610-77c1-4e39-9d85-0a3f61c8d244",
  "clientId": 118,
  "notes": "Includes spa."
}

201 Created
{
  "id": "3f9b1c2e-5d41-4a77-9f0b-6c2d8a1e0b53",
  "userId": 42,
  "name": "Johnson Backyard Pool",
  "clientId": 118,
  "clientName": "Dana Johnson",
  "status": "DRAFT",
  "notes": "Includes spa.",
  "rootSectionId": "c41d9b77-2e58-4f10-8b6a-1d7e3c095aa2",
  "rootMarkupPresetId": "b2f4a610-77c1-4e39-9d85-0a3f61c8d244",
  "rootMarkupPresetName": "Standard",
  "rootMarkupPresetMaterialRate": 0.3,
  "rootMarkupPresetLaborRate": 0.5,
  "rootTaxProfileId": "5e7c9a02-4b18-49d3-bb61-7f2c8e105d3a",
  "rootTaxProfileName": "CA – Los Angeles",
  "createdAt": 1757500800000,
  "updatedAt": 1757500800000
}
PUT/estimates/{id}Estimates · Edit

Partial update: any subset of name, clientId, notes, validUntil, tagIds (replaces the tag set) and preferredVendorIds (replaces the ordered vendor list; each must be one of your suppliers). Fields you omit are unchanged; this endpoint cannot clear a field.

PATCH/estimates/{id}/statusEstimates · Edit

Body {"status": "SENT"}. Moving out of DRAFT snapshots every line item's resolved inventory item and unit cost, so later inventory price changes no longer move the estimate. Any status may be set from any other.

POST/estimates/{id}/change-orderEstimates · Edit

Copies the estimate (sections, items, overrides, snapshot) as a new DRAFT named "<name> — Change order N" and linked to the chain root. Refused with 409 Conflict while any revision in the chain is SENT. GET /estimates/{id}/chain lists the chain oldest first as { id, originalEstimateId, name, status, creatorName, creatorEmail, createdAt, updatedAt }; GET /estimates/{id}/changes diffs a change order against the revision before it, returning { baselineName, revisedName, sections: [{ name, depth, type, items: [{ description, type, oldQty, newQty, oldUnitPrice, newUnitPrice, oldTotal, newTotal }], oldTotal, newTotal }], baselineTotal, revisedTotal } where type is ADDED, REMOVED, MODIFIED or UNCHANGED.

POST/estimates/{id}/bind-projectEstimates · Edit

Body {"projectId": 91}. Sets the project and copies its current model into projectVariableSnapshot; binding again refreshes the snapshot. Drafts only (400 otherwise). POST …/unbind-project clears both.

GET/estimates/{id}/totalsEstimates · View
{
  "sections": [
    { "name": "Excavation", "materialCost": 0.0, "laborCost": 3145.0, "materialSell": 0.0, "laborSell": 4717.5, "sectionTax": 0.0, "sectionTotal": 4717.5 },
    { "name": "Equipment", "materialCost": 4820.0, "laborCost": 0.0, "materialSell": 6266.0, "laborSell": 0.0, "sectionTax": 548.28, "sectionTotal": 6814.28 }
  ],
  "summary": { "materialSubtotal": 6266.0, "laborSubtotal": 4717.5, "subtotal": 10983.5, "totalTax": 548.28, "grandTotal": 11531.78 },
  "taxProfile": { "name": "CA – Los Angeles", "materialTaxRate": 0.0875, "laborTaxRate": 0.0 },
  "profitAnalysis": { "totalCost": 7965.0, "totalRevenue": 11531.78, "totalTax": 548.28, "grossProfit": 3018.5, "grossMarginPercent": 26.2, "materialMarginPercent": 23.1, "laborMarginPercent": 33.3 }
}

sections lists every section at every depth, in tree order. Margin percentages are whole percents.

GET/estimates/{id}/export-pdfEstimates · View

application/pdf. ?view= is client-itemized (default), client-phase, client-lump or builder; ?scope=changes renders the change-order diff instead of the whole estimate.

Sections

A section is { id, estimateId, parentSectionId, name, description, sortOrder, isTemplate, isRoot, templateSourceId, markupPresetId, markupPresetName, markupPresetMaterialRate, markupPresetLaborRate, taxProfileId, taxProfileName, tags, children, lineItems, createdAt, updatedAt }. Templates are sections with isTemplate: true and no estimateId. Markup and tax resolve nearest-ancestor first: a line item's own override, then the closest section with one, then the estimate's root section, then the account defaults.

POST/estimates/{estimateId}/sectionsEstimates · Edit

Adds a section under the estimate's root (or under parentSectionId). POST /sections/{id}/sections adds a child to any section, template sections included. Answers 201 with the section.

FieldTypeNotes
namerequiredstring2–255 characters.
descriptionstringUp to 5,000 characters.
sortOrderintegerOmit to append.
parentSectionIduuidNest under this section instead of the root.
markupPresetId, taxProfileIduuidOverrides for this section; omit to inherit.
templateSourceIduuidCopy this template's tree instead of creating an empty section.
POST/estimates/{estimateId}/sections/from-template/{templateId}Estimates · Edit

No body. Copies the template's tree into the estimate, under ?parentSectionId= or the root. Every formula in the template is first checked against the estimate's model snapshot; if one references a calculator output the model cannot produce, the whole call is refused with 400 and the description names the outputs.

PUT/sections/{id}/overridesEstimates · Edit

Body {"markupPresetId": "…", "taxProfileId": null}. This is a replace: a field you omit or set to null is cleared back to inherit. Use it with estimate.rootSectionId to change the estimate-level overrides. PUT /sections/{id}, by contrast, only sets what you send (name, description, sortOrder, parentSectionId).

POST/sections/{id}/sections/reorderEstimates · Edit

Body {"sectionIds": [...]} — exactly the current child ids, in the new order. POST /sections/{id}/items/reorder takes {"itemIds": [...]} the same way. Both answer {"status": "reordered"}; a list that is not exactly the current set is refused with 400.

Line items

A line item is { id, sectionId, formula, descriptionOverride, inventoryItemIds, resolvedInventoryItemId, resolvedInventoryItemName, unit, quantity, unitCost, liveCost, unitPriceOverride, costType, taxStatus, taxRateOverride, markupOverride, sortOrder, createdAt, updatedAt }. The resolved fields, unit, quantity, unitCost and costType are computed from the formula and the inventory — you set the formula, Aquaticus fills the rest. liveCost appears on drafts whose snapshot differs from today's inventory price.

POST/sections/{id}/itemsEstimates · Edit
FieldTypeNotes
sectionIdrequireduuidMust be present in the body; the path wins if they differ.
formularequiredstringSee Formulas. Validated on save; the first problem is returned as 400.
descriptionOverridestringReplaces the inventory item's description on output. Up to 5,000 characters.
unitPriceOverridenumberExplicit sell price per unit; bypasses markup.
taxStatusstringTAXABLE (default) or EXEMPT.
taxRateOverridenumberFraction; beats the tax profile for this line.
markupOverridenumberFraction; one number for either cost type.
sortOrderintegerOmit to append.
POST /sections/7b21e4d9-0a53-4c68-9e17-8f4b2d60c1a5/items
{
  "sectionId": "7b21e4d9-0a53-4c68-9e17-8f4b2d60c1a5",
  "formula": "{Excavation Labor} * digVolume"
}

201 Created
{
  "id": "e0c7a914-6b32-4d85-a1f7-52d9b3e08c46",
  "sectionId": "7b21e4d9-0a53-4c68-9e17-8f4b2d60c1a5",
  "inventoryItemIds": ["9d13f6c8-2a47-4e91-b035-7c6e148af2d0"],
  "formula": "{Excavation Labor} * digVolume",
  "resolvedInventoryItemName": "Excavation Labor",
  "unit": "yd³",
  "quantity": 135.2,
  "unitCost": 42.5,
  "taxStatus": "TAXABLE",
  "createdAt": 1757500800000,
  "updatedAt": 1757500800000
}

PUT /line-items/{id} accepts any subset of the same fields plus sectionId to move the item; omitted fields stay. PUT /line-items/{id}/overrides takes { markupOverride, taxStatus, taxRateOverride } as a replace — omitted overrides are cleared and an omitted taxStatus resets to TAXABLE.

Formulas

A line-item formula names exactly one inventory item in braces and, optionally, multiplies it by an expression over the estimate's model:

{Variable-Speed Pump 2.7HP}
{Rebar #4} * rebarFeet / 20
{Excavation Labor} * digVolume
if length > 40 use {Large Skimmer} else {Standard Skimmer}
  • {Name} refers to an inventory item by its exact name.
  • Model variables (length, deepDepth, …) and calculator outputs (digVolume, rebarFeet, perimeter, …) come from the bound project's snapshot. GET /formulas/variables lists the variables with descriptions, units and advisory ranges; ?scope=all adds the nested spa and feature fields.
  • An if … use … else … chain may pick different items per branch, as long as each branch selects exactly one item.
POST/formulas/validateEstimates · View

Body {"formula": "…", "estimateId": "…"}. With estimateId the formula is checked against that estimate's snapshot; without it, against every known variable and output. Always 200 — read ok:

{
  "ok": false,
  "errors": [{ "message": "Unknown inventory item '{Pmup}'", "start": 0, "end": 6 }],
  "projectVariables": [],
  "inventoryItemNames": [],
  "projectOutputs": []
}

Templates

POST/templatesEstimates · Edit

Body { "name", "description", "tagIds" } creates an empty template (201); build it up with POST /sections/{id}/sections and POST /sections/{id}/items. PUT /templates/{id} updates name, description and tags. POST /templates/from-estimate/{estimateId} with { "name", "description" } turns an estimate into a template, keeping its markup and tax overrides but dropping the frozen prices so the template re-prices wherever it is used. GET /templates lists templates without their trees; GET /templates/{id} includes the whole tree.

Estimate tags

GET /estimate-tags returns [{ id, name, color, createdAt, usageCount }], pageable with ?limit= and ?offset=. POST /estimate-tags with { "name", "color" } creates a tag, or returns the existing one when a tag with that name (case-insensitive) already exists. PUT /estimate-tags/{id} renames or recolours it (409 Conflict on a name clash); GET /estimate-tags/{id}/usage answers { inventoryCount, estimateCount, templateCount }; DELETE removes the tag from everything.

Inventory

Inventory items are the materials and labor rates estimates are built from. A material item carries one or more vendor prices (one per supplier), exactly one of which is preferred and sets the item's effective unitCost; a labor item has a plain cost. Items are grouped in categories (two levels) and can carry tags and notes. Permission section: Inventory.

EndpointPermissionDescription
GET/inventoryViewList items (filters below; ?limit= default 100, ?offset=)
POST/inventoryEditCreate an item
GET/inventory/{id}ViewGet one item
PUT/inventory/{id}EditUpdate an item (partial)
DELETE/inventory/{id}DeleteDeactivate an item
POST/inventory/bulkEditBulk delete / tag / categorise / adjust cost
POST/inventory/refresh-pricesEditPull current prices from live vendors
POST/inventory/apply-vendorEditAttach a live vendor to many items
GET/inventory/export.csvViewDownload the inventory as CSV
POST/inventory/import.csvEditUpload a CSV
GET/inventory/{id}/price-historyViewCost changes over time (?limit=, ?offset=)
GET/inventory/{id}/notesViewNotes on an item (?limit=, ?offset=)
POST/inventory/{id}/notesEditAdd a note
DELETE/inventory/{id}/notes/{noteId}DeleteDelete a note
GET/inventory/{itemId}/pricesViewVendor prices of an item
POST/inventory/{itemId}/pricesEditAdd a vendor price
PUT/inventory/{itemId}/prices/{priceId}EditUpdate a vendor price
DELETE/inventory/{itemId}/prices/{priceId}DeleteRemove a vendor price
GET/inventory/categoriesViewAll categories (flat; ?limit=, ?offset=)
POST/inventory/categoriesEditCreate a category
PUT/inventory/categories/{id}EditUpdate a category
DELETE/inventory/categories/{id}DeleteDelete a category
GET/suppliersViewYour suppliers (?limit=, ?offset=)
GET/suppliers/predefinedViewVendor integrations Aquaticus ships with
POST/suppliersEditAdd a supplier
PUT/suppliers/{id}EditUpdate a supplier
DELETE/suppliers/{id}DeleteDelete a supplier and its prices
GET/suppliers/{id}/searchViewSearch a live vendor catalogue
GET/suppliers/{id}/categoriesViewA live vendor's category tree

The inventory item

FieldTypeDescription
iduuid
namestring2–255 characters; may not contain { or } (formulas reference items by name in braces).
description, sku, mpnstringFree text, internal SKU and manufacturer part number. Default "".
unitstringNormalised on save to each, ft, sq ft, hr, day, flat, ton or yd³ when it matches a known alias (ea, lf, sqft, cy, …); other values are kept as given.
costTypestringMATERIAL or LABOR.
unitCostnumberEffective cost: the preferred vendor's price for a material item, the plain cost for labor.
vendorCostsobject[]The item's vendor prices (see below), preferred first.
preferredVendorName, preferredVendorCoststring, numberConvenience copies of the preferred price.
categoryId, categoryName, subcategoryId, subcategoryNameuuid, string
taxStatusDefaultstringTAXABLE (default) or EXEMPT.
isActivebooleanDELETE sets it to false; set it back to true with PUT to restore.
autoUpdateFromApibooleanDefault true; lets refresh-prices update this item.
tagsobject[]{ id, name, color, createdAt }.
createdAt, updatedAtintegerEpoch milliseconds.

Vendor price

{ id, inventoryItemId, supplierId, supplierName, supplierAdapterType, supplierSku, cost, isPreferred, autoUpdate, notes, lastUpdated, createdAt }. Exactly one price per item is isPreferred. supplierAdapterType is magento for a live catalogue or manual.

GET/inventoryInventory · View
QueryDefaultNotes
searchSubstring of name, SKU, MPN, description or a vendor SKU.
categoryId, subcategoryId, tagIdExact matches.
costTypeMATERIAL or LABOR.
activeOnlytruefalse includes deactivated items.
sortNAME_ASCNAME_ASC, NAME_DESC, COST_ASC, COST_DESC, UPDATED_DESC, SKU_ASC, SKU_DESC, MPN_ASC, MPN_DESC, CATEGORY_ASC, CATEGORY_DESC, VENDOR_ASC.
limit, offset100, 0
withCountfalsetrue wraps the result as { "data": [...], "total": n }.
POST/inventoryInventory · Edit

Answers 201 with the item. A MATERIAL item needs at least one entry in vendorCosts; a LABOR item takes unitCost directly and ignores vendorCosts.

FieldTypeNotes
namerequiredstring2–255 characters, no braces.
unitrequiredstring1–50 characters.
costTyperequiredstringMATERIAL or LABOR.
vendorCostsobject[]Each { supplierId, cost, supplierSku, isPreferred, autoUpdate }; one per supplier; the first isPreferred wins (or the first entry). Required for materials.
unitCostnumberLabor only.
description, sku, mpnstringUp to 5,000 / 100 / 100 characters.
categoryId, subcategoryIduuid
taxStatusDefaultstringDefault TAXABLE.
autoUpdateFromApibooleanDefault true.
tagIdsuuid[]
POST /inventory
{
  "name": "Pentair IntelliFlo VSF Pump",
  "sku": "PMP-3HP-VSF",
  "unit": "each",
  "costType": "MATERIAL",
  "categoryId": "3a6c1f80-11d2-4c3e-8a2b-9c0f77b1a201",
  "vendorCosts": [
    { "supplierId": "5d2b8c91-4e07-4f3a-9b11-2a8e6c4d0f77", "cost": 1289.5, "supplierSku": "PEN-011056", "isPreferred": true }
  ]
}

201 Created
{
  "id": "8f14e45f-ceea-467a-9a36-dedd4bea2543",
  "userId": 42,
  "name": "Pentair IntelliFlo VSF Pump",
  "sku": "PMP-3HP-VSF",
  "unit": "each",
  "unitCost": 1289.5,
  "costType": "MATERIAL",
  "categoryId": "3a6c1f80-11d2-4c3e-8a2b-9c0f77b1a201",
  "categoryName": "Equipment",
  "preferredVendorName": "Heritage Pool Plus",
  "preferredVendorCost": 1289.5,
  "vendorCosts": [
    {
      "id": "e1c9a77b-2f40-4b8d-9a6e-77c1d0e3b512",
      "inventoryItemId": "8f14e45f-ceea-467a-9a36-dedd4bea2543",
      "supplierId": "5d2b8c91-4e07-4f3a-9b11-2a8e6c4d0f77",
      "supplierName": "Heritage Pool Plus",
      "supplierAdapterType": "magento",
      "supplierSku": "PEN-011056",
      "cost": 1289.5,
      "isPreferred": true,
      "lastUpdated": 1757635200000,
      "createdAt": 1757635200000
    }
  ],
  "createdAt": 1757635200000,
  "updatedAt": 1757635200000
}

PUT /inventory/{id} takes any subset of the same fields plus isActive and clearSubcategory. A vendorCosts or tagIds you send replaces the whole list. Every cost change is recorded in the price history.

POST/inventory/bulkInventory · Edit

Body { "op", "itemIds", ... } where op is DELETE (deactivate), ADD_TAGS / REMOVE_TAGS (with tagIds), SET_CATEGORY (with categoryId, subcategoryId, clearSubcategory) or ADJUST_COST (with costMultiplier > 0, applied to the item and all its vendor prices). Answers { "op", "affected" }.

POST/inventory/refresh-pricesInventory · Edit

Re-reads the price of every vendor cost that opted in (autoUpdate on the price, autoUpdateFromApi on the item, a live vendor and a vendor SKU) and is older than ?maxAgeMs= (default 6 hours), up to 100 per call. Answers { "updated", "unchanged", "failed", "errors" }. POST /inventory/apply-vendor with { "supplierId", "itemIds", "makePreferred" } looks up to 200 material items up in a live vendor's catalogue and attaches the matches, answering { "applied", "unmatched", "skipped", "unmatchedNames", "errors" }.

GET/inventory/export.csvInventory · View

text/csv of all active items with the columns name, sku, mpn, description, unit, unitCost, costType, vendor, category, subcategory, taxStatus, tags (tags separated by |).

POST /inventory/import.csv takes the same layout as a raw CSV body (Content-Type: text/csv, up to 5 MB and 50,000 rows). name, unit, unitCost and costType are required columns; vendor is required for material rows. Unknown categories, tags and vendors are created on the fly. ?updateExistingBySku=true updates items whose SKU matches instead of skipping them. Always answers 200 { "created", "updated", "skipped", "errors": ["Line 14: …"] } — check errors.

GET/inventory/{id}/price-historyInventory · View

Newest first: [{ id, inventoryItemId, oldCost, newCost, source, note, changedAt }] with source one of MANUAL, API, IMPORT, BULK. Notes work the same way: GET /inventory/{id}/notes → [{ id, inventoryItemId, body, source, createdAt }], POST with { "body" } (up to 10,000 characters) → 201, DELETE …/notes/{noteId} → 204.

POST/inventory/{itemId}/pricesInventory · Edit

Body { "inventoryItemId", "supplierId", "cost", "supplierSku", "isPreferred", "autoUpdate", "notes" } (inventoryItemId must be present; the path value is used). PUT …/prices/{priceId} updates any subset of cost, supplierSku, isPreferred, autoUpdate, notes; DELETE removes the price. Prefer PUT /inventory/{id} with a full vendorCosts list when you also need the item's unitCost and price history kept in step.

Categories

A category is { id, userId, name, sortOrder, parentId }; parentId makes it a subcategory (one level deep). POST /inventory/categories with { "name", "sortOrder", "parentId" } → 201; PUT accepts name, sortOrder, parentId and clearParent; DELETE → 204. Items keep their category ids after a delete, so re-categorise them first.

Suppliers

A supplier is { id, userId, name, adapterType, vendorKey, baseUrl, isActive, hasCustomerLogin, createdAt, updatedAt }; credentials are never returned. GET /suppliers/predefined lists the vendor integrations Aquaticus ships with (currently Heritage Pool Plus, key heritage_pool_plus).

POST/suppliersInventory · Edit

A manual supplier just needs { "name" }. A live one names a predefined vendor and its customer login: { "name", "vendorKey": "heritage_pool_plus", "customerEmail", "customerPassword" }; the login is verified with the vendor before saving (401 SupplierAuthFailed when it is rejected). PUT /suppliers/{id} updates name, baseUrl and the credentials. DELETE /suppliers/{id} also removes every vendor price that references the supplier. GET /suppliers/{id}/search?q=&category=&page=&pageSize= and GET /suppliers/{id}/categories query a live vendor's catalogue.

Error Reference

StatuserrorWhen
400BadRequestA value failed validation; the description says which.
400BadJsonBodyThe body is not valid JSON for this endpoint: a missing required field, a wrong type or an unknown key.
400InvalidParameterA path parameter or a model value is invalid (e.g. a non-numeric project id, a negative dimension).
400InvalidHistoryEntry, NoVariablesRollback: the entry belongs to another project, or holds no model to restore.
401UnauthorizedMissing or unknown API key.
401SupplierAuthFailedA live vendor rejected the stored customer login.
403ApiKeyPermissionDeniedThe key lacks the View, Edit or Delete permission this request needs.
403ApiKeyEndpointForbiddenThe endpoint is not available to API keys at all.
403EmailNotVerified, AlphaAccessRequiredThe account behind the key is not verified or approved.
403ForbiddenThe action is not allowed for this account or object.
404NotFoundNo such object in your account.
409Conflict, ClientExists, EmailExistsThe change collides with existing data (a change order while one is SENT, a duplicate tag name, a client email already in use).
413PayloadTooLargeBody over 10 MB.
500exception class nameSomething unexpected, including a malformed UUID in a URL. Retry with corrected input; report persistent failures.

Need Help?

Questions about the API, or a use case the permissions don't cover yet? We'd like to hear it.