Documentation

Projects API

Create and manage AV system design projects with rooms and equipment.

GET /api/v1/projects

Returns a paginated list of projects for your organization.

Query Parameters

Parameter Type Default Description
page integer 1 Page number
pageSize integer 20 Items per page (max 100)
search string Search by name, job number, or creator

Response (200 - Success)

{
    "success": true,
    "data": {
        "projects": [
            {
                "id": 1,
                "projectName": "Corporate HQ Upgrade",
                "jobNumber": "2024-001",
                "createdBy": "[email protected]",
                "salesEngineer": "John Smith",
                "projectManager": "Jane Doe",
                "accountManager": "Bob Wilson",
                "opportunityOwner": "Alice Johnson",
                "laborRate": 85.00,
                "equipmentMarkup": 1.25,
                "roomCount": 5
            }
        ],
        "pagination": { ... }
    }
}

GET /api/v1/projects/{id}

Returns a single project by ID, including summary statistics for its rooms.

Response (200 - Success)

{
    "success": true,
    "data": {
        "project": {
            "id": 1,
            "projectName": "Corporate HQ Upgrade",
            "jobNumber": "2024-001",
            "createdBy": "[email protected]",
            "salesEngineer": "John Smith",
            "projectManager": "Jane Doe",
            "accountManager": "Bob Wilson",
            "opportunityOwner": "Alice Johnson",
            "laborRate": 85.00,
            "equipmentMarkup": 1.25,
            "roomCount": 5,
            "rooms": [
                {
                    "id": 1,
                    "roomName": "Conference Room A",
                    "quantity": 1,
                    "isAddAlt": false,
                    "laborHours": 8.5,
                    "programmerHours": 4.0
                }
            ]
        }
    },
    "timestamp": "2024-12-25T12:00:00Z"
}

Error Responses

Status Code Description
404 PROJECT_NOT_FOUND Project with specified ID does not exist

POST /api/v1/projects

Creates a new project in your organization.

Request Body

Field Type Required Description
projectName string Yes Project name
jobNumber string No Job or reference number
jobSiteAddress string No Physical job site address
opportunityOwner string No Opportunity owner name
salesEngineer string No Assigned sales engineer
accountManager string No Assigned account manager
projectManager string No Assigned project manager
status string No Project status: InProgress, RevisionRequested, Complete
laborRate decimal No Hourly labor rate
programmingRate decimal No Hourly programming rate
equipmentMarkup decimal No Equipment markup multiplier (e.g., 1.25 for 25%)
taxRate decimal No Tax rate percentage
shippingPct decimal No Shipping percentage

Response (201 - Created)

{
    "success": true,
    "data": {
        "projectId": 42
    },
    "message": "Project created successfully",
    "timestamp": "2024-12-25T12:00:00Z"
}

PUT /api/v1/projects/{id}

Updates any field on a project in one call — name, job number, personnel, pipeline stage, workflow status, customer link, and the contact, billing (Bill To), shipping (Ship To), and job-site addresses. PATCH is also accepted. Updates are partial: only the fields you include are changed; everything else is left as-is.

Status changes are recorded in history

Setting status writes an entry to the project's status history. Pass statusNote alongside it to describe why the status changed — the note is ignored unless status is also present.

Rates and markups go elsewhere

Cost rates, sell rates, markups, and cabling settings are not accepted here — send them to PATCH /api/v1/projects/{id}/configuration. Including them returns WRONG_ENDPOINT.

Request Body

Field Type Description
Identity & Personnel
projectName string Project name (max 200 characters)
jobNumber string Job or reference number
salesEngineer string Assigned sales engineer
projectManager string Assigned project manager
accountManager string Assigned account manager
opportunityOwner string Opportunity owner name
Lifecycle
stage string Fulfillment stage: Quote, PurchaseOrder, Ordered, Shipped, Delivered, Installed
status string Workflow status: InProgress, RevisionRequested, Complete. Recorded in status history.
statusNote string Optional note describing the status change. Ignored unless status is provided.
Basic Info
createdBy string Person who created/owns the project
jobSiteAddress string Job site address
Bill To
billToCompany string Billing company name
billToContact string Billing contact name
billToAddress string Billing street address
billToCity string Billing city
billToState string Billing state
billToZip string Billing ZIP code
billToEmail string Billing email address
billToPhone string Billing phone number
Ship To
shipVia string Shipping carrier (e.g., UPS, FedEx)
shipBy string Required ship date
shipToName string Ship to contact name
shipToAddress string Ship to street address
shipToCity string Ship to city
shipToState string Ship to state
shipToZip string Ship to ZIP code
shipToContact string Ship to contact phone/email
Customer Link
customerId integer Link to customer record (0 to unlink). Ownership is verified before linking.

Example Request

{
    "projectName": "Downtown Office Buildout",
    "stage": "Ordered",
    "status": "Complete",
    "statusNote": "Signed off by client",
    "billToCompany": "Acme Corporation",
    "customerId": 17
}

Response (200 - Success)

{
    "success": true,
    "data": {
        "success": true,
        "projectId": 42,
        "message": "Project updated successfully. Changed: projectName, stage, billToCompany, customerId, status",
        "timestamp": "2024-12-25T12:00:00Z"
    },
    "message": null,
    "timestamp": "2024-12-25T12:00:00Z"
}

Error Responses

Status Code Description
400 VALIDATION_ERROR Request body failed validation (e.g., project name over 200 characters)
400 WRONG_ENDPOINT Body contained fields not valid here (e.g., rate/markup fields belong on the configuration endpoint)
400 NO_CHANGES No recognized fields were provided
404 PROJECT_NOT_FOUND Project with specified ID does not exist
404 CUSTOMER_NOT_FOUND customerId does not match a customer in your organization
500 UPDATE_FAILED The update could not be saved

DELETE /api/v1/projects/{id}

Permanently deletes a project and all its associated rooms.


PATCH /api/v1/projects/{id}/configuration

Updates project configuration settings including rates, markups, and percentages. Only provided fields will be updated.

Request Body

Field Type Description
Cost Rates
laborRate decimal Labor cost rate per hour
programmingRate decimal Programming cost rate per hour
configurationRate decimal Configuration cost rate per hour
unionRate decimal Union labor rate per hour
schematicsCostRate decimal Schematics/CAD cost rate percentage
shippingCostRate decimal Shipping cost rate percentage
projectManagementCostRate decimal Project management cost rate percentage
Sell Rates
laborSell decimal Labor sell rate per hour
programmingSell decimal Programming sell rate per hour
configurationSell decimal Configuration sell rate per hour
Markups & Percentages
laborMarkup decimal Labor markup percentage
programmingMarkupPercentage decimal Programming markup percentage
configurationMarkupPercentage decimal Configuration markup percentage
equipmentMarkup decimal Equipment markup percentage
shippingPct decimal Shipping percentage of equipment cost
taxRate decimal Tax rate percentage
avDrawingsPercentage decimal AV drawings percentage
projectManagementPercentage decimal Project management percentage
Cabling Configuration
c6AStpInRoom integer Cat6A STP cables in room
c6AUtpInRoom integer Cat6A UTP cables in room
c6AUtpFromIdf integer Cat6A UTP cables from IDF
c6AStpInRoomHours decimal Hours per Cat6A STP cable in room
c6AUtpInRoomHours decimal Hours per Cat6A UTP cable in room
c6AIdFHours decimal Hours per Cat6A cable from IDF

Example Request

{
    "laborRate": 45.00,
    "laborSell": 125.00,
    "equipmentMarkup": 35,
    "taxRate": 8.25
}

Response (200 - Success)

{
    "success": true,
    "projectId": 42,
    "message": "Project configuration updated successfully",
    "timestamp": "2024-12-25T12:00:00Z"
}

POST /api/v1/projects/{id}/estimate

Generates an estimate/proposal document for a project. Returns HTML that can be rendered or converted to PDF.

Estimate number auto-generated when omitted

Leave estimateNumber off the request body and the server fills in EST-{projectId}-{yyyyMMddHHmmss}. Pass an explicit value to use your own numbering scheme.

Request Body

Field Type Required Description
estimateNumber string Yes Estimate number (e.g., "EST-2024-001")
estimateDate date No Date of the estimate (defaults to today)
validDays integer No Days until estimate expires (default: 30, range: 1-365)
roomIds array No Specific room IDs to include (null = all non-AddAlt rooms)

Example Request

{
    "estimateNumber": "EST-2026-001",
    "estimateDate": "2026-02-20",
    "validDays": 30
}

Response (200 - Success)

{
    "success": true,
    "html": "<!DOCTYPE html>...",
    "totalAmount": 45250.00,
    "totalEquipment": 38500.00,
    "totalServices": 5250.00,
    "totalShipping": 1500.00,
    "totalTax": 3733.13,
    "totalWithTax": 48983.13,
    "taxRate": 8.25,
    "estimateNumber": "EST-2026-001",
    "estimateDate": "2026-02-20T00:00:00Z",
    "validUntil": "2026-03-22T00:00:00Z",
    "message": "Estimate generated successfully",
    "timestamp": "2026-02-20T12:00:00Z"
}

POST /api/v1/projects/{id}/duplicate

Creates a complete copy of a project including all rooms and equipment.

Request Body

{
    "newProjectName": "Corporate HQ Upgrade - Copy"
}

GET /api/v1/projects/{id}/rooms

Returns all rooms belonging to the specified project.


POST /api/v1/projects/{id}/rooms

Creates a new room within a project.

Request Body

Field Type Required Description
roomName string Yes Name of the room
quantity integer No Number of identical rooms (default: 1)
isAddAlt boolean No Whether this is an Add Alternate room (default: false)
laborHours decimal No Installation labor hours
programmerHours decimal No Programming hours
configurationHours decimal No Configuration hours
includeTravel boolean No Include travel charges (default: false)
travelAmount decimal No Travel charge amount (if includeTravel is true)

Example Request

{
    "roomName": "Conference Room A",
    "quantity": 2,
    "isAddAlt": false,
    "laborHours": 8.5,
    "programmerHours": 4.0
}

Response (201 - Created)

{
    "success": true,
    "data": {
        "roomId": 123,
        "projectId": 456
    },
    "message": "Room created successfully",
    "timestamp": "2024-12-25T12:00:00Z"
}

PUT /api/v1/projects/{id}/rooms/{roomId}

Updates an existing room within a project.

Request Body

Field Type Required Description
roomName string Yes Name of the room
quantity integer No Number of identical rooms (default: 1)
isAddAlt boolean No Whether this is an Add Alternate room
laborHours decimal No Installation labor hours
programmerHours decimal No Programming hours
includeTravel boolean No Include travel charges
travelAmount decimal No Travel charge amount

Example Request

{
    "roomName": "Conference Room A (Updated)",
    "quantity": 3,
    "laborHours": 12.0,
    "programmerHours": 6.0
}

Response (200 - Success)

{
    "success": true,
    "roomId": 123,
    "projectId": 456,
    "message": "Room updated successfully",
    "timestamp": "2024-12-25T12:00:00Z"
}

Error Responses

Status Code Description
404 ROOM_NOT_FOUND Room does not exist or belongs to another project

DELETE /api/v1/projects/{id}/rooms/{roomId}

Permanently deletes a room and all its associated line items.

Cascading Delete

Deleting a room will also remove all equipment line items within that room. This action cannot be undone.

Response (200 - Success)

{
    "success": true,
    "roomId": 123,
    "projectId": 456,
    "message": "Room deleted successfully",
    "timestamp": "2024-12-25T12:00:00Z"
}

Error Responses

Status Code Description
404 ROOM_NOT_FOUND Room does not exist or belongs to another project

GET /api/v1/projects/{projectId}/rooms/{roomId}/items

Returns all equipment line items and section dividers in a room.

Response (200 - Success)

{
    "success": true,
    "data": {
        "lineItems": [
            {
                "id": 1,
                "productId": 456,
                "make": "Crestron",
                "model": "TSW-1060",
                "part": "6510483",
                "description": "10.1 in. Touch Screen",
                "quantity": 1,
                "order": 1,
                "productCostEach": 1500.00,
                "laborHours": 2.0
            }
        ],
        "dividers": [
            {
                "id": 5,
                "categoryName": "Head End",
                "order": 2
            }
        ]
    },
    "timestamp": "2024-12-25T12:00:00Z"
}

POST /api/v1/projects/{projectId}/rooms/{roomId}/items

Adds a new equipment line item to a room.

Request Body

Field Type Required Description
productId integer No* Product ID from catalog. If provided, other fields auto-populate.
make string No* Manufacturer name (used for product lookup if no productId)
model string No* Model number (used for product lookup if no productId)
partNumber string No* Part/SKU number (used for product lookup if no productId)
description string No Product description (auto-fills from product)
quantity integer No Item quantity (default: 1)
unitCost decimal No Unit cost (auto-fills from product)
laborHours decimal No Labor hours per unit (auto-fills from product)
displayOrder integer No Sort order in room (auto-assigned if not provided)
Product Lookup

Add items by productId or make/model/partNumber combination. The product must exist in your catalog. Details like description, cost, and labor hours auto-fill from the matched product.

Example Request (by Product ID)

{
    "productId": 456,
    "quantity": 2
}

Example Request (by Make/Model)

{
    "make": "Crestron",
    "model": "TSW-1060",
    "quantity": 1
}

Response (201 - Created)

{
    "success": true,
    "lineItemId": 789,
    "roomId": 123,
    "productId": 456,
    "message": "Line item added successfully",
    "timestamp": "2024-12-25T12:00:00Z"
}

Error Responses

Status Code Description
404 ROOM_NOT_FOUND Room does not exist or belongs to another project
404 PRODUCT_NOT_FOUND No product found matching the provided productId or make/model

PUT /api/v1/projects/{projectId}/rooms/{roomId}/order

Reorders a room's line items and section dividers in one call. Line items and dividers share one position space, so the request body carries the room's COMPLETE new row order and the server renumbers it 1..N.

Request Body

{
    "entries": [
        { "type": "divider", "id": 5 },
        { "type": "item", "id": 90 },
        { "type": "item", "id": 91 }
    ],
    "allowEmptySections": false
}

allowEmptySections is optional and defaults to false. It only guards headings that currently have items under them. Left false or omitted, an order that would empty one of those headings is refused with STRANDED_SECTION and nothing changes. Set it to true to deliberately submit such an order. A heading that is already empty is not affected either way — it reorders freely.

Exact cover, all or nothing

Every line item and divider currently in the room must appear in entries exactly once. An extra id, a missing id, or a duplicate is rejected and nothing changes — the room keeps the order it had. Read the room first with GET /api/v1/projects/{projectId}/rooms/{roomId}/items (data.lineItems and data.dividers) to build the complete list, then send back every row you read, in the order you want it.

Response (200 - Success)

{
    "success": true,
    "data": {
        "itemsReordered": 2,
        "dividersReordered": 1,
        "sequence": [
            { "type": "divider", "id": 5, "order": 1, "label": "Head End" },
            { "type": "item", "id": 90, "order": 2, "label": "Crestron TSW-1060" },
            { "type": "item", "id": 91, "order": 3, "label": "QM75C" }
        ],
        "sections": [
            { "dividerId": 5, "name": "Head End", "itemCount": 2 }
        ],
        "emptySections": [],
        "unlabelledItemCount": 0
    },
    "timestamp": "2024-12-25T12:00:00Z"
}
sequence can be null

null means the write SUCCEEDED and the row order changed — only the read-back that builds this list failed. Re-read the room with GET /api/v1/projects/{projectId}/rooms/{roomId}/items to see it. An empty room reads back as [], never null. sections, emptySections and unlabelledItemCount are null under the exact same condition — never independently.

Error Responses

Status Code Description
400 VALIDATION_ERROR entries is missing, empty, or contains a row with no type or a non-positive id
404 ROOM_NOT_FOUND Room does not exist or belongs to another project
400 ENTRY_MISMATCH entries is not an exact cover of the room's current rows — an id is missing, an id doesn't belong to the room, an id is duplicated, or an entry's type is neither item nor divider (a blank/missing type is a VALIDATION_ERROR above; a misspelled one like "iteem" reaches here instead). Nothing changes. The message names GET api/v1/projects/{projectId}/rooms/{roomId}/items as where to read the current ids.
400 STRANDED_SECTION A valid exact-cover entries that would empty a category heading that currently has items under it. Nothing changes. Headings that are already empty are not affected. The message names the heading and adds "Pass allowEmptySections: true if an empty heading is intended."
409 LOCKED_VERSION The room's project version is contracted and locked. The response carries the standard locked-version body (errorCode, userFacingMessage, suggestedAction, versionId, contractId) — see Replace Equipment for the full body — not the error.code shape this table uses. Nothing was reordered.

POST /api/v1/projects/{projectId}/rooms/{roomId}/sections/{dividerId}/move

Moves one whole section, a heading and every row under it, in a single call.

PUT .../order above demands the room's complete new sequence. Building that permutation by hand to shift one section is arithmetic a caller can get wrong. A mistake there can strand a heading or drop a row.

Here the caller states the destination. The server does the slicing, then runs the result through the same reorder engine. The version lock, the exact-cover check and the renumbering all stay unchanged.

The section that moves is named by the route's {dividerId}. The request body only carries where it goes. To move only the heading, leaving its items behind under whatever heading now sits above them, use the divider update endpoint above instead.

Request Body

Field Type Required Description
placement string Yes One of top, end, above, below, case-insensitive. Anything else, including blank or missing, is rejected as VALIDATION_ERROR.
targetDividerId integer Only for above/below The other section's divider id — get it from GET /api/v1/projects/{projectId}/rooms/{roomId}/items, whose data.dividers array carries the id and name of every section heading in the room. Ignored by top and end. Omitting it on above/below, or setting it to the route's own {dividerId}, is rejected as VALIDATION_ERROR.
{
    "placement": "above",
    "targetDividerId": 5
}
"Top" means above the first heading, not the first row

Rows that sit before any heading belong to no section. Placing a section at top puts it above every other heading but leaves those unlabelled rows exactly where they are — it does not push them down.

Response (200 - Success)

{
    "success": true,
    "data": {
        "dividerId": 12,
        "categoryName": "Video Conferencing",
        "rowsMoved": 2,
        "sequence": [
            { "type": "divider", "id": 12, "order": 1, "label": "Video Conferencing" },
            { "type": "item", "id": 90, "order": 2, "label": "Crestron TSW-1060" },
            { "type": "divider", "id": 5, "order": 3, "label": "Head End" }
        ],
        "sections": [
            { "dividerId": 12, "name": "Video Conferencing", "itemCount": 1 },
            { "dividerId": 5, "name": "Head End", "itemCount": 0 }
        ],
        "emptySections": [
            { "dividerId": 5, "name": "Head End", "itemCount": 0 }
        ],
        "unlabelledItemCount": 0
    },
    "timestamp": "2024-12-25T12:00:00Z"
}

Head End already had no items before this move. A section travels as one block — its heading and its rows together — so a move can never leave a heading newly empty. It is listed in emptySections exactly as it would have been before the call.

sequence can be null

null means the write SUCCEEDED — the section moved — and only the read-back that builds this list failed. Re-read the room with GET /api/v1/projects/{projectId}/rooms/{roomId}/items to see it. An empty room reads back as [], never null. sections, emptySections and unlabelledItemCount are null under the exact same condition — never independently.

rowsMoved: 0 is a no-op, not a failure

The section was already where the request asked for it. Nothing is written, so a contract-locked project version is never checked and never blocks it — there is no change for a lock to refuse.

Error Responses

Status Code Description
400 VALIDATION_ERROR placement is missing or not one of top, end, above, below — or it is above/below with targetDividerId omitted, or with targetDividerId equal to the route's own {dividerId} (a section cannot be placed relative to itself).
404 ROOM_NOT_FOUND Room does not exist or belongs to another project
404 DIVIDER_NOT_FOUND The route's {dividerId} does not name a divider in this room
404 TARGET_NOT_FOUND targetDividerId does not name a divider in this room
409 ROOM_CHANGED The room changed between the read that computed the move and the write that applied it — a race, not a bad request. Nothing was written; re-read the room and retry. Uses the standard error.code envelope shown below, not the raw LockedVersionError body the LOCKED_VERSION row below uses.
409 LOCKED_VERSION The room's project version is contracted and locked. The response carries the standard locked-version body (errorCode, userFacingMessage, suggestedAction, versionId, contractId) — see Replace Equipment for the full body — not the error.code shape this table uses. Nothing was moved.
{
    "success": false,
    "error": {
        "code": "ROOM_CHANGED",
        "message": "The room changed while the move was being computed — re-read and retry.",
        "details": null
    },
    "timestamp": "2024-12-25T12:00:00Z"
}

POST /api/v1/projects/{projectId}/rooms/{roomId}/dividers

Adds a section divider — a labeled heading that groups the line items beneath it — to a room. Name a target and every row at or below it shifts down one. Anchoring above the first line of an existing section empties that section: it stays a bare heading row on estimates and never becomes a band on proposals, so rename or delete it if you meant to relabel.

Request Body

Field Type Required Description
categoryName string No Section label. Defaults to "Section" when omitted.
aboveLineItemId integer No Line item to place the heading directly above. Wins over order. The heading lands at that line item's current position and every row at or below it shifts down one.
order integer No Explicit position. Ignored when aboveLineItemId is set. Every row at or below it shifts down one.

Omit both aboveLineItemId and order to append the heading past the room's last row.

Response (200 - Success)

{
    "success": true,
    "data": {
        "dividerId": 5,
        "categoryName": "Head End",
        "order": 1,
        "sequence": [
            { "type": "divider", "id": 5, "order": 1, "label": "Head End" },
            { "type": "item", "id": 90, "order": 2, "label": "Crestron TSW-1060" }
        ],
        "sections": [
            { "dividerId": 5, "name": "Head End", "itemCount": 1 }
        ],
        "emptySections": [],
        "unlabelledItemCount": 0
    },
    "timestamp": "2024-12-25T12:00:00Z"
}
sequence can be null

Placing a heading shifts the rows below it, so the heading's own order does not show where the rest of the room ended up. null means the write SUCCEEDED — the heading was added — and only the read-back that builds this list failed. Re-read the room with GET /api/v1/projects/{projectId}/rooms/{roomId}/items to see it. An empty room reads back as [], never null. sections, emptySections and unlabelledItemCount are null under the exact same condition — never independently.

Error Responses

Status Code Description
404 ROOM_NOT_FOUND Room does not exist or belongs to another project
404 LINE_ITEM_NOT_FOUND aboveLineItemId is not a line item of this room
409 LOCKED_VERSION The room's project version is contracted and locked — see Replace Equipment for the body shape. Nothing was added.

PUT /api/v1/projects/{projectId}/rooms/{roomId}/dividers/{dividerId}

Renames and/or repositions a section divider. This moves ONLY the heading row — the items it labeled stay exactly where they are and pick up whatever heading now sits above them. To move a whole section, heading and items together, use POST .../sections/{dividerId}/move above instead. Moving the heading directly above the first line of another section empties that one: it still renders as a bare heading row on estimates, and never becomes a band on proposals.

Request Body

Field Type Required Description
categoryName string No New section label. Omit to leave the name unchanged.
aboveLineItemId integer No Line item to move the heading directly above. Wins over order. Every row at or below the target shifts down one.
order integer No New explicit position. Ignored when aboveLineItemId is set. Every row at or below it shifts down one.

Omit both aboveLineItemId and order to rename the heading without moving it.

Response (200 - Success)

Same shape as the add endpoint above: { dividerId, categoryName, order, sequence, sections, emptySections, unlabelledItemCount }. Same null contract too — sections, emptySections and unlabelledItemCount are null exactly when sequence is null.

Error Responses

Status Code Description
404 DIVIDER_NOT_FOUND Divider does not exist, or belongs to another room or project
404 LINE_ITEM_NOT_FOUND aboveLineItemId is not a line item of this room
409 LOCKED_VERSION The room's project version is contracted and locked — see Replace Equipment for the body shape. Nothing was changed.

DELETE /api/v1/projects/{projectId}/rooms/{roomId}/dividers/{dividerId}

Removes a section divider. The line items it labeled are not deleted — they stay where they are and regroup under whichever heading now sits above them, or lose their heading entirely if there was none.

Response (200 - Success)

{
    "success": true,
    "data": {
        "dividerId": 5,
        "deleted": true
    },
    "timestamp": "2024-12-25T12:00:00Z"
}

No sequence here: a delete removes one row and renumbers nothing, so the rows you already know about keep the orders they had.

Error Responses

Status Code Description
404 DIVIDER_NOT_FOUND Divider does not exist, or belongs to another room or project

Deleting a divider does not touch line-item rows, so it is not blocked by a locked project version.


PUT /api/v1/projects/{projectId}/rooms/{roomId}/items/{itemId}

Updates an existing line item in a room.

Request Body

Field Type Required Description
quantity integer No Item quantity (1-10000)
productCostEach decimal No Unit cost override
productMarkup decimal No Markup percentage override
laborHours decimal No Labor hours for this item
configurationHours decimal No Configuration hours
programmerHours decimal No Programming hours
notes string No Notes or comments for this item
equipmentSource string No Equipment source/vendor name
poNumber string No Purchase order reference
quoteReference string No Vendor quote reference
make string No Update manufacturer (custom items only)
model string No Update model (custom items only)
part string No Update part number (custom items only)
description string No Update description (custom items only)
clearOverridePercent boolean No If true, clears productMarkup back to the tenant default markup. Takes precedence over any productMarkup value sent in the same request.
cables array No Full replacement of cable assignments on this line item. Omit or send null to leave cables unchanged. Send [] (empty array) to clear all cables. See Cable Object Shape below.
Snapshot Fields

The make, model, part, and description fields update the item's snapshot data. This is useful for custom line items or when you need to override the catalog values for a specific project.

Cable Object Shape

Each entry in the cables array describes one cable assignment. Pricing snapshots are resolved server-side from the current cable type catalog at write time — callers do NOT supply prices.

Field Type Required Description
cableTypeId integer Yes FK to a cable type in the tenant's catalog. Must be IsActive = true. Unknown or inactive IDs reject the whole request.
pricingMode string Yes Either "per_foot" or "per_run". Determines whether cost is charged per linear foot or per run.
runs integer Yes Number of runs. Must be at least 1.
length decimal Conditional Feet per run. Required and must be > 0 when pricingMode is "per_foot". Ignored (stored as 0) when "per_run".
Atomic Cable Replacement

If cables is provided, the existing cable assignments on the line item are completely replaced — not merged. Validation runs before any write: if any entry has an unknown cableTypeId, an invalid pricingMode, or a missing length on per-foot mode, the entire request rejects and nothing is written. New cable rows pull fresh snapshot prices from the active cable type catalog.

Example Request

{
    "quantity": 3,
    "productCostEach": 1450.00,
    "laborHours": 2.5,
    "notes": "Mount on east wall, customer providing cable",
    "cables": [
        { "cableTypeId": 7, "pricingMode": "per_foot", "runs": 2, "length": 50 },
        { "cableTypeId": 12, "pricingMode": "per_run", "runs": 1 }
    ]
}

Response (200 - Success)

{
    "success": true,
    "lineItemId": 789,
    "roomId": 123,
    "productId": 456,
    "message": "Line item updated successfully",
    "timestamp": "2024-12-25T12:00:00Z"
}

Error Responses

Status Code Description
404 LINE_ITEM_NOT_FOUND Line item does not exist in the specified room (or the URL's projectId / roomId doesn't match the actual line item — both collapse to 404)
400 VALIDATION_ERROR Invalid data (e.g., quantity out of range)
400 VALIDATION_FAILED Cable validation rejected the request — for example, an unknown or inactive cableTypeId, an invalid pricingMode, runs < 1, or missing length on per-foot mode. The response message names the offending value.
400 NO_CHANGES The request body was empty — no fields supplied to update.
400 UNMAPPED_FIELDS The request contains unknown field names (typos). The response lists the unrecognized fields.

DELETE /api/v1/projects/{projectId}/rooms/{roomId}/items/{itemId}

Removes a line item from a room.

Response (200 - Success)

{
    "success": true,
    "lineItemId": 789,
    "roomId": 123,
    "productId": 456,
    "message": "Line item deleted successfully",
    "timestamp": "2024-12-25T12:00:00Z"
}

Error Responses

Status Code Description
404 LINE_ITEM_NOT_FOUND Line item does not exist in the specified room

POST /api/v1/projects/{id}/invoice

Generates an invoice document (HTML) for a project.

Invoice number auto-generated; tax preferences inherit from project

Omit invoiceNumber and the server uses INV-{projectId}-{yyyyMMddHHmmss}. Omit includeTaxes, showNonEquipment, or paymentTerms to inherit the project's saved Invoice preferences; pass explicit values to override.

Request Body

Field Type Required Description
invoiceNumber string Yes Invoice number (max 100 chars)
purchaseOrderNumber string No Customer PO number
invoiceDate datetime No Invoice date (default: today)
dueDate datetime No Payment due date (default: 30 days from today)
paymentTerms string No Payment terms (default: "Due on Receipt")
paymentInstructions string No Payment instructions text
showNonEquipment boolean No Include labor, programming, freight, etc. (default: true)
includeTaxes boolean No Include tax calculations (default: false)
billToCompany string No Billing company name (defaults to project's saved Bill To)
billToAddress string No Billing street address (defaults to project's saved Bill To)
billToCity string No Billing city (defaults to project's saved Bill To)
billToState string No Billing state (defaults to project's saved Bill To)
billToZip string No Billing ZIP code (defaults to project's saved Bill To)
shipToName string No Shipping location name (defaults to project's saved Ship To)
shipToAddress string No Shipping street address (defaults to project's saved Ship To)
roomIds array No Specific room IDs to include (default: all non-AddAlt rooms)
Invoice Output

The response includes the complete invoice as an HTML document in the html field. This HTML is print-ready and can be rendered in a browser, converted to PDF, or saved directly.

Address Defaults

Bill To and Ship To addresses default to the project's saved addresses. You only need to provide these fields if you want to override the saved values for this specific invoice.

Example Request

{
    "invoiceNumber": "INV-2024-001",
    "purchaseOrderNumber": "PO-12345",
    "invoiceDate": "2024-12-25",
    "dueDate": "2024-12-25",
    "paymentTerms": "Due on Receipt",
    "showNonEquipment": true,
    "includeTaxes": false,
    "billToCompany": "Acme Corporation",
    "billToAddress": "123 Main St",
    "billToCity": "New York",
    "billToState": "NY",
    "billToZip": "10001"
}

Response (200 - Success)

{
    "success": true,
    "html": "<!DOCTYPE html><html>...</html>",
    "totalAmount": 45750.00,
    "invoiceNumber": "INV-2024-001",
    "message": "Invoice generated successfully",
    "timestamp": "2024-12-25T12:00:00Z"
}

Error Responses

Status Code Description
400 VALIDATION_ERROR Invalid invoice data (e.g., missing invoice number)
400 INVOICE_GENERATION_FAILED Project not found or no rooms available

Excel Export

Mint a 7-day signed URL to download a project's Excel workbook.

GET /api/v1/projects/{id}/export/excel

Auth: standard API token (Authorization: Bearer avs_...) or JWT.

The endpoint does not return the workbook directly — it returns JSON with a signed URL. GET the URL in a second request to download the .xlsx. The signed download URL does not require an Authorization header (the signature is the authorization), so simple tools like curl or a browser can fetch it.

Query Parameters

All parameters are optional — omit any to fall back to the project's saved default, then the system default. API consumers use the PascalCase names; the MCP-equivalent snake_case names are listed for cross-reference.

Query parameter MCP equivalent Type Default Description
mode mode enum FullProject FullProject for the full workbook, EquipmentOnly for a one-tab-per-room equipment list
IncludeVendorTabs include_vendor_tabs bool? (project default → true) Include the Vendor sheets
IncludeVendorPOTabs include_vendor_po_tabs bool? (project default → true) Include the Vendor PO sheets
IncludeBOMCombinedTab include_bom_combined_tab bool? (project default → true) Include the BOM Combined sheet
IncludeFinancialAnalysisTab include_financial_analysis_tab bool? (project default → true) Include the Financial Analysis sheet
HideNotesColumn hide_notes_column bool? (project default → false) Hide the Notes column on per-room sheets
IncludePartNumberColumn include_part_number_column bool? false Add a Part# column to per-room and Vendor sheets
IncludeFormulas include_formulas bool? true Use Excel formulas for totals (false for static values)

Example Request

curl -H "Authorization: Bearer avs_..." \
  "https://yourdomain.com/api/v1/projects/42/export/excel?mode=FullProject&HideNotesColumn=true"

Response (200 - Success)

{
    "url": "https://yourdomain.com/project-excel/<token>?mode=FullProject&HideNotesColumn=true",
    "expires_at": "2026-05-26T14:32:08Z",
    "filename": "Acme HQ - 2026-05-19.xlsx"
}

The signed URL is valid for 7 days. After that the token expires and the URL returns 410 Gone. Cache the URL if you'll download repeatedly within the window; re-mint via the same endpoint once expired.

For the user-workflow guide (what the workbook contains, how saved defaults work, etc.), see Excel Export.