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": "user@example.com",
"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": "user@example.com",
"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,
"replacesRoomId": null,
"replacesRoomName": null,
"linkState": "None"
},
{
"id": 2,
"roomName": "Conference Room A (Alt)",
"quantity": 1,
"isAddAlt": true,
"laborHours": 8.5,
"programmerHours": 4.0,
"replacesRoomId": 1,
"replacesRoomName": "Conference Room A",
"linkState": "Resolved"
}
]
}
},
"timestamp": "2024-12-25T12:00:00Z"
}
Each room reports the base room it replaces. replacesRoomId is the stored id of that base room, or null. linkState says whether the link is live: None (the room has no link), Resolved, or the reason it is not, which is BaseMissing, BaseArchived or BaseIsOptional. replacesRoomName carries the base room's name only when linkState is Resolved. GET /api/v1/projects/{id}/rooms returns the same three fields.
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 |
replacesRoomId |
integer | No | Id of the base room this optional room replaces on the proposal. 0 clears the link, and leaving the field out keeps it. Only an optional room can carry a link, so send isAddAlt: true in the same call or mark the room optional first. Setting isAddAlt to false also clears the link |
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 |
| 400 | ROOM_LINK_REFUSED |
The optional flag or the link was refused. error.message says why and error.refusal names the reason: BaseNotFound, SameRoom, LinkOnBaseRoom, BaseIsOptional, BaseArchived or DifferentVersion. The link is checked before any other field is written, so a refused call changes nothing |
POST /api/v1/projects/{projectId}/rooms/{roomId}/duplicate-as-alternate
Duplicates a base room as an optional room that replaces it, so the proposal prices the copy as a difference from the base room. The request has no body.
The new room is named "<room> (Alt)", or "(Alt 2)", "(Alt 3)" and so on when that name is taken. It goes at the end of the room's version, copies the base room's equipment, dividers and room settings (quantity, hours, travel and shipping), is linked to the base room, and joins no Add/Alt group.
Response (201 - Created)
data is the new room, in the same shape the room lists return.
{
"success": true,
"data": {
"id": 131,
"roomName": "Executive Boardroom (Alt)",
"laborHours": 12.0,
"programmerHours": 6.0,
"quantity": 1,
"isAddAlt": true,
"includeTravel": false,
"travelAmount": null,
"shippingFlat": null,
"replacesRoomId": 123,
"replacesRoomName": "Executive Boardroom",
"linkState": "Resolved"
},
"message": "Room duplicated as alternate \"Executive Boardroom (Alt)\"",
"timestamp": "2024-12-25T12:00:00Z"
}
Error Responses
| Status | Code | Description |
|---|---|---|
| 404 | ROOM_NOT_FOUND |
Room does not exist or belongs to another project |
| 400 | ROOM_LINK_REFUSED |
The room cannot be duplicated as an alternate. error.refusal is BaseIsOptional (the room is optional, so it is not a base room), BaseArchived, RoomLimit (a free account is at its room limit) or DifferentVersion. Nothing was created |
| 409 | LOCKED_VERSION |
The room's version is contracted and locked |
| 500 | ROOM_LINK_REFUSED |
error.refusal is CopyFailed: copying the equipment failed and the new room was removed. Nothing was changed |
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. Each line item carries cadSystemId, the ID of the system the line belongs to (from GET /api/v1/cad-systems), or null when it is unassigned.
productCostEach is null for a line whose 0.00 cost nobody has confirmed, such as a $0.00 saved before Accept $0 cost existed. Sending productCostEach: 0 does not confirm a zero: the line is saved with a 0.00 nobody has confirmed, reads back as null, and counts as unpriced until someone accepts it in the app. Sending 0 for a line whose accepted $0.00 is already stored leaves that acceptance in place. A line item has no separate field that says whether its zero was confirmed.
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,
"cadSystemId": 3
}
],
"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) |
part |
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) |
productCostEach |
decimal | No | Your cost for one unit. Leave it out and we snapshot the catalog price at the project's currency rate. Sending 0 saves a 0.00 that nobody has confirmed: the line counts as unpriced until someone accepts it in the app. |
laborHours |
decimal | No | Labor hours per unit (auto-fills from product) |
order |
integer | No | Sort order in room (auto-assigned if not provided) |
cadSystemId |
integer | No | The system the new line belongs to, from GET /api/v1/cad-systems. Leave it out to add the line unassigned. The response echoes it as cadSystemId. |
Add items by productId or make/model/part 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 |
| 400 | VALIDATION_FAILED |
cadSystemId is not one of your systems, for example System 99 not found. Nothing is added. |
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. Sending 0 does not confirm a zero: the line counts as unpriced until someone accepts it in the app, unless it already holds an accepted $0.00. |
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. |
cadSystemId |
integer | No | Sets the system the line belongs to. null or omitted leaves it unchanged; to clear it, use PUT .../items/{itemId}/system below. The response echoes the line's system as cadSystemId. |
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 — or cadSystemId is not one of your systems. 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. |
PUT /api/v1/projects/{projectId}/rooms/{roomId}/items/{itemId}/system
Sets or clears the system of one line. The system is part of the line's device tag when the tag pattern uses {System}. Changing it never changes a tag already assigned. A line on a contracted version is locked, as for any other line field.
Path Parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | integer | Yes | The project's ID |
roomId | integer | Yes | The room the line is in |
itemId | integer | Yes | The line item's ID |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
cadSystemId |
integer or null | No | The system to set, from GET /api/v1/cad-systems. null, or leaving the field out, clears it. |
Example Request
{
"cadSystemId": 3
}
Response (200 - Success)
{
"success": true,
"data": {
"success": true,
"lineItemId": 789,
"roomId": 123,
"productId": null,
"make": null,
"model": null,
"part": null,
"description": null,
"cadSystemId": 3,
"message": "Line item system set",
"timestamp": "2026-10-11T15:30:00Z"
},
"message": null,
"timestamp": "2026-10-11T15:30:00Z"
}
When the system is cleared, cadSystemId is null and the inner message is Line item system cleared.
Error Responses
| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_FAILED | cadSystemId is not one of your systems, for example System 99 not found. |
| 400 | UNKNOWN_FIELDS | The body names a field other than cadSystemId |
| 403 | READ_ONLY_ACCESS | The caller has read-only access |
| 404 | LINE_ITEM_NOT_FOUND | The line item does not exist in that project and room |
| 409 | LOCKED_VERSION | The line's project version is contracted and locked — see Replace Equipment for the body shape. Nothing was changed. |
POST /api/v1/projects/{projectId}/items/set-system
Sets one system on many lines of a project in one save, or clears it from all of them. The lines may be in any room. IDs that are not lines of this project are skipped. Every room involved is checked first, so a line in a contracted version refuses the whole request and nothing changes.
Path Parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | integer | Yes | The project's ID |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
itemIds |
array of integer | Yes | The line item IDs to set. Must list at least one. |
cadSystemId |
integer or null | No | The system to set on every listed line. null, or leaving the field out, clears it. |
Example Request
{
"itemIds": [789, 790, 815],
"cadSystemId": 5
}
Response (200 - Success)
{
"success": true,
"data": {
"updated": 3
},
"message": "System set on 3 line item(s)",
"timestamp": "2026-10-11T15:30:00Z"
}
updated is how many of the listed IDs are lines of this project. All of them now carry the system.
Error Responses
| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | itemIds is missing or empty |
| 400 | VALIDATION_FAILED | cadSystemId is not one of your systems |
| 400 | UNKNOWN_FIELDS | The body names a field other than itemIds and cadSystemId |
| 403 | READ_ONLY_ACCESS | The caller has read-only access |
| 409 | LOCKED_VERSION | A listed line is in a contracted version. Nothing was changed. |
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/{projectId}/device-tags/assign
Creates the units of every equipment line in the project's active version and gives each untagged unit its device tag, the same as Assign Device Tags in the project's Actions menu. A unit is tagged only when every part of the tag pattern can be filled in: a unit whose product has no device category, or whose line has no system while the pattern uses {System}, stays untagged. Existing tags never change, so running it twice assigns nothing the second time. To read the tags, list a line's units; see the Line Item Attributes API.
Path Parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | integer | Yes | The project's ID |
Example Request
POST /api/v1/projects/42/device-tags/assign
Authorization: Bearer YOUR_JWT_TOKEN
The request has no body.
Response (200 - Success)
{
"success": true,
"data": {
"unitsSeen": 42,
"tagsAssigned": 40
},
"message": "Units seen 42, tags assigned 40",
"timestamp": "2026-10-11T15:30:00Z"
}
| Field | Type | Description |
|---|---|---|
unitsSeen | integer | The units of the active version's equipment lines, counted after the run |
tagsAssigned | integer | The units that had no tag, or did not exist, before the run and have a tag now |
Error Responses
| Status | Code | Description |
|---|---|---|
| 403 | READ_ONLY_ACCESS | The caller has read-only access |
| 404 | PROJECT_NOT_FOUND | The project does not exist |
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.
