Tradesmen Technologies Tradesmen Technologies

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:

  • name is required: a trimmed, non-empty string of at most 200 characters.
  • sku (≤ 100 chars) and unit (≤ 50 chars) are optional strings; blank or null stores null.
  • categoryId is optional: the id of one of the company's inventory categories (below), or null / blank for an uncategorized item. An id that is not in the company's category list is a 400. Items created before categories existed have no categoryId attribute at all — treat a missing key as null.
  • unitCost is an optional non-negative number in dollars (same convention as a service's price/cost). lowStockThreshold is an optional non-negative number. Both are null when unset.
  • Installable equipment (added 2026-09-21, replaces the company-row equipment catalog — see equipment-endpoints.md): isEquipment is an optional boolean (null/omitted on create stores false) 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 carries brand (≤ 200 chars), model (≤ 200 chars), notes (≤ 2000 chars) — optional strings, blank or null stores null — and attributes, the same optional flat spec map as on equipment records (same validation; replaced as a whole on update, never merged per key; null clears). The four fields are stored whatever isEquipment says, so unflagging an item loses nothing. Items created before these fields existed have none of the attributes — treat missing keys as false / null. Items copied over from a company's old equipment catalog also carry a server-side sourceCatalogItemId (informational; not writable).
  • Quantities are finite numbers — decimals are allowed (e.g. feet of wire). warehouseQuantity may be negative (a job can consume more than the count on hand; the warehouse absorbs it). A truck quantity is never driven below 0 by a deduction and cannot be adjusted below 0.
  • truckQuantities is always present (possibly {}); a truck with no entry holds 0.
  • companyId, itemId, the quantities, createdAt, and updatedAt are 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>, quantity is 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, jobId set. One movement per (item, location) actually debited.
  • JOB_REVERSAL — fromLocation: null, toLocation: <location>, quantity > 0, jobId set.

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, and inventoryCategories arrays (see accounts-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 name is required. The installable-equipment fields (isEquipment, brand, model, notes, attributes — see the item shape) are optional on create and update. categoryId must be one of the company's categories when given (400 otherwise); omitted stores null. warehouseQuantity defaults to 0; when it is non-zero an ADJUSTMENT movement with note: "Initial stock" is recorded. truckQuantities always 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, unknown categoryId), 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; null clears it (categoryId: null makes the item uncategorized; a non-null categoryId must be one of the company's categories). warehouseQuantity and truckQuantities are ignored if sent (a body carrying only quantities is a 400 No fields provided for update).
  • Success Response: 200 OK — { "item": Item } (the full stored row).
  • Error Responses: 400 (validation, unknown categoryId), 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: location is "WAREHOUSE" or an existing truckId (an unknown truck is a 400). newQuantity is the absolute count after the adjustment; the signed delta (new − old) is applied atomically and recorded as an ADJUSTMENT movement (a zero delta records nothing). The warehouse may be set negative; a truck may not (400). note is 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: from and to must differ; truck ids must exist; quantity > 0. Transfers never drive the source negative — the write is guarded on the source holding at least quantity, and a failed guard returns 400 with "code": "INSUFFICIENT_STOCK". Recorded as a TRANSFER movement.
  • 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 (bad limit), 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 truckId is 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 (TRANSFER movements with note: "Truck removed"; a zero balance just drops the key). Validation: non-empty unique names (case-insensitive, ≤ 100 chars), unique ids (never "WAREHOUSE"), employeeIds an 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 categoryId is 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 (categoryId set to null, updatedAt stamped). 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:

  1. The job's stored inventoryUsage.items when the object is present (the app saves what the technician confirmed — an empty list means "nothing used" and suppresses the fallback).
  2. Otherwise usage is derived from documents: the job's newest invoice (status NEW/SENT/PAID, not hidden), else its newest ACCEPTED estimate. For every services / oneDPrices line carrying a serviceId, the service's inventoryItems × the line quantity (default 1), summed per item. Custom lines (no serviceId) 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.