---
title: "Net sales at category × channel type × period, the actuals feed for finance"
method: GET
path: "/api/v1/sales/net-sales"
tags: ["sales"]
---

# Net sales at category × channel type × period, the actuals feed for finance

`GET /api/v1/sales/net-sales`

## Request

- Base URL: `https://api.app.tightly.io`
- URL: `https://api.app.tightly.io/api/v1/sales/net-sales`
- Auth: HTTP bearer

Net sales bucketed by category, channel type and period: one row per combination with `net_sales` and `net_items_sold`, plus the window, the grain, the calendar, a `coverage` block counting the orders and dimensions behind the rows, and a basis note. This is the coarse actuals feed a plan is measured against.

`start_date` and `end_date` are required and `end_date` is INCLUSIVE, as it is on get_stock_on_hand: 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; a caller wanting more asks twice.

`categories` and `channel_types` filter and never change the grain, and `coverage` then describes the filtered population, so a filtered total is honest about its own denominator instead of reading as the whole book.

Use get_sales_table when the question is per variant. This is the same book at a coarser grain, keyed the way a plan is keyed.

A key issued to one account is refused with `account_mismatch`: this read covers every account on the book. That account's own sales are at `GET /sales/table`.

Scope: `sales:read`.

## Query parameters

- `group_by` 'category' | 'product'
- `commitment_id` string
- `start_date` string, date, required
- `end_date` string, date, required
- `grain` string
- `categories` string
- `channel_types` string

## Headers

- `Tightly-Version` string

## Response `200`

One row per category × channel type × period, the window actually read, the calendar and grain behind it, and a coverage block describing the population these totals were measured over.

- object
  - `data` object
    - `basis_note` string
    - `calendar` string
    - `commitment_scope` object, nullable: Exact commitment membership and date bounds applied to these actuals; null for an unscoped read.
      - `commitment_id` string
      - `membership_basis` string
      - `membership_date` string, date
      - `name` string, nullable
      - `sales_channel_id` string, nullable
      - `variants` integer
      - `window_end_exclusive` string, date
      - `window_start` string, date
    - `coverage` object: The population behind the totals, what was measured, what was priced, and what could not be attributed.
    - `grain` string
    - `rows` object[]
    - `window` object
  - `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 query can strike.
- `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 Sales; `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; `account_mismatch` when the key is issued to one account, because this operation covers every account.
- `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)
