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.
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.
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.
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.
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) |
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.
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
}
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. |
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". |
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.
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) |
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.
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.
