---
title: "One product with its stock and the ranges its variants span"
method: GET
path: "/api/v1/product/{product_id}"
tags: ["product"]
---

# One product with its stock and the ranges its variants span

`GET /api/v1/product/{product_id}`

## Request

- Base URL: `https://api.app.tightly.io`
- URL: `https://api.app.tightly.io/api/v1/product/{product_id}`
- Auth: HTTP bearer

One product as the catalogue holds it: title, description, vendor, images, category, subcategory and family, status and published date, country of origin, HS code, unit of measure, and the sales channels it is sold through.

Figures that differ variant by variant are served as `{min, max}` pairs rather than a single number: `unit_cost`, `sell_price`, `lead_time`, `min_order_quantity`, `weight`, `length`, `width` and `height` all take that shape. `in_stock`, `incoming_stock` and `inventory_value` are totals across the product's variants, and `variant_count` and `num_variants` both carry the count.

`location_id` narrows the stock figures to one warehouse. Without it they are the total across every warehouse.

`pim_owned_fields` names the columns the PIM has taken over, which the connector defers to on sync; it is `[]` for an organisation that has not opted in. `planning_gaps` is what planning is missing on this product, each entry `{field, label, reason, severity}` with `severity` of `missing` or `review`, and `size_curve` is the effective curve (a declaration beats a measurement beats the labelled default) while `size_curve_declared` is the raw declaration. Where the effective curve is the labelled default because the engine refused to measure one, `size_curve.fallback_reason_code` and `size_curve.fallback_reason` say which refusal and in what words, and `price_band_reason` says the same for the price band.

A product id that does not exist in this organisation is refused 404.

Scope: `products:read`.

## Path parameters

- `product_id` string, required

## Query parameters

- `location_id` string

## Headers

- `Tightly-Version` string

## Response `200`

The product, its ranges across variants, and its planning attributes.
`department` contains distinct source NetSuite Department names across variants. It is read-only, independent of Brand and Category, and does not establish purchasing subsidiary, supplier eligibility or channel assignment.

`size_curve.fallback_reason_code` is one of `no_stock_history`, `a_run_size_never_observed`, `a_sold_size_outside_the_run`, `run_broken_every_day`, `too_few_clean_days`, `too_few_clean_units` or `one_size_sold`, and `size_curve.fallback_reason` is that code in words, safe to render as it stands. `price_band_reason` carries a `reason` of `no_category`, `no_price`, `category_too_thin` or `no_spread`, with the counts behind it. Both are null where nothing was refused.

- object
  - `data` GetProductPayload
    - `brand_id` string, nullable
    - `brand_name` string, nullable
    - `category` string, nullable
    - `category_id` string, nullable
    - `country_of_origin` string, nullable
    - `currency` string, nullable
    - `department` string, nullable
    - `description` string, nullable
    - `family` string, nullable
    - `family_id` string, nullable
    - `forecast_model` string, nullable
    - `gallery_images` string[], nullable
    - `health_cover_by_variant` unknown[], nullable
      - unknown
    - `height` object, nullable
    - `hs_code` string, nullable
    - `image` string, nullable
    - `image_url` string, nullable
    - `in_current_season_plan` boolean, nullable
    - `in_stock` integer, required
    - `incoming_stock` integer, required
    - `inventory_value` number, nullable
    - `is_managed` boolean, required
    - `is_seasonal` boolean, nullable
    - `last_updated_at` string, nullable
    - `last_updated_by` string, nullable
    - `launch_date` string, nullable
    - `lead_time` object, nullable
    - `length` object, nullable
    - `min_order_quantity` object, nullable
    - `num_variants` integer
    - `options` object, nullable
    - `performance_category_by_location` PerformanceCategoryByLocation[], nullable
      - `capital_quadrant` string, nullable
      - `location` string, required
      - `performance_category` string, nullable
    - `pim_owned_fields` string[], nullable
    - `planning_attribute_sources` object, nullable
    - `planning_gaps` object[], nullable
    - `price_band` string, nullable
    - `price_band_reason` object, nullable
    - `price_band_source` string, nullable
    - `product_id` string, required
    - `product_name` string, required
    - `product_status` string, nullable
    - `product_title` string, nullable
    - `published_at` string, nullable
    - `published_status` string, nullable
    - `replenishment_mode` string, nullable
    - `replenishment_strategies` string[], nullable
    - `sales_channels` ProductSalesChannel[], nullable
      - `id` string, required
      - `name` string, required
    - `scoped_suppliers` object[]
    - `sell_price` object, nullable
    - `selling_window_end` string, nullable
    - `selling_window_start` string, nullable
    - `shopify_tags` string[], nullable
    - `shopify_url` string, nullable
    - `size_curve` object, nullable
    - `size_curve_declared` object, nullable
    - `status` string, nullable
    - `subcategory` string, nullable
    - `subcategory_id` string, nullable
    - `suppliers` ProductVariantSupplier[], nullable
      - `is_default` boolean, nullable
      - `supplier_id` string, required
      - `supplier_name` string, nullable
    - `trading_partners` object[], nullable
    - `unit_cost` object, nullable
    - `uom` string, nullable
    - `variant_count` integer
    - `variant_options` object, nullable
    - `vendor` string, nullable
    - `weight` object, nullable
    - `weight_unit` string, nullable
    - `width` object, nullable
  - `message` object: severity, service and desc.

## Other responses

- `401`: No usable key: malformed, unknown, revoked, expired or stopped. One refusal covers all five, because telling a caller which is which maps the surface for them.
- `403`: The key may not reach this operation. `scope_missing` when the key does not hold Products; `ip_not_allowed` when the caller's address is outside the key's allowlist. `plan_excludes` when the organisation's plan does not include the public API itself, which is sold with Essentials and is asked of every key minted under that gate. A key minted before the gate existed is never refused by it, whatever the plan holds. Products is ungated, so that is the only plan reason served here.
- `404`: No product in this organisation carries that id.
- `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)
