API reference Inventory
Inventory Service API Endpoints
The production API base URL is https://api.tradesmentechnologies.com/.
Authentication
All endpoints require a Bearer token in the Authorization header:
Authorization: Bearer <your_token>
Mutating requests may carry an Idempotency-Key header so a retry never acts twice — see Idempotent Retries in the Accounts API documentation.
GET /inventory may be called by every employee type. Every other endpoint in this document is ADMIN only (the caller's employee record is looked up; a non-ADMIN receives 403).
Base Path
/prod/inventory
Concepts
Inventory tracks the parts and materials a company stocks (filters, wire, fittings…) and where they are: one implicit warehouse per company plus any number of admin-managed trucks. Every stock change is recorded as a movement. Stock is deducted automatically the first time a job becomes COMPLETED or PAID (by any path — app status change, invoice sent, invoice paid, program visit report sent) and returned if the job later leaves those statuses; see Job deductions below.
The app gates the whole module behind the company feature flag allowInventory. The backend does not gate any endpoint on that flag (matching every other company flag).
Locations
A location identifier on the wire is either the literal string "WAREHOUSE" or a truckId.
Item shape
{
"companyId": "company-uuid",
"itemId": "item-uuid",
"name": "16x25x1 filter",
"sku": "FLT-16251",
"unit": "each",
"categoryId": "category-uuid",
"unitCost": 4.5,
"lowStockThreshold": 10,
"isEquipment": false,
"brand": null,
"model": null,
"notes": null,
"attributes": null,
"warehouseQuantity": 42,
"truckQuantities": { "truck-uuid": 6 },
"createdAt": "2026-09-04T10:00:00.000Z",
"updatedAt": "2026-09-04T10:00:00.000Z"
}
Field notes:
nameis required: a trimmed, non-empty string of at most 200 characters.sku(≤ 100 chars) andunit(≤ 50 chars) are optional strings; blank ornullstoresnull.categoryIdis optional: the id of one of the company's inventory categories (below), ornull/ blank for an uncategorized item. An id that is not in the company's category list is a400. Items created before categories existed have nocategoryIdattribute at all — treat a missing key asnull.unitCostis an optional non-negative number in dollars (same convention as a service'sprice/cost).lowStockThresholdis an optional non-negative number. Both arenullwhen unset.- Installable equipment (added 2026-09-21, replaces the company-row equipment catalog — see
equipment-endpoints.md):isEquipmentis an optional boolean (null/omitted on create storesfalse) marking the item as a unit the company installs at customer properties (an A/C model, a water heater). Such an item is the app's prefill source for a new customer-equipment record and additionally carriesbrand(≤ 200 chars),model(≤ 200 chars),notes(≤ 2000 chars) — optional strings, blank ornullstoresnull— andattributes, the same optional flat spec map as on equipment records (same validation; replaced as a whole on update, never merged per key;nullclears). The four fields are stored whateverisEquipmentsays, so unflagging an item loses nothing. Items created before these fields existed have none of the attributes — treat missing keys asfalse/null. Items copied over from a company's old equipment catalog also carry a server-sidesourceCatalogItemId(informational; not writable). - Quantities are finite numbers — decimals are allowed (e.g. feet of wire).
warehouseQuantitymay be negative (a job can consume more than the count on hand; the warehouse absorbs it). A truck quantity is never driven below0by a deduction and cannot be adjusted below0. truckQuantitiesis always present (possibly{}); a truck with no entry holds0.companyId,itemId, the quantities,createdAt, andupdatedAtare server-managed. Quantities change only through Adjust, Transfer, truck removal, and job deductions — never through Update Item.
Truck shape
{ "truckId": "truck-uuid", "name": "Truck 1", "employeeIds": ["employee-uuid"] }
Trucks live on the company row (inventoryTrucks, like equipmentCatalog). Names are required, trimmed (≤ 100 chars), and unique case-insensitively; an employee may be assigned to at most one truck; at most 100 trucks; the server assigns a truckId when the request omits it. A truckId can never be "WAREHOUSE".
Category shape
{ "categoryId": "category-uuid", "name": "Filters" }
Categories are a flat list (no nesting) used to group items on the inventory screen. They live on the company row (inventoryCategories, exactly like trucks): names are required, trimmed (≤ 100 chars), and unique case-insensitively; at most 100 categories; the server assigns a categoryId when the request omits it. The list is replaced as a whole via Update Categories; removing a category makes every item that belonged to it uncategorized (categoryId: null).
Movement shape
{
"companyId": "company-uuid",
"movementId": "item-uuid#2026-09-04T10:00:00.000Z#k3j9x2",
"itemId": "item-uuid",
"type": "TRANSFER",
"quantity": 4,
"fromLocation": "WAREHOUSE",
"toLocation": "truck-uuid",
"jobId": null,
"employeeId": "employee-uuid",
"note": "Restock",
"createdAt": "2026-09-04T10:00:00.000Z"
}
type is one of:
ADJUSTMENT—fromLocation: null,toLocation: <location>,quantityis the signed delta (new − old). Created by Adjust and by a non-zero opening balance on Create Item (note: "Initial stock").TRANSFER— both locations set,quantity > 0. Created by Transfer and by truck removal (note: "Truck removed").JOB_DEDUCTION—fromLocation: <location>,toLocation: null,quantity > 0,jobIdset. One movement per (item, location) actually debited.JOB_REVERSAL—fromLocation: null,toLocation: <location>,quantity > 0,jobIdset.
employeeId is the acting employee (null on public/webhook paths such as a customer paying an invoice from the document viewer). movementId is ${itemId}#${createdAt}#${6 random chars}, so an item's history sorts in time order. Movements are never deleted (deleting an item leaves its history in place, unreachable).
Endpoints
Get Inventory
Every item of the company plus the truck and category lists. Any employee.
- Method: GET
- Path:
/prod/inventory - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "items": [ { "companyId": "company-uuid", "itemId": "item-uuid", "name": "16x25x1 filter", "sku": "FLT-16251", "unit": "each", "categoryId": "category-uuid", "unitCost": 4.5, "lowStockThreshold": 10, "warehouseQuantity": 42, "truckQuantities": { "truck-uuid": 6 }, "createdAt": "2026-09-04T10:00:00.000Z", "updatedAt": "2026-09-04T10:00:00.000Z" } ], "trucks": [ { "truckId": "truck-uuid", "name": "Truck 1", "employeeIds": ["employee-uuid"] } ], "categories": [ { "categoryId": "category-uuid", "name": "Filters" } ] } - Note: The same three lists are also returned on the login bootstrap as the top-level
inventoryItems,inventoryTrucks, andinventoryCategoriesarrays (seeaccounts-endpoints.md).
Create Item
- Method: POST
- Path:
/prod/inventory/items - Headers:
Authorization: Bearer <token> - Request Body:
{ "name": "16x25x1 filter", "sku": "FLT-16251", "unit": "each", "categoryId": "category-uuid", "unitCost": 4.5, "lowStockThreshold": 10, "warehouseQuantity": 42 } - Note: Only
nameis required. The installable-equipment fields (isEquipment,brand,model,notes,attributes— see the item shape) are optional on create and update.categoryIdmust be one of the company's categories when given (400otherwise); omitted storesnull.warehouseQuantitydefaults to0; when it is non-zero anADJUSTMENTmovement withnote: "Initial stock"is recorded.truckQuantitiesalways starts as{}— stock a truck via Transfer. - Success Response:
201 Created{ "item": { "companyId": "company-uuid", "itemId": "item-uuid", "name": "16x25x1 filter", "sku": "FLT-16251", "unit": "each", "categoryId": "category-uuid", "unitCost": 4.5, "lowStockThreshold": 10, "warehouseQuantity": 42, "truckQuantities": {}, "createdAt": "2026-09-04T10:00:00.000Z", "updatedAt": "2026-09-04T10:00:00.000Z" } } - Error Responses:
400(validation, unknowncategoryId),403(non-ADMIN).
Update Item
- Method: PUT
- Path:
/prod/inventory/items/{itemId} - Headers:
Authorization: Bearer <token> - Request Body: (all fields optional)
{ "name": "16x25x1 pleated filter", "sku": null, "unit": "each", "categoryId": "category-uuid", "unitCost": 4.75, "lowStockThreshold": null } - Note: An omitted field keeps its stored value;
nullclears it (categoryId: nullmakes the item uncategorized; a non-nullcategoryIdmust be one of the company's categories).warehouseQuantityandtruckQuantitiesare ignored if sent (a body carrying only quantities is a400 No fields provided for update). - Success Response:
200 OK—{ "item": Item }(the full stored row). - Error Responses:
400(validation, unknowncategoryId),403,404(unknown item).
Delete Item
- Method: DELETE
- Path:
/prod/inventory/items/{itemId} - Headers:
Authorization: Bearer <token> - Success Response:
200 OK{ "message": "Inventory item deleted" } - Note: Movements are left in place. Services that still reference the item derive nothing for it, and a later job reversal for the item is skipped.
- Error Responses:
403,404.
Adjust Item Quantity
Set the count held at one location (a stock count or correction).
- Method: POST
- Path:
/prod/inventory/items/{itemId}/adjust - Headers:
Authorization: Bearer <token> - Request Body:
{ "location": "WAREHOUSE", "newQuantity": 40, "note": "Monthly count" } - Note:
locationis"WAREHOUSE"or an existingtruckId(an unknown truck is a400).newQuantityis the absolute count after the adjustment; the signed delta (new − old) is applied atomically and recorded as anADJUSTMENTmovement (a zero delta records nothing). The warehouse may be set negative; a truck may not (400).noteis optional (≤ 500 chars). - Success Response:
200 OK—{ "item": Item }. - Error Responses:
400,403,404.
Transfer Item Stock
Move stock between two locations (warehouse ↔ truck, truck ↔ truck).
- Method: POST
- Path:
/prod/inventory/items/{itemId}/transfer - Headers:
Authorization: Bearer <token> - Request Body:
{ "from": "WAREHOUSE", "to": "truck-uuid", "quantity": 6, "note": "Restock" } - Note:
fromandtomust differ; truck ids must exist;quantity > 0. Transfers never drive the source negative — the write is guarded on the source holding at leastquantity, and a failed guard returns400with"code": "INSUFFICIENT_STOCK". Recorded as aTRANSFERmovement. - Success Response:
200 OK—{ "item": Item }. - Error Response:
400 Bad Request(insufficient stock){ "message": "Not enough stock at 'WAREHOUSE' to transfer 6", "code": "INSUFFICIENT_STOCK" } - Other Error Responses:
400(validation),403,404.
Get Item Movements
- Method: GET
- Path:
/prod/inventory/items/{itemId}/movements?limit=50 - Headers:
Authorization: Bearer <token> - Query Parameters:
limit— optional integer 1–200 (default 50). - Success Response:
200 OK— newest first{ "movements": [ { "companyId": "company-uuid", "movementId": "item-uuid#2026-09-04T10:00:00.000Z#k3j9x2", "itemId": "item-uuid", "type": "JOB_DEDUCTION", "quantity": 2, "fromLocation": "truck-uuid", "toLocation": null, "jobId": "job-uuid", "employeeId": "employee-uuid", "note": null, "createdAt": "2026-09-04T10:00:00.000Z" } ] } - Error Responses:
400(badlimit),403.
Update Trucks
Replace the company's whole truck list.
- Method: PUT
- Path:
/prod/inventory/trucks - Headers:
Authorization: Bearer <token> - Request Body:
{ "trucks": [ { "truckId": "truck-uuid", "name": "Truck 1", "employeeIds": ["employee-uuid"] }, { "name": "Truck 2", "employeeIds": [] } ] } - Note: The list replaces the stored one. A truck without a
truckIdis created with a server-assigned id (keep sending stored ids for existing trucks, or their stock is treated as belonging to a removed truck). A truck missing from the new list is removed and its stock on every item is moved to the warehouse (TRANSFERmovements withnote: "Truck removed"; a zero balance just drops the key). Validation: non-empty unique names (case-insensitive, ≤ 100 chars), unique ids (never"WAREHOUSE"),employeeIdsan optional array of non-empty strings (default[], duplicates within one truck collapsed), no employee in two trucks, at most 100 trucks. - Success Response:
200 OK{ "trucks": [ { "truckId": "truck-uuid", "name": "Truck 1", "employeeIds": ["employee-uuid"] }, { "truckId": "new-truck-uuid", "name": "Truck 2", "employeeIds": [] } ] } - Error Responses:
400,403,404(company row missing).
Update Categories
Replace the company's whole inventory-category list.
- Method: PUT
- Path:
/prod/inventory/categories - Headers:
Authorization: Bearer <token> - Request Body:
{ "categories": [ { "categoryId": "category-uuid", "name": "Filters" }, { "name": "Wire" } ] } - Note: The list replaces the stored one. A category without a
categoryIdis created with a server-assigned id (keep sending stored ids for existing categories — a rename is the same id with a new name; a dropped id is a removal). A category missing from the new list is removed and every item that belonged to it becomes uncategorized (categoryIdset tonull,updatedAtstamped). Validation: non-empty unique names (case-insensitive, ≤ 100 chars), unique ids (≤ 100 chars), at most 100 categories. - Success Response:
200 OK{ "categories": [ { "categoryId": "category-uuid", "name": "Filters" }, { "categoryId": "new-category-uuid", "name": "Wire" } ] } - Error Responses:
400,403,404(company row missing).
Job deductions
Stock is deducted the first time a job enters COMPLETED or PAID, by any path — PATCH /jobs/jobs/{jobId} (and the instance-edit endpoint), a job created directly in a terminal status, sending an invoice (POST /jobs/documents/{documentId}/send and /jobs/documents/send), an invoice being paid (POST /payments/transactions, Tap to Pay, a Stripe "Pay over time" checkout), the incoming webhook creating a job in a terminal status, and sending a program visit report. Leaving both statuses (e.g. back to IN_PROGRESS, or a customer accepting an estimate on a job that was already completed) reverses the deduction, returning stock to where it came from. The rule is state-based and idempotent: a job is deducted at most once while it stays terminal, and reversed at most once while it stays non-terminal. It never blocks a status change — stock problems are logged and swallowed.
What is deducted:
- The job's stored
inventoryUsage.itemswhen the object is present (the app saves what the technician confirmed — an empty list means "nothing used" and suppresses the fallback). - Otherwise usage is derived from documents: the job's newest invoice (status
NEW/SENT/PAID, nothidden), else its newestACCEPTEDestimate. For everyservices/oneDPricesline carrying aserviceId, the service'sinventoryItems× the linequantity(default 1), summed per item. Custom lines (noserviceId) and 2D lines derive nothing. Item ids that no longer exist are dropped.
Where it comes from — the truck is resolved as: inventoryUsage.truckId (when that truck still exists) → the truck whose employeeIds contains the acting employee → the first of the job's employeeIds that has a truck → none (warehouse only). For each item: fromTruck = min(quantity, max(truckQuantity, 0)), fromWarehouse = quantity − fromTruck. The warehouse may go negative. Each debited location gets a JOB_DEDUCTION movement, and the job's server-managed inventoryDeduction record is written (see jobs-endpoints.md, "Inventory usage & deduction"). A reversal adds fromTruck back to that truck (or to the warehouse if the truck has since been removed) and fromWarehouse to the warehouse, records JOB_REVERSAL movements, and removes the record.