---
title: "Stock on hand at each period boundary, the level a stock ladder is anchored on"
method: GET
path: "/api/v1/inventory/stock-on-hand"
tags: ["inventory"]
---

# Stock on hand at each period boundary, the level a stock ladder is anchored on

`GET /api/v1/inventory/stock-on-hand`

## Request

- Base URL: `https://api.app.tightly.io`
- URL: `https://api.app.tightly.io/api/v1/inventory/stock-on-hand`
- Auth: HTTP bearer

Stock at each period boundary, per category and location: one row per period with its opening and closing level, plus the window, the grain, the calendar it was bucketed on, a coverage block and a basis note.

A level and not a flow. A period's opening stock is the level on its first day and its closing stock the level on its last, and a period's closing boundary is the next period's opening boundary read from the same row, so a ladder built from these ties by construction.

`start_date` and `end_date` are required and `end_date` is INCLUSIVE, as it is on get_net_sales: the service turns it into the exclusive bound, so two callers asking for 2026-01-31 and 2026-02-01 each see the period once. The window may not exceed 400 days, which is also the archive's retention, and a wider one asks for boundaries that have been deleted. `categories` and `locations` filter without changing the grain.

There is deliberately no channel filter: stock sits at a location and a unit in a warehouse is not allocated to a sales channel, so a channel argument could only be answered by inventing an allocation. Use get_inventory_table when the question is per variant rather than per period.

Scope: `inventory:read`.

## Query parameters

- `start_date` string, date, required
- `end_date` string, date, required
- `grain` string
- `categories` string
- `locations` string

## Headers

- `Tightly-Version` string

## Response `200`

One row per period boundary and category with `opening` and `closing`, the window actually read, the calendar and grain behind it, and a coverage block that describes the population these figures were measured over.

- object
  - `data` object
    - `basis_note` string
    - `calendar` string
    - `channel_axis` string, nullable
    - `cover_note` string
    - `coverage` object: Which pairs were measured, priced and costed, how stale the archive is, and what could not be attributed.
    - `grain` string
    - `rows` object[]
    - `window` object: The window actually read, `start` and `end_exclusive`.
  - `message` object: severity, service and desc.

## Other responses

- `400`: A date could not be read, `end_date` precedes `start_date`, the window exceeds 400 days, or the grain is not one the calendar module serves.
- `401`: No usable key: malformed, unknown, revoked, expired or stopped. One refusal covers all five, telling a caller which is which maps the surface for them. A request with no Authorization header at all is refused 400 before any key is looked for.
- `403`: The key may not reach this operation. `scope_missing` when the key does not hold Stock; `organization_mismatch` when the path names an organisation that is not the key's; `ip_not_allowed` when the caller's address is outside the key's allowlist.
- `429`: The key has spent its allowance for this minute. 600 reads and 120 writes a minute, counted per key, refused calls included. `Retry-After` says how long to wait.
- `503`: The database is briefly out of reach, which is what a failover or a full connection pool looks like from outside. Wait the seconds `Retry-After` names and send the request again. A read is safe to repeat. A write is not, because the connection was lost in the middle of it, so read the object back before sending it a second time. Branch on the status rather than on `code`, which is `POSTGRES_UNAVAILABLE` from one of the two seams that raise this and `SERVICE_UNAVAILABLE` from the other.

---

[API](https://skmtc.dev/tightly/apis/tightly-api.md) · [All operations](https://skmtc.dev/tightly/apis/tightly-api/llms.txt) · [OpenAPI document](https://skmtc.dev/tightly/apis/tightly-api/revisions/199f7eb35afb?raw)
