Products API
Create, read, update, and delete products in your organization's catalog.
GET /api/v1/products
Returns a paginated list of products from your organization's catalog. Use query parameters to filter and search.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer | 1 | Page number |
pageSize |
integer | 20 | Items per page (max 100) |
search |
string | Search by Part# or Model | |
category |
string | Filter by category | |
productType |
string | Filter by product type | |
activeOnly |
boolean | true | Only return active products |
Example Request
GET /api/v1/products?page=1&pageSize=10&search=microphone
Authorization: Bearer YOUR_JWT_TOKEN
Response (200 - Success)
{
"success": true,
"data": {
"products": [
{
"id": 123,
"make": "Shure",
"model": "MXA910",
"description": "Ceiling array microphone",
"price": 2999.00,
"productType": "Microphone",
"part": "MXA910-60CM",
"category": "Audio",
"isActive": true
}
],
"pagination": {
"currentPage": 1,
"pageSize": 10,
"totalCount": 150,
"totalPages": 15,
"hasPrevious": false,
"hasNext": true
}
},
"timestamp": "2024-12-25T12:00:00Z"
}
GET /api/v1/products/{id}
Returns a single product by its unique identifier.
Response (200 - Success)
{
"success": true,
"data": {
"product": {
"id": 123,
"make": "Shure",
"model": "MXA910",
"description": "Ceiling array microphone",
"longDescription": "Professional ceiling array microphone with 60cm pickup pattern...",
"enrichedDescription": "AI-generated detailed description...",
"price": 2999.00,
"vendor": "TD SYNNEX",
"imageUrl": "https://example.com/mxa910.jpg",
"productType": "Microphone",
"part": "MXA910-60CM",
"category": "Audio",
"roomType": "Conference Room",
"tags": "microphone,ceiling,array,beamforming",
"specifications": "60cm pickup pattern, 8 lobes, PoE+",
"isActive": true,
"dateUpdated": "2024-12-20T10:30:00Z",
"hasEmbedding": true
}
},
"timestamp": "2024-12-25T12:00:00Z"
}
Error Responses
| Status | Code | Description |
|---|---|---|
| 404 | PRODUCT_NOT_FOUND |
Product with specified ID does not exist |
Variant Group Fields
A variant group links catalog products that are the same device in different finishes. Each product in the responses of GET /api/v1/products and GET /api/v1/products/{id} carries four fields that describe its group. All four are null when the product is in no group.
| Field | Type | Description |
|---|---|---|
variantGroupId |
integer or null | The ID of the product's variant group |
variantGroupName |
string or null | The name of the group, for example C-IC6LP |
variantOption |
string or null | The group's option name, which says what differs between its members, for example Colour |
variantLabel |
string or null | This product's value for that option, for example White |
A product that is in a group, with its other fields left out:
{
"id": 412,
"make": "Biamp",
"model": "C-IC6LP-W",
"variantGroupId": 12,
"variantGroupName": "C-IC6LP",
"variantOption": "Colour",
"variantLabel": "White"
}
The four fields are read-only on products. To change a product's group or label, use the Variant Groups API.
PUT /api/v1/products/{id}refuses a body that names any of the four fields. It returns400 UNKNOWN_FIELDSand changes nothing.POST /api/v1/productsdoes not read the four fields. A new product is always created in no group.- The make of a product that is in a group cannot be changed.
PUT /api/v1/products/{id}with a differentmakereturns400 VARIANT_GROUP_RULEwith the problemUngroup this product before changing its make.Remove the product from its group first. A change of letter case or of surrounding spaces is the same make, so it is accepted.
Device Category Fields
A product's device category is the category its device tags are named from, such as Display / DISP. It is separate from the free-text category and productType. Each product in the responses of GET /api/v1/products and GET /api/v1/products/{id} carries the fields below. cadCategoryId and cadCategory are null when the product is unclassified, and cadBlocks is empty when the product has no CAD block of its own.
| Field | Type | Description |
|---|---|---|
cadCategoryId |
integer or null | The ID of the product's device category, from GET /api/v1/cad-categories |
cadCategory |
object or null | The category's id, name and abbreviation |
cadBlocks |
array | The product's own CAD block for each view it sets, in view order: view, id (the block's ID), name, blockName and visibilityState (null for the block's default state). A view it does not list uses the category's default block. A block archived since it was chosen is still listed but no longer used. Set with PUT /api/v1/products/{id}/cad-blocks below. |
A classified product with a block for the plan view, its other fields left out:
{
"id": 123,
"make": "Samsung",
"model": "QM65C",
"cadCategoryId": 7,
"cadCategory": { "id": 7, "name": "Display", "abbreviation": "DISP" },
"cadBlocks": [
{ "view": "plan", "id": 9, "name": "Display wall", "blockName": "AVS_DISP_WALL", "visibilityState": "TOP" }
]
}
POST /api/v1/products and PUT /api/v1/products/{id} take cadCategoryId. On PUT, a null or missing value leaves the category unchanged, so clear it with PUT /api/v1/products/{id}/category below. A cadCategoryId that is not one of your categories returns 404 NOT_FOUND and nothing is written. The category list itself is in the Device Tags API.
POST /api/v1/products
Creates a new product in your organization's catalog.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
make |
string | Yes | Manufacturer name (max 100 chars) |
model |
string | Yes | Model number (max 100 chars) |
description |
string | No | Short description (max 255 chars) |
longDescription |
string | No | Extended description (max 2000 chars) |
price |
decimal | No | MSRP price (0-9,999,999) |
vendor |
string | No | Distributor or vendor name |
part |
string | No | Part number / SKU |
productType |
string | No | Equipment type (e.g., Microphone, Display) |
category |
string | No | Product category (e.g., Audio, Video) |
roomType |
string | No | Suggested room type for this product |
imageUrl |
string | No | URL to product image |
tags |
string | No | Comma-separated tags for search |
specifications |
string | No | Technical specifications text |
cadCategoryId |
integer | No | The device category to classify the product with (see Device Category Fields above). Leave it out to create the product unclassified. |
isActive |
boolean | No | Whether product is active (default: true) |
Response (201 - Created)
{
"success": true,
"productId": 124,
"message": "Product created successfully",
"timestamp": "2024-12-25T12:00:00Z"
}
PUT /api/v1/products/{id}
Updates an existing product. All fields from the POST request body are accepted.
DELETE /api/v1/products/{id}
Permanently removes a product from your catalog.
Response (200 - Success)
{
"success": true,
"productId": 123,
"message": "Product deleted successfully"
}
PUT /api/v1/products/{id}/metadata
Replace a product's connection metadata — input/output ports, device type, category, and power requirements. Used to configure products for schematic wiring.
Request Body
Send a full ConnectionMetadata JSON object. Port types are validated against the port type registry — use GET /api/v1/products/port-types to see valid options; an invalid one fails the whole request (see Error Responses below).
A port states its PoE with two fields, and it carries at most one of them:
poeSource— the tier this jack supplies to whatever plugs into it:"PoE","PoE+","PoE++", or"none"to state that it supplies nothing. Omit it, or sendnull, when the jack was never described.poeDraw— the tier this jack draws from the far end. This jack is the one being powered.
Neither field fails the request. Both are checked against the port type registry and saved as null when the connector cannot carry PoE — only rj45 and ethercon can. A port that arrives with both set keeps poeDraw and has its poeSource cleared: a jack being powered is what decides which way the watts run.
The device-level power is derived from the jacks on save. When any port draws, power becomes the strongest tier drawn, whatever you sent. When no port draws, the power you sent is kept as-is — and a PoE tier sent with no drawing jack behind it raises a notice on the wiring report, because nothing says which jack is fed. In the example below, the AVB/Ctrl jack is what backs the "power": "PoE+".
{
"category": "Processing",
"deviceType": "Digital Signal Processor",
"power": "PoE+",
"inputPorts": [
{ "portType": "rj45", "innerLabel": "AVB/Ctrl", "poeDraw": "PoE+" },
{ "portType": "dante_rj45", "innerLabel": "DANTE IN 1" },
{ "portType": "xlr_3pin_female", "innerLabel": "MIC 1" }
],
"outputPorts": [
{ "portType": "dante_rj45", "innerLabel": "DANTE OUT 1" },
{ "portType": "speakon_nl4", "innerLabel": "AMP OUT" }
]
}
Response (200 — Success)
{
"success": true,
"data": {
"success": true,
"productId": 157,
"message": "Product metadata updated"
}
}
Error Responses
| Status | Code | Description |
|---|---|---|
| 400 | INVALID_PORT_TYPES | One or more port types are not valid |
| 404 | PRODUCT_NOT_FOUND | Product ID not found for your tenant |
POST /api/v1/products/{id}/metadata/apply-to-variants
Copies this product's saved connection metadata onto other members of its variant group. The copy replaces the metadata of each target. Each copy keeps the target's own make and model, and the target's own description when it has one.
The product in the URL is the source. It must be in a variant group, and it must have connection metadata. The endpoint copies the metadata that is saved, so save the source first with PUT /api/v1/products/{id}/metadata. Devices already placed on drawings keep their own copy of the metadata.
Path Parameters
| Field | Type | Required | Description |
|---|---|---|---|
id |
integer | Yes | The catalog product ID of the source product, whose metadata is copied |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
targetProductIds |
array of integer | No | The product IDs to copy onto. Each one must be another member of the source's variant group. An ID that is not one is skipped, not refused. When the list is empty or left out, nothing is copied and the response is still 200. |
includeBlockSkin |
boolean | No | Also copies the source's block skin, which is the custom device block pinned to the product. The default is true. When the source has no block skin, each target keeps its own. |
Example Request
{
"targetProductIds": [413, 77],
"includeBlockSkin": true
}
Response (200 - Success)
{
"success": true,
"data": {
"sourceProductId": 412,
"updatedProductIds": [413],
"skippedProductIds": [77],
"skinNotCopied": false
},
"message": "Metadata applied to 1 variants; 1 skipped",
"timestamp": "2026-10-08T15:30:00Z"
}
| Field | Type | Description |
|---|---|---|
sourceProductId |
integer | The product the metadata was copied from |
updatedProductIds |
array of integer | The targets that received the metadata |
skippedProductIds |
array of integer | The requested targets that were left untouched, because they are not another member of the source's variant group. The source's own ID is skipped too. |
skinNotCopied |
boolean | true when includeBlockSkin was true and the source has no block skin, so the targets kept their own. false when the skin was copied or was not requested. |
Error Responses
| Status | Code | Description |
|---|---|---|
| 400 | VARIANT_GROUP_RULE | The source product has no connection metadata. Nothing is copied. The body is described under Errors in the Variant Groups API. |
| 403 | READ_ONLY_ACCESS | The caller has read-only access |
| 404 | PRODUCT_NOT_FOUND | The source product does not exist, or it is in no variant group |
PUT /api/v1/products/{id}/category
Sets or clears one product's device category. This is the way to clear a category: PUT /api/v1/products/{id} reads a null cadCategoryId as "unchanged". Existing device tags of the product's units never change; units tagged afterwards use the new category.
Path Parameters
| Field | Type | Required | Description |
|---|---|---|---|
id |
integer | Yes | The catalog product ID |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
cadCategoryId |
integer or null | No | The category to set. null, or leaving the field out, clears the category. Any other field name is refused with 400 UNKNOWN_FIELDS, so a misspelling cannot clear a category by accident. |
Example Request
{
"cadCategoryId": 7
}
Response (200 - Success)
{
"success": true,
"data": {
"productId": 123,
"cadCategoryId": 7,
"cadCategory": { "id": 7, "name": "Display", "abbreviation": "DISP" }
},
"message": "Category set to Display",
"timestamp": "2026-10-11T15:30:00Z"
}
When the category is cleared, cadCategoryId and cadCategory are null and the message is Category cleared.
Error Responses
| Status | Code | Description |
|---|---|---|
| 400 | UNKNOWN_FIELDS | The body names a field other than cadCategoryId |
| 403 | READ_ONLY_ACCESS | The caller has read-only access |
| 404 | NOT_FOUND | The product or the category does not exist. The message says which, for example Category 99 not found. |
POST /api/v1/products/set-category
Sets one device category on many products in one save, or clears it from all of them. IDs that are not your products are skipped.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
productIds |
array of integer | Yes | The products to set. Must not be empty. |
cadCategoryId |
integer or null | No | The category to set on every listed product. null, or leaving the field out, clears it. |
Example Request
{
"productIds": [123, 124, 130],
"cadCategoryId": 7
}
Response (200 - Success)
{
"success": true,
"data": {
"updatedCount": 3,
"cadCategoryId": 7,
"cadCategory": { "id": 7, "name": "Display", "abbreviation": "DISP" }
},
"message": "Category set on 3 products",
"timestamp": "2026-10-11T15:30:00Z"
}
updatedCount is how many of the listed IDs are your products. All of them now carry the category, including any that already had it.
Error Responses
| Status | Code | Description |
|---|---|---|
| 400 | INVALID_INPUT | productIds is missing or empty |
| 400 | UNKNOWN_FIELDS | The body names a field other than productIds and cadCategoryId |
| 403 | READ_ONLY_ACCESS | The caller has read-only access |
| 404 | NOT_FOUND | cadCategoryId is not one of your categories. Nothing is written. |
PUT /api/v1/products/{id}/cad-blocks
Replaces the product's own CAD block for each view. A view you leave out is cleared and uses the category's default block again, or a generic box when the category has none; an empty array clears every view. The block library and the views are in the CAD Blocks API.
Path Parameters
| Field | Type | Required | Description |
|---|---|---|---|
id |
integer | Yes | The catalog product ID |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
assignments |
array of object | Yes | One item per view the product sets, each view at most once: view (plan, ceiling, elevation or schematic, any case), blockId, and an optional visibilityState, one of a dynamic block's states (null or empty for the block's default state). Any other field name, on the body or on an item, is refused with 400 UNKNOWN_FIELDS. |
Example Request
{
"assignments": [
{ "view": "plan", "blockId": 9, "visibilityState": "TOP" },
{ "view": "elevation", "blockId": 9, "visibilityState": "FRONT" }
]
}
Response (200 - Success)
{
"success": true,
"data": [
{ "view": "plan", "blockId": 9, "blockName": "AVS_DISP_WALL", "name": "Display wall", "visibilityState": "TOP" },
{ "view": "elevation", "blockId": 9, "blockName": "AVS_DISP_WALL", "name": "Display wall", "visibilityState": "FRONT" }
],
"message": "CAD blocks set for 2 view(s)",
"timestamp": "2026-10-12T15:30:00Z"
}
The product's blocks as saved, in view order. An empty array returns "data": [] with the message CAD blocks cleared. The product's cadBlocks field reports the same set, with the block's ID as id.
Error Responses
| Status | Code | Description |
|---|---|---|
| 400 | INVALID_INPUT | The body or assignments is missing, or an item is null, has no view or an unknown one, has no blockId, or repeats a view |
| 400 | UNKNOWN_FIELDS | The body or an item names a field not listed above |
| 400 | BLOCK_ARCHIVED | A new or changed item names an archived block. An item that repeats the product's saved block and state for its view passes, even when the block was archived since. |
| 400 | VIEW_MISMATCH | The block does not list the item's view |
| 400 | INVALID_VISIBILITY_STATE | The block has no visibility state of that name; the message lists the ones it has |
| 403 | READ_ONLY_ACCESS | The caller has read-only access |
| 404 | PRODUCT_NOT_FOUND | The product is not one of yours |
| 404 | BLOCK_NOT_FOUND | A blockId is not one of your blocks |
GET /api/v1/products/port-types
Returns all valid port types grouped by category (Video, Audio, Data, Fiber, Power, Control). Use these IDs in the portType field when setting connection metadata.
Response (200 — Success)
{
"success": true,
"data": {
"port_types": {
"Video": [
{ "id": "hdmi_a", "name": "HDMI Type A" },
{ "id": "displayport", "name": "DisplayPort" }
],
"Audio": [
{ "id": "xlr_3pin_male", "name": "XLR 3-Pin Male" },
{ "id": "speakon_nl4", "name": "Speakon NL4" }
],
"Data": [
{ "id": "rj45", "name": "RJ-45 (Ethernet)" },
{ "id": "usb_c", "name": "USB Type-C" }
]
}
}
}