---
title: "One account's sell-out by door, the grain a plan is actually delivered at"
method: GET
path: "/api/v1/sell-out/doors"
tags: ["sell_out"]
---

# One account's sell-out by door, the grain a plan is actually delivered at

`GET /api/v1/sell-out/doors`

## Request

- Base URL: `https://api.app.tightly.io`
- URL: `https://api.app.tightly.io/api/v1/sell-out/doors`
- Auth: HTTP bearer

One account's sell-out by door: units sold, stock on hand, variants and weeks reported, the last week reported and the sell-through rate per door, ranked by what they sold, with the account's totals beside.

`account_id` is required and `weeks` bounds the window. "24 doors clearing 98%" and "3 doors carrying the whole number" are the same headline and opposite buys.

Four optional narrowings, composing with AND: `variant_id` is the SKU, `product_id` the style, `family_id` the product family, `category` the catalogue category matched on its id or its name, exactly and case-sensitively. Every per-door figure narrows, `by_week` included, and all four are echoed. Unstated, nothing narrows.

The door list is the same under every filter: a door with nothing in scope keeps its row and reads `units: 0`, `on_hand` and `sell_through` null. `scope_reason` says why a narrowing could not be made.

`sell_through` is null rather than 0 wherever either side is unreported, since folding an unreported side to zero manufactures the 100% and 0% rates the write path refuses. `grain` is read from the rows: an account reporting one total answers `grain: account` and an empty `doors` list.

`units` and `on_hand` are not summed the same way. `units` is a flow, so a window of it is that window's sales. `on_hand` is a level: each variant's LAST reported reading at that door, summed within the door.

Sold with Essentials+: without Tightly Connect the answer is 403 `plan_excludes`.

Scope: `sell_out:read`.

## Query parameters

- `account_id` string, required
- `weeks` integer
- `product_id` string
- `variant_id` string
- `family_id` string
- `category` string

## Headers

- `Tightly-Version` string

## Response `200`

One row per door with units, on-hand, variants, weeks reported and sell-through, plus the money the account reported and the units that came back, the account total, the grain the account actually reports at, the four narrowings echoed (`product_id`, `variant_id`, `family_id`, `category`), each null for the whole account, and `scope_reason` where a narrowing could not be made. A door with nothing in the scope is a row with `units: 0`, never a dropped row.

`value_sold_cents` is what SHOPPERS SPENT at that door over the window, in whole cents of `currency`. Divide by 100 to show it, and show the code beside it. It is the ACCOUNT's takings at their own shelf price, not this business's revenue: it is larger, and it lands in a different period. `returned` is units shoppers brought back and is NOT subtracted from `units`; a return lands in the week the shopper walked in rather than the week they bought, so a window can carry more returns than sales, and a return rate is the two divided over a range wide enough to cover both.

MOST ACCOUNTS REPORT NEITHER, AND SAY SO IN WORDS. Measured across nine real retailer exports, four report no value and eight report no returns. Where an account has never sent one the figure is null and `value_absent` / `returns_absent` carry the sentence "Not reported". A null rendered as `0` would claim shoppers spent nothing, which is the one thing this read must not say. Where a window spans two currencies, because an account's confirmed currency was corrected mid-history, `value_sold_cents` is withheld and `value_absent` reads "Reported in more than one currency": adding pence to cents is a number with no unit, and nothing here converts between them. Those two sentences are the whole of `value_absent`, and `returns_absent` only ever carries the first. Branch on whether the field is null and show the sentence, rather than matching its text.

`account_total` IS THE ACCOUNT'S OWN TOTAL AND NOT A SUM OF THE DOORS. It is served from the rows a retailer sends at account grain, with no door on them, so it is present only where they send those rows and is null where they do not. Where a retailer sends both, the doors and the total are two readings of the same trade: show one or the other and never add them, which would count the account twice. `grain` says which shape the rows arrived in.

`by_week` is the periods behind each row, KEYED ON THE PERIOD: unambiguous weekly start dates and otherwise start/end dates, `2026-08-01/2026-08-31`, and not on the start alone, because a month and a week that open on the same day are two periods and one would silently replace the other. Each cell holds `units`, `on_hand` and `variants`, and states its own extent with `period_start`, `period_end`, `period_days` and `grain`, so a face draws ONE band across a month rather than four cells the account never sent. A period the account did not report is absent from the map, never present as a zero: a dead week and a week nobody sent are different facts. A week that carried stock and no sales is present with a null `units`. The cells' `units` add up to the row's `units`; a cell's `on_hand` is that week's shelf reading and adds up to nothing, since the row's own `on_hand` is each variant's last reading. The account total carries the same map, which for an account reporting one total is the only period series it has.

`last_reported` is the START of the newest period a door reported, the date `/sell-out/coverage` serves for the same account and draws its axis at; `last_period_end` is the newest closing day it reported. Both are null on a door that sent nothing in the window. Until 25 Sep 2026 `last_reported` carried the closing day.

EVERY ROW NAMES ITS GRAIN. `weeks_reported` counts PERIODS and its name predates the accounts that file whole calendar months; `period_grain` says what a period is, `periods_reported` is the same count without a unit in its name, `coverage_sentence` is what a face prints ("4 months reported"), and `weekly_rate_absent` carries the sentence a per-week rate is refused with on a monthly account. A month is never divided by 4.33, and an account that reports both keeps its weekly rate because it has weeks.

- object
  - `data` object
    - `account_total` object, nullable
    - `category` string, nullable: The catalogue category every figure is about, echoed, or null.
    - `doors` object[]
    - `family_id` string, nullable: The product family every figure is about, echoed, or null.
    - `grain` string, nullable: `store` where doors are named, `account` where one total is reported, null where nothing is.
    - `period_axis` object[]: Exact cell keys and bounds, preserving distinct weekly and monthly periods with the same start.
      - `grain` string
      - `key` string
      - `period_days` integer, nullable
      - `period_end` string, date, nullable
      - `period_start` string, date
    - `product_id` string, nullable: The style every figure is about, echoed, or null for the whole account.
    - `scope_reason` string, nullable: Why a narrowing could not be made, or null. A catalogue with no product families cannot answer `family_id`, and a filter that matched nothing looks exactly like one this catalogue cannot answer once every figure is a zero.
    - `trading_partner_id` string
    - `variant_id` string, nullable: The SKU every figure is about, echoed, or null for the whole account.
    - `weeks` integer
  - `message` object: severity, service and desc.

## Other responses

- `400`: No account was named.
- `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. `plan_excludes` when Sell-out is sold with Essentials+ and this organisation's plan does not include it; `scope_missing` when the key does not hold Sell-out; `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 a key issued to one account names another in `account_id`, and the sentence names both accounts.
- `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)
