Recipes / Best sellers & product performance

Reports

Best sellers & product performance

Rank products by revenue or quantity for any period, per location, straight from invoice rows.

GET/api/reports/product-sales

Built on the invoice-row pipeline our financial report uses.

Pull the top products by revenue

One call ranks every product sold in the period by revenue (incl VAT). It is built from the same invoiced line items as the financial report, so the revenue here reconciles with your bookkeeping numbers. Dates are inclusive, Europe/Amsterdam.

# top 10 by revenue for the whole of June
curl -s "https://api.storekeeper.me/api/reports/product-sales?from=2026-06-01&to=2026-06-30&limit=10" \
  -H "Authorization: Bearer $TOKEN"
{
  "from": "2026-06-01",
  "to": "2026-06-30",
  "location_id": null,
  "sort": "revenue",
  "count": 10,
  "total": 412,
  "distinct_products": 412,
  "truncated": false,
  "data": [
    { "product_id": 4477, "name": "Appeltaart groot", "sku": "TAART-APPEL", "quantity": 118, "revenue_incl_vat": 6200.90 },
    { "product_id": 4410, "name": "Volkorenbrood 800g", "sku": "BROOD-VLB-800", "quantity": 1240, "revenue_incl_vat": 4216.00 },
    { "product_id": 4512, "name": "Croissant roomboter", "sku": "CRS-RB", "quantity": 2110, "revenue_incl_vat": 3376.00 }
  ]
}

Flip to quantity and compare

The same call with sort=quantity answers a different business question. Revenue ranks your cash cows; quantity ranks your traffic drivers, the products that get people through the door even at a low price point. Products near the top of one list and far down the other are the interesting ones.

curl -s "https://api.storekeeper.me/api/reports/product-sales?from=2026-06-01&to=2026-06-30&sort=quantity&limit=10" \
  -H "Authorization: Bearer $TOKEN"
# June, side by side
by revenue:   1. Appeltaart groot        2. Volkorenbrood 800g   3. Croissant roomboter
by quantity:  1. Croissant roomboter     2. Volkorenbrood 800g   3. Appeltaart groot

Split per location

Call once per location_id to compare branches. The same product can be a best seller in one branch and dead stock in another; find the ids via /api/locations and diff the top lists.

curl -s "https://api.storekeeper.me/api/reports/product-sales?from=2026-06-01&to=2026-06-30&location_id=14&limit=25" \
  -H "Authorization: Bearer $TOKEN"
curl -s "https://api.storekeeper.me/api/reports/product-sales?from=2026-06-01&to=2026-06-30&location_id=17&limit=25" \
  -H "Authorization: Bearer $TOKEN"

Put the ranking to work

Two immediate uses: assortment pruning (everything below rank 300 of 412 that also has thin margin is a delisting candidate) and shelf placement (your quantity top 10 belongs at eye level and near the register). total and distinct_products tell you how long the tail is; raise limit (up to 500) to see it.

# the long tail: how much of the assortment barely sells?
curl -s "https://api.storekeeper.me/api/reports/product-sales?from=2026-06-01&to=2026-06-30&limit=500" \
  -H "Authorization: Bearer $TOKEN" | jq '[.data[] | select(.revenue_incl_vat < 50)] | length'
Gotcha: returns and corrections are part of the sums. A refunded line subtracts from both quantity and revenue_incl_vat, so a product can even show a negative total. That is correct for reconciliation, but remember it when reading "units sold" as foot traffic.