Recipes / Stock level monitor

Inventory

Stock level monitor

Watch stock per product per location, and understand the difference between a location and a shop before it bites.

GET/api/stock GET/api/products GET/api/locations

The read model behind our shortage planner.

Find your location ids

Stock is counted per physical site, so everything starts with a location id. List the locations once and cache the mapping; the ids are stable. A location (physical site) and a shop (sales channel) live in different id spaces: /api/stock filters by location_id, never by a shop id.

curl -s https://api.storekeeper.me/api/locations -H "Authorization: Bearer $TOKEN"
Gotcha: do not feed a shop id into location_id. The numbers look similar, both exist, and a wrong-but-existing id returns rows for the wrong site instead of an error. Resolve ids from /api/locations only.

Watch specific products at one location

Scope with location_id plus a comma-separated product_ids list. Find the product ids first via catalog search; the id in a search row is the product id that stock rows carry.

# find the products (name or SKU search)
curl -s "https://api.storekeeper.me/api/products?q=croissant&lang=nl" \
  -H "Authorization: Bearer $TOKEN"

# stock for those products at location 14
curl -s "https://api.storekeeper.me/api/stock?location_id=14&product_ids=812,813,957" \
  -H "Authorization: Bearer $TOKEN"
{
  "start": 0,
  "limit": 100,
  "count": 3,
  "total": 3,
  "data": [
    { "id": 40211, "shop_product_id": 5031, "product_id": 812, "location_id": 14,
      "orderable_stock_value": 24, "unfulfilled_stock_value": 0,
      "value": 24, "in_stock": true, "unlimited": false },
    { "id": 40212, "shop_product_id": 5032, "product_id": 813, "location_id": 14,
      "orderable_stock_value": 3, "unfulfilled_stock_value": 2,
      "value": 5, "in_stock": true, "unlimited": false },
    { "id": 40388, "shop_product_id": 5177, "product_id": 957, "location_id": 14,
      "orderable_stock_value": 0, "unfulfilled_stock_value": 0,
      "value": 0, "in_stock": false, "unlimited": false }
  ]
}

value is the raw counted stock; orderable_stock_value is what can still be sold (counted minus what open orders already claim, the unfulfilled_stock_value). For "can I sell this now" alerts, watch orderable_stock_value. unlimited: true means stock is not tracked for that product; skip it in alerting.

Gotcha: a mistyped product_ids (empty, or non-numeric junk like abc) returns a 400 with an error body, not a silently unfiltered full-catalog result. Treat a 400 here as a bug in your id list, not as "no stock".

Full-catalog sweep with pagination

For a nightly snapshot of everything at one site, drop product_ids and drain the pages with start/limit (max 500 per page) until you have total rows. The same drain pattern works on /api/products: omit q and it lists the whole catalog paginated (max 200 per page), which gives you titles and SKUs to join onto the stock rows by product_id.

# page 1, page 2, ... until start >= total
curl -s "https://api.storekeeper.me/api/stock?location_id=14&start=0&limit=500" \
  -H "Authorization: Bearer $TOKEN"
curl -s "https://api.storekeeper.me/api/stock?location_id=14&start=500&limit=500" \
  -H "Authorization: Bearer $TOKEN"

# names + SKUs to join on product_id (q omitted = full catalog)
curl -s "https://api.storekeeper.me/api/products?start=0&limit=200" \
  -H "Authorization: Bearer $TOKEN"
Gotcha: configurable products (a shirt with size variants, for example) roll their stock up to the parent product. The parent row already is the sum; do not add the variant rows into it yourself or you double-count. Alert on the variant rows for shelf-level detail, on the parent row for "any size left".

Turn the snapshot into alerts

Keep it boring and reliable:

Threshold per product, not global. Five croissants is a crisis; five wedding cakes is a warehouse. Store a reorder point per product_id and alert when orderable_stock_value drops to or below it.

Poll, do not hammer. Every 5 to 15 minutes is plenty for shelf alerting; once per night for the full-catalog sweep. Scope polls with product_ids to your watchlist so each poll is one cheap call.

Compare snapshots, alert on the crossing. Keep the previous poll's values and fire only when a product crosses its threshold downward. Alerting on the absolute state every poll spams the same message every cycle; alerting on the transition fires once.

# the alert condition, per watched product
prev.orderable_stock_value >  threshold[product_id]
curr.orderable_stock_value <= threshold[product_id]
curr.unlimited == false