# Inventory balances

Review current balances and exact stock-out counts by product and warehouse.

Canonical HTML: https://developer.shelfcycle.com/guides/inventory-balances

Review current physical stock by product and warehouse, focus on an availability state, and use complete product and product-and-warehouse stock-out counts.

Use `GET /inventory-balances` when a workflow needs the current physical balance for one product, a product family, one warehouse, or the complete visible inventory population.

## Access

The endpoint requires `inventory:read` plus the acting user's current access to view Products and Lots. `GET /me` reports the result through `effectiveCapabilities.inventory.balances.view` and `effectiveCapabilities.inventory.balances.currentStateRefresh`.

```bash
curl "$SHELFCYCLE_API_BASE_URL/inventory-balances?productFamilyId=product-family-id&warehouseId=warehouse-id&limit=25" \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY" \
  -H "X-ShelfCycle-Client: Inventory availability dashboard"
```

## Filters

| Parameter | Use it for |
| --- | --- |
| `productId` | One exact product. |
| `productFamilyId` | Every matching product in one family. |
| `warehouseId` | One exact physical warehouse. |
| `availabilityState` | `zero_on_hand`, `fully_allocated`, or `positive_available`. |
| `limit` | Rows per page from 1 to 100. The default is 25. |

Filters can be combined on the first request. An exact active and visible `productId` plus `warehouseId` request can return a zero row when no balance activity is present for that pair, making the zero explicit rather than leaving the match uncertain.

Availability states are based on the complete product-and-warehouse balance:

- `zero_on_hand` means on hand is zero.
- `fully_allocated` means on hand is positive and available is zero.
- `positive_available` means available is greater than zero.

To find every product-and-warehouse position with nothing available, request `zero_on_hand` and `fully_allocated` separately or use the complete `meta.census.pairs.zeroAvailable` count.

## Read a balance row

```json
{
  "data": [
    {
      "type": "inventory_balance",
      "product": {
        "id": "product-id",
        "code": "IPA-DRUM",
        "archived": false,
        "family": { "id": "product-family-id", "name": "Isopropyl Alcohol" }
      },
      "warehouse": {
        "id": "warehouse-id",
        "name": "Main Warehouse",
        "archived": false
      },
      "basis": {
        "type": "package_count",
        "unitOfMeasure": "GALLON",
        "packaging": { "id": "packaging-id", "name": "55 gallon drum" }
      },
      "onHand": "24",
      "allocated": "6",
      "available": "18",
      "quarantinedOnHand": "2"
    },
    {
      "type": "inventory_balance",
      "product": {
        "id": "variable-product-id",
        "code": "IPA-BULK",
        "archived": false,
        "family": { "id": "product-family-id", "name": "Isopropyl Alcohol" }
      },
      "warehouse": {
        "id": "warehouse-id",
        "name": "Main Warehouse",
        "archived": false
      },
      "basis": {
        "type": "base_uom",
        "unitOfMeasure": "POUND",
        "packaging": { "id": "base-packaging-id", "name": "Tank truck" }
      },
      "onHand": "12500.5",
      "allocated": "2500",
      "available": "10000.5",
      "quarantinedOnHand": "0"
    }
  ],
  "meta": {
    "populationCount": 2,
    "returnedRows": 2,
    "hasMore": false,
    "nextCursor": null,
    "ordering": "product_id_asc_warehouse_id_asc",
    "observedAt": "2026-08-24T16:30:00.000Z",
    "currentState": true,
    "snapshotIsolatedAcrossRequests": false,
    "census": {
      "population": "participating_physical_inventory_pairs",
      "scope": "entity_filters_excluding_availability",
      "pairs": {
        "zeroOnHand": 12,
        "fullyAllocated": 3,
        "positiveAvailable": 27,
        "zeroAvailable": 15,
        "archived": 1
      },
      "products": {
        "withParticipatingPairs": 36,
        "noneAvailableInScope": 11
      }
    },
    "requestId": "request-id"
  }
}
```

All quantities are decimal strings. `available` is `onHand` less `allocated`. `quarantinedOnHand` identifies the on-hand quantity currently in quarantine; it is part of on-hand, not an additional quantity.

The `basis` object tells you how to interpret every quantity on that row:

- `package_count` means the values count the named package and unit of measure.
- `base_uom` means the values use the product's named base unit, with the packaging record included as context.

> **Guardrail**: Keep each row's basis and unit with its quantities. Compare or add values only when the product and quantity basis make that operation meaningful.

## Continue larger results

When `meta.hasMore` is true, call the endpoint again with `meta.nextCursor` by itself:

```bash
curl "$SHELFCYCLE_API_BASE_URL/inventory-balances?cursor=$NEXT_CURSOR" \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY"
```

Do not combine a cursor with the original filters. The cursor retains the filter set, page size, ordering, organization, and API key needed for continuation. If a cursor is no longer valid, restart from the original filters.

`populationCount` reports the complete matched population, while `returnedRows`, `hasMore`, and `nextCursor` describe the current page and continuation.

## Count stock outs

Use `meta.census` for complete counts instead of counting the current page:

- `products.noneAvailableInScope` counts products with no available stock anywhere in the selected product, family, and warehouse scope.
- `pairs.zeroAvailable` counts product-and-warehouse positions with no available stock. It equals `zeroOnHand` plus `fullyAllocated`.
- `products.withParticipatingPairs` counts products with physical inventory records in the selected scope.

Keep the product and position counts distinct in every summary. For the example above, say "11 products have no available stock across 15 product-and-warehouse positions."

The census follows the product, product-family, and warehouse filters but is calculated before `availabilityState`. That lets a filtered page of physically empty or fully allocated positions retain the complete stock-out summary for the selected scope.

Products without a physical inventory record are not part of the balance census. Under a warehouse filter, `noneAvailableInScope` means the product has no available stock at the selected warehouse.

> **Stock-out pattern**: Use `products.noneAvailableInScope` for the product count and `pairs.zeroAvailable` for the product-and-warehouse-position count. Use the availability filter only when the workflow also needs the matching rows.

## Refresh current state

Each response includes `observedAt`, `currentState: true`, and `snapshotIsolatedAcrossRequests: false`. Treat it as the current view observed at that time. For a complete refresh, restart from the original filters and continue through the newly returned cursors.

Because inventory can change between pages, deduplicate by the product and warehouse ids when reconciling a refresh. Broader reads return current participating pairs. Archived products or warehouses with a nonzero balance remain clearly marked, while archived zero pairs are omitted. Quarantined quantities remain part of on hand when balances and availability states are calculated.

> **Practical pattern**: Use an exact product and warehouse query for an availability check. Use product-family or warehouse filters for operational views, and periodically restart the original request to refresh the complete current population.
