Products#
Reusable product and service items, and the categories that group them. Physical products can opt
into branch-scoped inventory tracking; services and untracked products remain catalog-only items.
Overview#
- 🧾 One catalog for products and services —
typedistinguishes them - 🗂️ Categories and sub-categories — at most one of each per item
- 🔁 Reused on invoices — selecting an item fills the line's name, description, price, tax and discount ($ or %)
- 🖼️ One image per item — JPEG, PNG, WebP or SVG up to 2 MB, served from a public URL
- 🔢 Optional per-order limit —
maxQuantitycaps how much of an item one invoice or POS cart may hold - 📦 Optional inventory tracking — tracked physical products use server-owned branch balances
- 📥 Excel import / export —
.xlsx, validated all-or-nothing - 🔒 Per merchant — a catalog is never shared between merchants
All endpoints require Authorization: Bearer <token> and X-Tenant-ID, plus a user permission:
| Permission | Endpoints |
|---|---|
invoices.view |
List, get, export, import template, list categories |
invoices.create |
Create, import (an import that updates existing products also needs invoices.manage) |
invoices.manage |
Update, status, image, delete, bulk delete, every category / sub-category write |
A missing permission returns 403 FORBIDDEN. API keys carry no user, so x-api-key calls to these endpoints are refused with 403 as well.
Merchant scope#
The merchant is taken from the access token. A merchant-scoped session may not read or write
another merchant's catalog, and passing a different merchantId returns 403 FORBIDDEN rather
than being ignored.
Platform-level tokens carry no merchant, so they must name one explicitly with the merchantId
query parameter (or, on a write, in the body).
Response shape#
Every endpoint answers with the platform envelope:
{ "success": true, "data": { }, "meta": { "page": 1, "limit": 10, "total": 42 } }{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Price must be 0 or greater" } }The product object#
{
"id": "2f1c...",
"type": "product",
"name": "Hardware Kit",
"description": "Standard equipment bundle",
"price": 480.50,
"unit": "box",
"unitLabel": "Box",
"discountValue": 5,
"discountType": "percent",
"taxRate": 8.25,
"productCode": "KIT-STD",
"status": "active",
"isFavorite": false,
"imageUrl": "https://cdn.example.com/products/tenant-1/6f1c.../2f1c.../8d0b....png",
"maxQuantity": 10,
"soldQuantity": 12,
"categoryId": "9b0e...",
"categoryName": "Hardware",
"subCategoryId": "41aa...",
"subCategoryName": "Parts",
"sku": "HW-KIT",
"trackInventory": true,
"lowQuantityThreshold": 2,
"unitCost": 275.00,
"allowZeroStockSale": false,
"onHand": 12,
"reserved": 4,
"available": 8,
"createdAt": "2026-09-23T02:55:30.000Z",
"updatedAt": "2026-09-23T02:55:30.000Z"
}| Field | Type | Notes |
|---|---|---|
type |
product | service |
Defaults to service on create |
name |
string | Required, trimmed, max 150 characters |
description |
string | null | Max 1000 characters |
price |
number | Required, 0 or greater, at most 2 decimal places |
unit |
string | null | What the price is per. One of piece, each, box, hour, day, kg, lb, ton, liter, gallon, metre, foot, mile, mil, other. Optional; aliases such as gallons or meter are stored as the code. Omitted on PUT = unchanged, null clears it. Copied onto an invoice line when the product is picked |
unitLabel |
string | null | Read-only display label for unit (Gallon) |
discountValue |
number | Defaults to 0. Read with discountType |
discountType |
percent | fixed |
Defaults to percent. A percentage is 0–100; a fixed amount is money and has no upper bound |
taxRate |
number | null | 0–100. A percentage, not a formatted string |
status |
active | inactive |
Defaults to active |
isFavorite |
boolean | Defaults to false. Pins the product as a POS tile. Not synced to Aux365 |
imageUrl |
string | null | Read-only. Public URL of the product image, or null. Set it with a multipart create/update or the image endpoints below. Each upload gets a new URL, so it can be cached indefinitely |
maxQuantity |
number | null | Most units of the product one invoice or POS cart may hold, summed across its lines. null (default) = unlimited. Greater than 0, at most 1,000,000, up to 3 decimal places (2.5 gallons). Omitted on PUT = unchanged, null clears it. Not stock: it never counts across orders. See "Per-order quantity limit" below |
soldQuantity |
number | Read-only. Units sold, see "Most sold" below |
categoryId / subCategoryId |
uuid | null | A sub-category cannot be set without its category |
sku |
string | null | Optional merchant SKU, unique per merchant ignoring case and surrounding whitespace |
trackInventory |
boolean | Physical products only. Defaults to false; a service cannot enable tracking |
lowQuantityThreshold |
number | null | Shared per-product low-stock threshold, 0 or greater, up to 3 decimal places |
unitCost |
number | null | Optional merchant cost, 0 or greater, up to 2 decimal places; never exposed by the device catalog |
allowZeroStockSale |
boolean | Defaults to false. When true, a final sale may deduct only current Available and record the rest as not deducted |
onHand / reserved / available |
number | null | Read-only aggregate across persisted location balances for tracked physical products; null for services and untracked products. available = max(0, onHand - reserved). Use the inventory balances endpoint for active-branch operational views |
price, discountValue, taxRate, maxQuantity, soldQuantity, lowQuantityThreshold,unitCost, onHand, reserved, and available are JSON numbers when present, not strings.
imageUrl and maxQuantity are local to AuxCORE and are not sent to Aux365.
A fixed discount is deliberately not capped at the price: an invoice line carries a quantity, and
the line is where the charge is finally worked out.
List products#
| Query | Notes |
|---|---|
search |
Case-insensitive partial match on name and description |
type |
all (default), product, service |
status |
all (default), active, inactive |
categoryId |
Restrict to one category; must be a valid id |
subCategoryId |
Restrict to one sub-category; must be a valid id |
favorite |
true keeps favorites only, false the rest |
view |
all (default) or most_sold — the top 10 active products, see "Most sold" below |
inventoryTracking |
all (default), tracked, not_tracked |
sort |
newest (default), name, price_asc, price_desc, most_sold |
page |
Defaults to 1 |
limit |
Defaults to 10, capped at 200 |
curl "https://uat.auxcore.net/api/v1/products?status=active&limit=200" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-Tenant-ID: your-tenant-id"{
"success": true,
"data": [ { "id": "2f1c...", "name": "Hardware Kit", "price": 480.50 } ],
"meta": { "page": 1, "limit": 200, "total": 1 }
}An unknown type, status, view, inventoryTracking, or sort value is rejected with400 VALIDATION_ERROR; use all to mean "no filter".
Most sold#
soldQuantity is a reporting ledger, separate from physical inventory balances. Its existing
reversal behavior does not restock On Hand:
- POS sales — product lines of a transaction sent with
lineItems, counted by the reporting lifecycle; - Invoices — lines carrying a
productId, counted when the invoice becomes fully paid. The portal's invoice builder setsproductIdwhen a line is picked from the catalog.
Units are summed as-is (2.5 + 3 gallons = 5.5). A partial refund leaves the count unchanged; misc
lines and lines without a productId are never counted.
For the paired-device inventory contract, use GET /api/v1/device/products; it resolves the live
device branch server-side and returns branch Available without exposing aggregate On Hand,
Reserved, or unit cost. See Mobile Product Catalog and
"Inventory".
view=most_sold returns at most 10 active products ordered by soldQuantity, then favorites, then
name — so before the first sale it is the favorites alphabetically. page is ignored andmeta.total is the number returned.
Get one product#
Returns 404 NOT_FOUND for an id outside the caller's merchant, so a missing product and another
merchant's product are indistinguishable.
Create a product#
{
"type": "product",
"name": "Hardware Kit",
"price": 480.50,
"discountValue": 5,
"discountType": "percent",
"taxRate": 8.25,
"description": "Standard equipment bundle",
"status": "active",
"isFavorite": true,
"categoryId": "9b0e...",
"subCategoryId": "41aa..."
}Responds 201 with the created product.
With an image#
To save the product and its image in one request, send multipart/form-data instead of JSON:
| Part | Content |
|---|---|
data |
The same JSON body as above, as a string |
image |
The image file — JPEG, PNG, WebP or SVG, max 2 MB |
curl -X POST https://uat.auxcore.net/api/v1/products \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-Tenant-ID: your-tenant-id" \
-F 'data={"type":"product","name":"Hardware Kit","price":480.50,"maxQuantity":10}' \
-F "image=@kit.png"The product is validated before the image is stored, so a rejected request leaves nothing behind.
The image type is read from the file's bytes, not from its name or the Content-Type the client
sends. An SVG is refused if it contains scripts, event handlers, javascript: links,<foreignObject> or references to external files. Render the image with an <img> tag (or
the platform's image view), never inline it into a page.
| Error | When |
|---|---|
400 VALIDATION_ERROR |
A field breaks one of the rules in the table above |
400 VALIDATION_ERROR |
The image is empty, over 2 MB, not a JPEG/PNG/WebP/SVG, or an unsafe SVG |
400 VALIDATION_ERROR |
The multipart data part is not a JSON object |
400 UPLOAD_ERROR |
The upload itself was refused (for example File too large (max 2MB)) |
503 IMAGE_STORAGE_UNAVAILABLE |
Image storage is not configured or did not accept the file; nothing was saved, retry |
400 VALIDATION_ERROR |
subCategoryId given without categoryId |
404 NOT_FOUND |
The category or sub-category does not exist for this merchant |
404 NOT_FOUND |
The sub-category exists but belongs to a different category |
Update a product#
Takes the same body as create and replaces every field, so send the whole object. OmittingcategoryId or taxRate clears it rather than leaving it alone. unit, isFavorite andmaxQuantity are the exceptions: omitted, they are left as they are.
The image is never touched by a JSON update. To change it, either:
- send the update as multipart (
data+image, as for create) — the new image replaces the old one; or - add
"removeImage": trueto the body to clear it.
The replaced image file is deleted from storage once the product points at the new one.
Updating a product never changes an invoice that already includes it: invoices keep their own copy
of each line.
Product image#
PUT /api/v1/products/:id/image
DELETE /api/v1/products/:id/imageChange or remove only the image, without resending the product. PUT takes multipart/form-data
with the file in the image field (same rules and errors as a create with an image); DELETE
takes no body and succeeds on a product that has no image. Both return the updated product and
require invoices.manage.
curl -X PUT https://uat.auxcore.net/api/v1/products/2f1c.../image \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-Tenant-ID: your-tenant-id" \
-F "image=@kit.webp"Per-order quantity limit#
A product with a maxQuantity may appear in one order — an invoice, or a POS cart sent with a
payment — at most that many units, summed across all of the order's lines with itsproductId. Two lines of 3 for a product limited to 5 are refused. Lines without a productId
(custom invoice lines, POS misc lines) never count.
Checked on:
| Request | Behaviour |
|---|---|
POST /api/v1/invoices |
Refused when any product is over its limit |
PUT /api/v1/invoices/:id, POST /api/v1/invoices/:id/edit |
Refused only when a product is over its limit and the edit raises its total. An invoice written before the limit was set or lowered stays editable as long as it does not add more of that product |
POS sale / auth / token-sale / ach-sale with lineItems |
Refused before the card or account is charged — see Transactions |
external-payment also validates the product cart, but money may already have been collected by
the device SDK. A limit or other inventory rejection is therefore persisted with the financial
record instead of rejecting or reversing the payment. When POS inventory settlement is enabled and
the payment reaches finality, the server queues inventory work. Stock or location conflicts accepted
by Invoice become Pending Review; a terminal Transaction delivery failure remains operational
recovery work. Limits are not checked on invoices synchronised from Aux365 or generated by a
recurring schedule — those record an order that exists, they do not create one. A product that is
deleted after being added to an order has no limit.
{
"success": false,
"error": {
"code": "QUANTITY_LIMIT_EXCEEDED",
"message": "\"Diesel\" is limited to 50 per order; this order has 60.",
"details": {
"violations": [
{ "productId": "2f1c...", "name": "Diesel", "maxQuantity": 50, "requested": 60 }
]
}
}
}400. details.violations lists every product over its limit, so a client can mark each
offending line. Enforce the limit in the client too (block the add or the quantity change), and
treat this error as the fallback.
Activate / deactivate#
{ "status": "inactive" }Inactive items stay in the catalog and in historical invoices, but are not offered when building a
new invoice line.
Favorite / unfavorite#
{ "isFavorite": true }Returns the updated product. Requires invoices.manage. isFavorite must be a boolean ("true"
and "false" are also accepted); anything else is 400 VALIDATION_ERROR. Favorites are local to
AuxCORE and are not sent to Aux365.
Delete a product#
A soft delete, answering { "success": true, "data": { "id": "2f1c..." } }. Invoices that already include the product are untouched.
Bulk delete#
{ "ids": ["2f1c...", "77ab..."] }{
"success": true,
"data": { "deleted": 1, "notFound": ["77ab..."] }
}Ids outside the caller's merchant are reported in notFound rather than deleted. At most 200 ids
per call. Declare this route before /:id in any client-side router too — bulk is not an id.
Bulk edit#
Set one or more fields on many products at once — the ticked rows, or every product matching the
list filters. The edit runs as a background job so a large selection can report progress. Starting and cancelling need invoices.manage; reading a job needs invoices.view.
Start a bulk edit#
{
"selection": { "mode": "ids", "ids": ["2f1c...", "77ab..."] },
"changes": {
"status": "inactive",
"price": { "mode": "adjust_percent", "value": -10 },
"taxRate": null
}
}selection is either { "mode": "ids", "ids": [...] } or { "mode": "filter", "filters": { ... } }, where filters takes the same keys as the list: search, type, status, categoryId, subCategoryId, favorite, view, sort. The filter is resolved to a list of products when the job is created; products that match it later are not touched. At most 10,000 products per job.
A key in changes is a field to set on every selected product; a key that is absent leaves each product's own value alone.
| Key | Value |
|---|---|
type |
product or service |
status |
active or inactive |
price |
{ "mode": "set", "value": 12.5 }, or { "mode": "adjust_percent", "value": -10 } to move each product's own price by a percentage (-100 to 1000) |
discount |
{ "value": 10, "type": "percent" } — value and type together |
taxRate |
0-100, or null to clear |
unit |
a unit code, or null to clear |
category |
{ "categoryId": "...", "subCategoryId": "..." } (sub-category optional), or null to remove the category and sub-category |
maxQuantity |
a limit, or null for unlimited |
isFavorite |
true or false |
A blank value is refused: only null clears a field. name, description, productCode and the image cannot be changed in bulk. A percentage price change is computed in whole cents and rounded half-up.
The answer is 202 with the job. Only one bulk edit per merchant can be queued or running; a second one answers 409 CONFLICT.
To check an edit before starting it, send the same body to POST /api/v1/products/bulk-jobs/preview (invoices.manage). It validates exactly like the start, queues nothing, and answers { "total": 40, "found": 40, "type": { "to": "service", "changing": 12, "alreadySet": 28 } }: found counts the live items in the selection, and type (null unless changes.type is set) says how many of them would switch type.
Get a bulk edit#
{
"success": true,
"data": {
"id": "9b2d...",
"status": "running",
"total": 1532,
"processed": 600,
"updated": 588,
"unchanged": 12,
"skipped": 0,
"changes": { "status": "inactive" },
"error": null,
"cancelRequested": false,
"createdAt": "2026-10-22T07:01:12.000Z",
"startedAt": "2026-10-22T07:01:12.000Z",
"finishedAt": null
}
}status is queued, running, completed, cancelled or failed. unchanged counts products that already had the new values (they are not written), and skipped counts products deleted before their turn.
The job works through the products in batches of 200. Each batch is saved completely or not at all, but the job as a whole is not one transaction: cancelling, or a failure, keeps the batches already applied, and the counts say how far it got.
Active bulk edit#
The merchant's queued or running job in the same shape, or data: null — what a page opened mid-job reads to show the progress again.
Cancel a bulk edit#
A queued job is cancelled at once; a running one stops before its next batch and ends as cancelled. Answers with the job. Cancelling a finished job changes nothing.
Every changed product is announced to Aux365 as product.updated, except a change to isFavorite alone, which stays local.
Categories#
GET /api/v1/product-categories
POST /api/v1/product-categories
PUT /api/v1/product-categories/:id
DELETE /api/v1/product-categories/:id
POST /api/v1/product-categories/:id/sub-categories
PUT /api/v1/product-categories/:id/sub-categories/:subId
DELETE /api/v1/product-categories/:id/sub-categories/:subIdThe list returns each category with its sub-categories and usage counts:
{
"success": true,
"data": [
{
"id": "9b0e...",
"name": "Hardware",
"productCount": 4,
"createdAt": "2026-09-23T02:50:00.000Z",
"subCategories": [
{ "id": "41aa...", "categoryId": "9b0e...", "name": "Parts", "productCount": 2,
"createdAt": "2026-09-23T02:51:00.000Z" }
]
}
],
"meta": { "page": 1, "limit": 10, "total": 1 }
}The list takes search, page and limit (default 10, capped at 100).
Create and update both take { "name": "Hardware" }. Creating a category or sub-category responds 201; a delete responds { "success": true, "data": { "id": "..." } }.
Sub-categories are addressed through their parent category, so the parentage is checked by the
route itself.
Rules#
- A category name is unique per merchant, ignoring case.
- A sub-category name is unique within its category, ignoring case — the same name may exist under
different categories. - Names are trimmed and limited to 100 characters; whitespace-only is rejected.
Errors#
| Error | When |
|---|---|
409 CONFLICT |
The name already exists (the message quotes the existing spelling) |
409 CONFLICT |
Deleting while products still reference it — the message says how many |
404 NOT_FOUND |
Unknown id, or a sub-category id under the wrong category |
Deleting a category is refused until the products using it are reassigned or cleared. Once allowed,
it also removes that category's sub-categories.
Export#
Returns the products as a file. GET takes the options as query parameters; POST /api/v1/products/export takes the same options as a JSON body
— use POST for ids, since a few hundred ids do not fit in a URL. Both take the list's search,type, status, categoryId, subCategoryId, favorite, view and sort, so the file matches
what the caller is filtered to; with none of them it exports the whole catalog. view=most_sold
exports the same top 10 the list shows.
| Option | Notes |
|---|---|
ids |
Export exactly these products, at most 500 (array, or comma-separated in a query string). Combined with any filters given |
preset |
update (default) — the import columns, so the file imports straight back as an update. report — adds the read-only columns |
columns |
Column keys to export, in place of preset. productId, name and price are always added; the file keeps the order below |
format |
xlsx (default) or csv |
| Key | Column | |
|---|---|---|
productId |
Product ID | always |
productCode |
Product Code | |
name |
Name | always |
type |
Type | |
price |
Price | always |
discount |
Discount | |
discountType |
Discount Type | |
unit |
Unit | exported as its label |
description |
Description | |
category / subCategory |
Category / Sub-category | by name |
tax |
Tax | |
status |
Status | |
maxQuantity |
Max Quantity | blank when there is no limit |
soldQuantity |
Sold Quantity | read-only |
favorite |
Favorite | read-only, Yes / No |
imageUrl |
Image URL | read-only |
createdAt / updatedAt |
Created At / Updated At | read-only |
Read-only columns are for reference: the import ignores them. Images themselves are not exported.
curl -X POST https://uat.auxcore.net/api/v1/products/export -H "Authorization: Bearer YOUR_TOKEN" -H "X-Tenant-ID: your-tenant-id" -H "Content-Type: application/json" -d '{"ids": ["3f2504e0-4f89-41d3-9a0c-0305e82c3301"], "columns": ["tax", "status"]}' -o products.xlsxThe .xlsx file is laid out for editing and importing back: Product ID and the read-only columns
are greyed, Product ID carries a "do not edit" note, Type / Status / Discount Type / Unit have
dropdowns, and an Instructions sheet explains the columns. .csv is UTF-8 with a byte-order mark,
dates in ISO 8601; it cannot be imported back.
400 VALIDATION_ERROR is returned for an export of more than 10,000 rows (narrow the filter rather
than getting a silently truncated file), more than 500 ids, an id that is not a valid id, an
unknown column key, or an unknown preset / format.
Import#
GET /api/v1/products/import-template
POST /api/v1/products/importimport-template returns a blank .xlsx with the expected headers, dropdowns on Type, Status,
Discount Type and Unit, and an Instructions sheet.
POST /products/import takes multipart/form-data with the file in the file field, max 5 MB.
curl -X POST https://uat.auxcore.net/api/v1/products/import \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-Tenant-ID: your-tenant-id" \
-F "file=@products.xlsx"Columns#
| Column | Required | Rule |
|---|---|---|
| Product ID | No | Blank adds a new product. The ID of one of this merchant's products updates it — see below |
| Product Code | No | Max 80 characters |
| Name | Yes | Max 150 characters |
| Type | No | Product or Service; blank means Service |
| Price | Yes | 0 or greater, at most 2 decimals |
| Unit | No | A unit label or code (Gallon, gallon, gallons); blank means no unit |
| Discount | No | Blank means 0. Read with Discount Type |
| Discount Type | No | Percent or Fixed; blank means Percent. % and $ are accepted too |
| Description | No | Max 1000 characters |
| Category | No | Must already exist |
| Sub-category | No | Must already exist under the Category named in the same row |
| Tax | No | 0–100 |
| Status | No | Active or Inactive; blank means Active |
| Max Quantity | No | Per-order limit: greater than 0, at most 1,000,000, up to 3 decimals; blank means unlimited |
Headers are matched by name, case-insensitively, so column order does not matter. Subcategory is
accepted as well as Sub-category, and ProductID / ID as well as Product ID. Blank rows are skipped.
Updating existing products#
A row whose Product ID is one of the caller's live products updates that product; every other
row adds a new one. To bulk-edit, export, change the cells, and import the file back.
- A Product ID that is not a valid id, is deleted, or belongs to another merchant is ignored: the
row is added as a new product and listed inwarnings. - A column left out of the file keeps the product's current value, so a sheet with only Product ID,
Name, Price and Tax edits only those. A blank Type, Status or Discount Type also keeps it; any
other blank cell clears that field. - A row identical to its product is counted as
unchangedand not written. - The same Product ID on two rows is a row error.
- Images and favorites are never changed by import.
Updating needs invoices.manage as well as invoices.create. A file that would change an
existing product without it is refused with 403 FORBIDDEN and nothing is written.
All or nothing#
A readable file always answers 200. Check data.imported, not the status code:
{
"success": true,
"data": {
"imported": 0,
"created": 0,
"updated": 0,
"unchanged": 0,
"totalRows": 3,
"validRows": 2,
"errors": [
{ "row": 3, "column": "Price", "message": "Price must be 0 or greater" }
],
"warnings": []
}
}If any row fails validation nothing is written — no row is added or updated, including the rows
that passed. row is the 1-based row number as Excel shows it, so it points at the line to fix.
Correct the file and upload it again.
On success imported is created + updated. warnings lists rows whose Product ID matched no
product and were added as new; re-uploading a file whose rows have no Product ID adds them again.
Images cannot be imported — add them per product afterwards. Import never creates categories — an
unknown Category or Sub-category is a row error.
400 VALIDATION_ERROR is returned when the file itself is unusable: not a readable .xlsx, missing
the Name or Price column, containing no product rows, or holding more than 2,000 rows.