Developer dashboard

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 — type distinguishes 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 — maxQuantity caps 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:

json
{ "success": true, "data": { }, "meta": { "page": 1, "limit": 10, "total": 42 } }
json
{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Price must be 0 or greater" } }

The product object#

json
{
  "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#

GET/api/v1/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
List productsbash
curl "https://uat.auxcore.net/api/v1/products?status=active&limit=200" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id"
List productsjson
{
  "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 with
400 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 sets productId when 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 and
meta.total is the number returned.


Get one product#

GET/api/v1/products/:id

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#

POST/api/v1/products
Create a productjson
{
  "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
With an imagebash
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#

PUT/api/v1/products/:id

Takes the same body as create and replaces every field, so send the whole object. Omitting
categoryId or taxRate clears it rather than leaving it alone. unit, isFavorite and
maxQuantity 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": true to 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#

http
PUT    /api/v1/products/:id/image
DELETE /api/v1/products/:id/image

Change 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.

bash
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 its
productId
. 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.

json
{
  "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#

PATCH/api/v1/products/:id/status
Activate / deactivatejson
{ "status": "inactive" }

Inactive items stay in the catalog and in historical invoices, but are not offered when building a
new invoice line.


Favorite / unfavorite#

PATCH/api/v1/products/:id/favorite
Favorite / unfavoritejson
{ "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#

DELETE/api/v1/products/:id

A soft delete, answering { "success": true, "data": { "id": "2f1c..." } }. Invoices that already include the product are untouched.

Bulk delete#

DELETE/api/v1/products/bulk
Bulk deletejson
{ "ids": ["2f1c...", "77ab..."] }
Bulk deletejson
{
  "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#

POST/api/v1/products/bulk-jobs
Start a bulk editjson
{
  "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#

GET/api/v1/products/bulk-jobs/:jobId
Get a bulk editjson
{
  "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#

GET/api/v1/products/bulk-jobs/active

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#

POST/api/v1/products/bulk-jobs/:jobId/cancel

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#

http
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/:subId

The list returns each category with its sub-categories and usage counts:

json
{
  "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#

GET/api/v1/products/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.

Exportbash
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.xlsx

The .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#

http
GET  /api/v1/products/import-template
POST /api/v1/products/import

import-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.

bash
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 in warnings.
  • 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 unchanged and 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:

json
{
  "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.