Replace Equipment
Send the equipment list you want a room to hold. AVstackr compares your list to the room, then adds, updates and deletes lines until the room matches it. A second endpoint does the same for several rooms at once. It touches only the rooms you name.
Use these endpoints when the real equipment list lives outside AVstackr, in an ERP, a spreadsheet or a design tool, and AVstackr has to follow it.
These are not CRUD endpoints. You are not telling us to add, update or delete anything. You send the list you want the room to end up with, and we work out which of those three to do for each line.
AVstackr does have CRUD endpoints for line items, in the Projects API: POST, PATCH and DELETE on a single line. Reach for those when you know exactly which line changed. Reach for these two when your own system owns the whole list and wants ours to match it.
The difference shows up in the work. Under CRUD your integration has to compare its list against ours, decide what changed, then fire one request per change, each able to fail on its own and leave the room half done. Here you send one list in one request. We do the rest:
- Send a line the room already holds, and we update it.
- Send a line the room does not hold, and we add it.
- Leave out a line the room holds, and we delete it.
We do all of it in one write. If any part of your request is bad, we save none of it. Working out that difference between your list and the stored one is called a reconcile.
Auth: Api-Key header, the same as every other line item endpoint. See Authentication. Both endpoints write, so we turn down a read-only key with 403 and the code READ_ONLY_ACCESS.
PUT /api/v1/projects/{projectId}/rooms/{roomId}/items
Replace one room's equipment list.
Request Body
Send a flat items list to set the equipment and nothing else. That is the common case:
{
"items": [
{ "productId": 39022, "quantity": 2 },
{ "productId": 33155, "quantity": 1 }
]
}
Send sections instead when you also want to set the headings that group the room's equipment. A divider is a named heading row inside a room's equipment list: "Head End", "Field Devices", "Audio". It prints as a section heading with its own subtotal on estimates, proposals and exports. See Bundles & Dividers for what they look like in the app.
Each section name in your payload becomes one divider, in the order you send them, and that section's items sit beneath it. We delete every divider the room had before and rebuild them from your payload, so the new dividers get new IDs. Re-read the room after a sections push if you store divider IDs:
{
"sections": [
{ "name": "Head End", "items": [ { "productId": 39022, "quantity": 1 } ] },
{ "name": "Field Devices", "items": [ { "productId": 33155, "quantity": 3 } ] }
]
}
Send items or send sections. Send both and we reject the request with INVALID_SHAPE. Send neither and we do the same.
A flat items list keeps every divider the room already has. It never adds one and never deletes one. The dividers do move. Each one re-anchors above the first of its lines that survived the push, so it still heads the same equipment. A divider that lost all its lines parks at the end of the room. Reach for sections only when you mean to redraw the headings themselves.
Send "items": [] and we empty the room. We delete every line in it, along with each line's cable assignments and its line item attribute entries. The room's dividers stay.
Sending "sections": [] is not the way to clear the headings. We reject an empty sections list with INVALID_SHAPE and the message "an empty sections list is ambiguous — push a flat items list to leave sectioning alone, or manage dividers via the divider endpoints." One section with an empty items list is fine: it creates the heading with nothing under it.
Item Fields
Every field except quantity is optional. What we do with a field you leave out depends on whether the line already exists:
- On a line that matches one already in the room, we keep the stored value. Leaving out
notesdoes not erase the notes. - On a new line, we leave the field empty, because there is nothing stored to keep.
productCostEachis the one exception. Leave it out on a new line and we snapshot the catalog price for you, at the project's currency rate.
Leaving a field out will not erase a value on a matched line. Use clearFields, described in the table below.
Every money and hours field is per unit, not per line. A line with "quantity": 4 and "laborHours": 2 carries 8 hours in total, and its extended price is the unit price times 4.
| Field | Type | Required | Description |
|---|---|---|---|
quantity |
integer | Yes | How many of this item the room holds (1-10000). Required on every item, including an item you are only updating. There is no default. We reject an item without it, using INVALID_SHAPE. The alternative would be to quietly set it to 1, which would shrink a 12-unit line and still report the push as a success |
productId |
integer | No | The ID of a product in your catalog. It names the line on its own, so you can leave make, model and part out when you send it. Get the ID from GET /api/v1/products, where it is the id field on each product. See Products API |
make |
string | No | Manufacturer. Pair make with model or part to name a product when you do not have its productId. On a line that already exists, this field only finds the line. We never write it to the line |
model |
string | No | Model. Goes with make. Same rule: it finds the line, and we never write it to the line |
part |
string | No | Part number. Goes with make. Same rule: it finds the line, and we never write it to the line. See How We Match a Line below for what it does to a match |
description |
string | No | Line description. Unlike make, model and part, we do write this one to a matched line. On a new line that found a catalog product, the catalog's description wins and we drop the one you send |
price |
decimal | No | Sell price for one unit, replacing the price this line would otherwise get from cost plus markup. Leave it out on a new line and the line carries no price of its own |
productCostEach |
decimal | No | Your cost for one unit, replacing the cost this line would otherwise get from the catalog. Leave it out on a new line and we snapshot the catalog price at the project's currency rate. On a matched line, a cost that differs from the stored one also clears any pinned price — see the callout below |
productMarkup |
decimal | No | Markup for this line as a percentage, so 25 means 25 percent and sells a $100 cost at $125. Replaces your organization's default markup. Leave it out, or send 0, and the line follows the default |
laborHours |
number | No | Install labor hours for one unit of this item |
configurationHours |
decimal | No | Configuration hours for one unit of this item |
programmerHours |
decimal | No | Programming hours for one unit of this item |
notes |
string | No | Notes or comments for this item |
equipmentSource |
string | No | Equipment source or vendor name: who you buy this line from. Procurement groups the buying worklist by it |
poNumber |
string | No | Purchase order reference |
quoteReference |
string | No | Vendor quote reference |
isOwnerFurnished |
boolean | No | Set to true when the client supplies the equipment themselves. The line's material cost counts as $0, and it still bills its labor, configuration and programming hours normally |
clearFields |
array | No | Names of fields to blank out on a matched line. Leaving a field out keeps its stored value, so this is the only way to set one back to empty. Accepts price, productMarkup, laborHours, configurationHours and programmerHours. We read the names loosely, so productMarkup and product_markup both work. We reject an item that clears a field and sends it a value at the same time. On a new line it does nothing, because there is nothing stored to erase. productCostEach is off the list on purpose: an empty cost counts as $0 downstream, which is not what "clear" means. Change a cost by sending a new value |
cables |
array | No | The complete cable list for this line. What you send replaces the line's cables outright. We do not merge the two lists. Leave the field out to keep the cables as they are, or send [] to clear them. For the shape of each entry, see Cable Object Shape in the Projects API |
make, model and part are how a replace finds an existing line. We never save them onto it. Send a corrected model for a line already in the room and nothing changes. Worse, the new spelling may not find the line at all. Then we delete the old line, add a new one in its place, and everything attached to the old line goes with it.
To correct a line's make, model or part, use PUT /api/v1/projects/{projectId}/rooms/{roomId}/items/{itemId} in the Projects API.
Someone in AVstackr can pin a line's price by hand. That price then holds, whatever the cost and markup say.
If your item says nothing about price but sends a productCostEach that differs from the stored one, we clear that pin and derive the rate from your new cost and the markup. Echoing back the same cost is not a change, so a deliberate pin survives a round-trip.
To change a cost and keep a pinned price, send the price you want alongside it. A price you send always wins.
Response (200 OK)
{
"success": true,
"data": {
"success": true,
"rooms": [
{
"roomId": 1738,
"roomName": "Layout Demo Room",
"added": 1,
"updated": 0,
"removed": 0,
"lineItems": [
{
"id": 16399,
"productId": 39022,
"make": "AJA",
"model": "Ki Pro Ultra Plus",
"part": "Ki Pro Ultra Plus",
"description": "File-based 4K/HD recorder and player for SDI workflows",
"quantity": 2,
"productCostEach": null,
"productMarkup": null,
"laborHours": null,
"configurationHours": null,
"programmerHours": null,
"notes": null,
"equipmentSource": null,
"order": 1,
"price": null,
"poNumber": null,
"quoteReference": null,
"isOwnerFurnished": false
}
]
}
],
"orphanedCanvasDevices": [],
"timestamp": "2026-08-27T18:13:21.1135959Z"
},
"message": null,
"timestamp": "2026-08-27T18:13:21.1534529Z"
}
We wrap the reply twice. The outer success, message and timestamp are the standard wrapper every AVstackr endpoint returns. data holds the result of the push itself, which carries its own success and timestamp.
added, updated and removed count what the push changed in that room. lineItems is the room's whole list after the push, not just the lines that changed, so you can store it as your new picture of the room. order on each line is its top-to-bottom position in the room. It follows the order you sent the items in, and you do not set it yourself.
orphanedCanvasDevices lists the schematic drawing blocks left with no equipment behind them. We Never Delete Canvas Devices below explains it. It sits at the top of data, not inside a room, and covers every room in the request.
Success is 200, not 201, even when the push adds lines. One request can add, update and delete at once, so there is no single new thing to point at.
PUT /api/v1/projects/{projectId}/items
Replace the equipment lists of several rooms in one write. Each entry under rooms takes the same body as the single-room endpoint above: either items or sections, never both. Two rooms in the same request can use different forms, as the example below does.
Request Body
{
"rooms": [
{
"roomId": 1741,
"items": [
{ "productId": 39022, "quantity": 2 },
{ "productId": 33155, "quantity": 1 }
]
},
{
"roomId": 1742,
"sections": [
{ "name": "Head End", "items": [ { "productId": 39022, "quantity": 1 } ] },
{ "name": "Field Devices", "items": [ { "productId": 33155, "quantity": 3 } ] }
]
}
]
}
Response (200 OK)
The same reply shape as the single-room endpoint, with one entry in rooms for each room you sent. Each entry carries its own added, updated and removed counts and its own full lineItems list. orphanedCanvasDevices stays a single list covering every room in the request.
This endpoint replaces only the rooms you name. A room you leave out of rooms keeps its equipment exactly as it was. Send three rooms on a ten-room project and the other seven stay as they are.
List each room at most once. We reject the same roomId twice in one request with INVALID_SHAPE, because the second list would delete what the first one just added.
Read the Room Before You Write It
Your list wins. Anything the room holds that you left out, we delete, with no warning and no confirmation step.
That makes a partial list dangerous. Send one item to a room that holds twenty, and you have just deleted nineteen lines. The response calls that a success, because it is one. We did exactly what you asked.
The safe pattern is always three steps:
- Read the room with
GET /api/v1/projects/{projectId}/rooms/{roomId}/items. See the Projects API. - Apply your changes to that list, in your own code.
- Send the whole edited list back with a
PUT.
Never build the payload from just the lines you know changed.
Step 3 means rebuilding each item from the fields in the table above, not posting back what you read. A read hands you fields this endpoint does not accept, and we reject a field we do not recognize rather than ignore it. Send back a room's lineItems unchanged and you get:
400 UNKNOWN_FIELDS — room 1741.items[0]: unknown field(s) [id, order]
Drop id and order — the read endpoint echoes them back, but they are read-only here. Every other field name matches: this endpoint takes part and productCostEach, the same names GET /api/v1/projects/{projectId}/rooms/{roomId}/items returns.
Example
Read room 1738, change one quantity, then push the whole room back. The read returns two lines:
GET /api/v1/projects/812/rooms/1738/items
Api-Key: YOUR_API_KEY
{
"success": true,
"data": {
"items": [
{ "id": 16399, "productId": 39022, "make": "AJA", "model": "Ki Pro Ultra Plus", "quantity": 2 },
{ "id": 16400, "productId": 33155, "make": "Shure", "model": "MXA910", "quantity": 1 }
]
}
}
To raise the second line to 3, send both lines back, rebuilt from the documented fields:
PUT /api/v1/projects/812/rooms/1738/items
Api-Key: YOUR_API_KEY
Content-Type: application/json
{
"items": [
{ "productId": 39022, "quantity": 2 },
{ "productId": 33155, "quantity": 3 }
]
}
The response reports added: 0, updated: 2, removed: 0. Both lines keep their IDs. Leaving the first item out instead would have deleted it.
A replace does not know or care where a line came from. Say an engineer adds a line in the AVstackr app after your last read. Your list does not carry it, so the push deletes it. Read the room right before you write it, not from a copy you cached this morning.
We Edit Matching Lines in Place
When a line in your list matches a line already in the room, we edit that line where it stands. We do not replace it. It keeps its database id, and everything hanging off that id survives the push:
- Notes, labor hours, configuration hours, programming hours, PO number and quote reference, unless your item sends new values for them.
- Cable assignments, unless your item sends a
cableslist. - Line item attributes, the key/value entries described in the Line Item Attributes API.
This matters because the alternative would destroy all of it. If a push deleted every line and added them back, each new line would get a new id, and the attributes recorded against the old ids would be gone. Push the same list twice and you would wipe the room's history both times.
We add the lines in your list that match nothing. We delete the lines in the room that match nothing in your list, and a delete takes that line's cable assignments and its line item attribute entries with it. Per-unit attributes are the exception. A unit hangs off the project, room, make and model rather than off a line item id, so we keep the serial numbers and MAC addresses recorded against individual units when the line goes.
How We Match a Line
We match in two steps.
First, we look inside the room. An item carrying a productId claims a room line with that same productId. An item carrying no productId claims a room line whose make, model and part all match its own. That comparison ignores capitalization and surrounding spaces, so "Shure" and "shure " are the same manufacturer. It does not ignore punctuation, so MXA-910 and MXA910 are two different lines here. An item can only claim a line in its own room, never one from another room in the same request.
Then, for anything left over, we look in the catalog. We reject a productId that is not in your catalog with PRODUCT_NOT_FOUND. We look up a name like this:
makemust match a catalog product's manufacturer. An item with nomakematches nothing.- You must also send
modelorpart, or both.makeon its own matches nothing. - Either value you send can hit either value on the catalog product. Many catalogs store the manufacturer's SKU in the model column, and many store it in the part column. So
makepluspart, with nomodel, is a legal way to name a product. - Catalog matching is looser than room matching. It ignores capitalization, spaces, hyphens, underscores and accents. Here
MXA-910andMXA910are the same product. - Because either value can hit either column, adding
partalongsidemodelcan only find the same products or more of them. It never narrows a catalog lookup, and it can turn a single match intoAMBIGUOUS_PRODUCT.
Three more rules worth knowing:
- We keep duplicate lines. We never merge them. Two lines of the same product in one room are legitimate, with different notes and different PO numbers. Send the same
productIdtwice in one payload and you get two lines, not one line with the quantities added together. Send it once and we delete the second existing line. - A line whose catalog product was deleted still round-trips. Say a room holds a line for a product someone has since removed from your catalog. Read the room, push it back, and it still works. The line matches inside the room and never reaches the catalog lookup.
- These endpoints cannot invent a product. We reject a name that matches nothing in the room and nothing in the catalog with
PRODUCT_NOT_FOUND. Add the product to your catalog first. See Products API.
We Never Delete Canvas Devices
Equipment lines and schematic drawings are two separate things in AVstackr. An engineer draws a project's signal flow on the schematics canvas, placing each device block by hand and wiring it to the other blocks by hand. The blocks stand for the room's equipment, but they are not the same records.
A replace only ever touches the equipment list. It never deletes, moves or unwires a device block. Drop a product off a room's list and the block drawn for it stays where the engineer put it, wiring intact. We do not throw away hours of layout work because an outside system changed a line.
That leaves the drawing showing a device the room no longer buys, and someone has to decide what to do about it. So the response lists every one of them under orphanedCanvasDevices:
{
"deviceId": "dev-DE0F2658",
"roomId": 1741,
"roomName": "Test Room A",
"manufacturer": "APC",
"partNumber": "SCL500RM1UC",
"page": 1
}
| Field | Type | Description |
|---|---|---|
deviceId |
string | ID of the device block on the canvas |
roomId |
integer | The room the block belongs to |
roomName |
string | That room's name, so you can name it in an alert without a second lookup |
manufacturer |
string | Manufacturer shown on the block |
partNumber |
string | Part identifier shown on the block |
page |
integer | Which schematic page the block sits on |
The list covers every room in the request, and only the rooms in the request. We report a block only when the room has no equipment line left at all for that device's manufacturer and model. Cut a line's quantity from three to one and we report nothing. Delete one of two lines for the same product and we report nothing.
An empty array normally means the push left nothing behind. It can also mean the check itself could not run. We run the check after we have saved the write, and if the check fails we log it and carry on rather than turn a good write into an error. Read an empty array as "nothing to report", not as proof the canvas is clean.
All or Nothing
We save the whole request or we save none of it. There is no partial success.
We write nothing until we have checked every item in every room: the shape of the payload, the limits, and every product name landing on exactly one catalog product. One bad item in room five kills the whole push, and rooms one through four keep exactly what they had. The same goes for a conflict we find part-way through. A 409 also means we wrote nothing.
A rejected request never leaves a project half-updated, so you can always fix the payload and send the whole thing again.
Limits
- 500 items per room. With
sections, this counts every item across all of that room's sections, not each section on its own. - 100 rooms per request.
- 5,000 items in total across all rooms.
quantitybetween 1 and 10,000.
Break any of the first three and you get LIMIT_EXCEEDED. A quantity outside its range gets INVALID_SHAPE. Either way we write nothing, so split the work across several requests and send them again.
Errors
A failure returns the standard error envelope, the reply shape every AVstackr endpoint uses when something goes wrong:
{
"success": false,
"error": {
"code": "...",
"message": "...",
"details": null
},
"timestamp": "..."
}
The message points at the exact item at fault, so a rejected push tells you which line to fix. We count positions from zero, matching the payload you sent, so you can walk straight to the line.
The two endpoints address that line differently, because they know the room differently. The project-wide endpoint counts your rooms, since you listed them:
rooms[1].items[0].quantity is required — send the line's quantity on every item, there is no default.
The single-room endpoint names the room outright, since it sits in the URL:
room 1741.sections[1].items[0].quantity must be between 1 and 10000 (0).
| Code | HTTP Status | Description |
|---|---|---|
INVALID_SHAPE |
400 | The payload is not built the way the endpoint expects. Both items and sections, or neither. An empty sections list. A missing quantity, or one outside 1-10000. The same roomId listed twice. Or a literal null sitting where an object should be, as in "items": [null], usually a serializer writing a gap into a list |
UNKNOWN_FIELDS |
400 | The payload carries a key the endpoint does not recognize, usually a typo such as quantitiy, or a field copied back from a read, such as id or order. We refuse the request rather than ignore the key, so a misspelled quantity can never quietly reset a line |
LIMIT_EXCEEDED |
400 | The request is over one of the limits above |
AMBIGUOUS_PRODUCT |
400 | make/model matched more than one catalog product, so we cannot tell which one you mean. Catalog matching ignores capitalization, spaces, hyphens and accents, so AB-100 and AB100 can be two real products that both match. The message lists the matching product IDs. Send one of them as productId to settle it |
VALIDATION_FAILED |
400 | Another validation failure |
READ_ONLY_ACCESS |
403 | The key or user making the request has read-only access. Both endpoints write, so we refuse the request with "You don't have permission. Contact your administrator." |
PRODUCT_NOT_FOUND |
404 | A productId or make/model matched nothing. Either the productId is not in this organization's catalog, or the name matches no product. An item that names no product at all, only a quantity, lands here too |
ROOM_NOT_FOUND |
404 | A roomId is not in this project. Either it does not exist, or it belongs to a different project or organization |
CONCURRENT_MODIFICATION |
409 | Another write landed while we were working this request out, either someone editing in the app or another API call. A line this push meant to update had already been deleted. We wrote nothing. Read the room again, rebuild your list from what comes back, and send it again |
When a customer approves an estimate, that version of the project becomes Contracted: a signed, frozen record of what was sold. Its equipment lines turn read-only in the app and through the API, so the signed numbers cannot drift. From then on, changes go through a Change Order, which sits on top of the contract and records what changed and what it costs.
We refuse a replace against a contracted version with 409. The response is the same locked-version body every other write path returns:
{
"errorCode": "LOCKED_VERSION",
"userFacingMessage": "This project version is locked. Create a Change Order to modify it.",
"suggestedAction": "create_change_order",
"versionId": 17,
"contractId": 88
}
Note this is not the error.code shape the table above uses. Check for errorCode when you handle a 409. Retrying will not help. The version stays locked until someone raises a Change Order. See Change Orders, Project Versions, and Error Handling.
