Query and update stock

This article covers the core read/write stock endpoints: general listing, query by SKU or by location, single update, bulk update, statistics, and reports.

List inventory

Lists the tenant's inventory records, with optional filters

pagenumber

Page, 1-indexed. Default: 1.

limitnumber

Items per page. Default: 20.

skustring

Filter by SKU (regex, case-insensitive).

productSkustring

Filter by parent product SKU (regex).

locationIdstring

Filter by location.

statusstring

Filter by record status.

lowStockboolean

If true, filters to records with low stock only (fixed threshold: stock < 10).

200
{
  "items": [
    {
      "tenantId": "69db07c8bce4d49b18c42a49",
      "sku": "CAM-ROJO-M",
      "locationId": "loc_abc123",
      "stock": 42,
      "compromised": 0,
      "handlerType": "manual"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 318, "pages": 16 }
}

Required permission: inventory:read

lowStock uses a fixed threshold

lowStock=true filters to records with stock < 10. This threshold is hardcoded in the service — it is not configurable per tenant or per SKU from this endpoint.

Query by SKU

Queries the inventory record for a SKU

skustringrequired

SKU to query (part of the route).

200
{
  "tenantId": "69db07c8bce4d49b18c42a49",
  "sku": "CAM-ROJO-M",
  "locationId": "loc_abc123",
  "stock": 42
}
400
{ "code": "bad-request/missing-sku" }
404
{ "code": "not-found" }

Required permission: inventory:read

Returns only ONE location, not all of them

This endpoint performs a findOne({tenantId, sku}) — it returns the first record Mongo finds for that SKU, with no deterministic ordering. Since a SKU can have one inventory record per location (unique index {tenantId, sku, locationId}), this endpoint does not consolidate or sum the stock across all locations of a multi-location SKU. If you need the stock of a SKU in a specific location, use GET /inventory/location/:id/product/:sku (further down this same page).

Query by location

Lists the inventory of a specific location

locationIdstringrequired

Location ID (part of the route).

pagenumber

Page, 1-indexed. Default: 1.

limitnumber

Items per page. Default: 20.

200
{
  "items": [
    { "sku": "CAM-ROJO-M", "locationId": "loc_abc123", "quantity": 42 }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 54, "pages": 3 }
}

Required permission: inventory:read

Query by location and SKU

Queries the exact record of a SKU in a location

locationIdstringrequired

Location ID.

skustringrequired

SKU to query.

200
{ "tenantId": "69db07c8bce4d49b18c42a49", "sku": "CAM-ROJO-M", "locationId": "loc_abc123", "stock": 42 }
404
{ "code": "not-found" }

Required permission: inventory:read

This is the correct endpoint to read the exact stock of a SKU in a specific location, without the ambiguity of GET /inventory/items/:sku.

Update stock (single)

Sets the stock of a SKU in a location (replaces the value, does not apply a delta)

locationIdstringrequired

Location ID.

skustringrequired

SKU to update.

stocknumberrequired

New absolute stock value (not an increment/decrement).

{ "stock": 50 }
200
{ "tenantId": "69db07c8bce4d49b18c42a49", "sku": "CAM-ROJO-M", "locationId": "loc_abc123", "stock": 50 }
400
{ "code": "bad-request/invalid-stock" }
404
{ "code": "not-found" }

Required permission: inventory:update

Negative stock returns 500, not 400

If you send a stock value that would result in a negative number, the API responds with 500 and {"code":"internal-error","message":"Stock cannot be negative"} — not a structured 400. This is the current behavior in production: negative validation happens in a layer that doesn't distinguish a business error from an internal error. Treat it as a business error response (reject the attempt), but don't expect the usual HTTP status for validations.

A record whose stock is controlled by an integration or channel (handlerType: 'integration' | 'channel') may reject the manual write with 403 forbidden/inventory-handler.

Update stock (bulk)

Updates the stock of multiple SKUs in a single request

itemsarrayrequired

List of { sku, stock, locationId? } to update.

{
  "items": [
    { "sku": "CAM-ROJO-M", "stock": 50, "locationId": "loc_abc123" },
    { "sku": "PAN-AZUL-32", "stock": -5 }
  ]
}
200
[
  { "success": true, "sku": "CAM-ROJO-M", "result": { "stock": 50 } },
  { "success": false, "sku": "PAN-AZUL-32", "error": "Stock cannot be negative" }
]
400
{ "code": "bad-request/invalid-items" }

Required permission: inventory:update

200 does not mean every item was updated

This response is always 200 if the body is valid, even if some (or all) individual items fail. There is no root-level success flag. You must iterate the results array and check success on each element — a global 200 never guarantees your entire batch was applied.

Statistics

Aggregated product totals by stock status

locationIdstring

Limits the statistics to one location.

200
{
  "totalProducts": 318,
  "productsInStock": 290,
  "productsOutOfStock": 12,
  "productsLowStock": 16
}

Required permission: inventory:read

productsLowStock uses a fixed threshold of 5 units (DEFAULT_MIN_ALERT_STOCK), different from the < 10 threshold used by the lowStock filter on GET /inventory. Don't confuse the two — they are two independent hardcoded thresholds.

Reports

Generates a report based on the requested type

reportTypestringrequired

inventory | transfers | adjustments | counts | valuation

startDatestring

ISO 8601 start date of the range (does not apply to reportType=inventory).

endDatestring

ISO 8601 end date of the range.

locationIdstring

Filters the report to a location.

formatstring

Accepted (json | csv) but has no effect — see the warning below.

200
{
  "data": [
    { "sku": "CAM-ROJO-M", "quantity": 42 }
  ],
  "summary": { "totalItems": 318, "locationId": "loc_abc123" }
}
400
{ "code": "bad-request/missing-type" }

Required permission: inventory:read

reportTypedatasummary
inventoryInventory[] (up to 10,000, filtered by locationId){ totalItems, locationId }
transfersInventoryTransfer[] filtered by date{ total, byStatus }
adjustmentsInventoryAdjustment[] filtered by date/location{ total, totalQuantityAdjusted, byReason }
countsInventoryCount[] filtered by date/location{ total, byStatus }
valuationArray<{sku, productName, stock, unitCost, totalValue, locationId}>{ totalItems, totalUnits, totalValue, averageUnitCost }

The valuation report (reportType=valuation) is broken

The valuation report reads unitCost directly from the Inventory document, but that field does not exist in the inventory schema — it is always undefined. As a result, totalValue and averageUnitCost from this report are effectively 0 for any tenant. Do not use it as an inventory valuation source.

format=csv does nothing

The format parameter is accepted but ignored: the response is always JSON, regardless of the value sent.

POST /inventory is not implemented

Root creation route — not implemented

No body is validated or read — the route responds 501 regardless of the body sent.

{}
501
{ "code": "not-implemented" }

GET /inventory (root route) is equivalent to the listing documented above. POST /inventory on that same route responds 501 not-implemented — there is no generic "create inventory" operation; inventory records are created implicitly when stock is written (PUT/bulk-update) or through internal product flows.

Example — update stock

curl -X PUT 'https://api.fenicia.io/inventory/location/loc_abc123/product/CAM-ROJO-M' \
  -H 'Authorization: Bearer fkapi_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{"stock": 50}'

Errors

CodeStatusDescription
bad-request/missing-tenant401The tenant could not be resolved from the API key.
bad-request/missing-sku400The SKU is missing from the route or the body.
bad-request/missing-body400The request body is empty.
bad-request/invalid-json400The body is not valid JSON.
bad-request/invalid-stock400stock is missing or is not of type number.
bad-request/invalid-items400items is missing, empty, or malformed (bulk-update).
not-found404No inventory record exists for that SKU/location.
forbidden/inventory-handler403The record is controlled by an integration/channel; the manual write was rejected.
internal-error500Internal error — includes the negative stock case (see the warning above).
not-implemented501POST /inventory (root route) — this operation does not exist.

Next steps