# Catalogue: products, categories, filters, images

**Status:** current · **Verified:** 2026-08-19/20 (§3–§6, §9) + 2026-08-30
(§7 galleries, live curl against the wave-3 stand)
**Owner:** Vizen API documentation · **Serves:** `GET /docs/catalogue` · **Skill:** `vizen-catalog`
**Related guide:** `/docs/catalog-import` — the import *scenario*, a different document

**Update 2026-10-21:** changing the type of an existing section; document gates are
`TestAreaCopiesInSync` and `TestAreaHasEssentials`, behaviour is covered by the
integration suite `category_type_change_integration_test.go`.

## 1. What you can do here

Read a shop's catalogue — categories, products, prices, attributes, images — and
fill it: create sections and products, attach photos, wire variants or linked
products, publish. The shop is chosen by the key, not by a parameter.

## 2. Decide first

| If you want… | Take this path | Cost |
|---|---|---|
| a product row on a page | `productListing` widget — `widgets.md` | live data, look bounded by props |
| a product row in your own design | own markup over the live feed `products[]` — `own-markup.md` §2 | live prices; chosen attributes and group tiles on request. Prices freeze only if typed into the markup |
| one card, many combinations | variants inside one product | one URL, no separate SEO |
| a URL and content per combination | linked products in a group | N products plus a group to maintain |

When a variant needs to look different, pick one of three — they do not replace
each other:

| You want | Mechanism | Where it is written | Cost |
|---|---|---|---|
| a different **chip** and cart-row photo, one shared slider | the set's `image_id` | `PUT /products/{id}/variants` | one picture per set |
| **different photos in the slider**, one product, one URL | a gallery plus the set's `gallery_id` | `POST /galleries` → `PUT /products/{id}` → `PUT /products/{id}/variants` | ≤15 galleries per object × ≤24 items; SEO stays shared |
| a **URL, SEO, text and grid card of its own** | linked products | N × `POST /products` + `/product-groups` | N products to maintain; extra cards hidden with `catalog_hidden` |

`image_id` and `gallery_id` are independent fields: the first drives the chip
and the cart row, the second only the slider. The variants-vs-linked-products
choice is made once and is expensive to reverse; the full comparison is in
`GET /docs/catalog-import` §4.

### 2.1 Change the type of an existing section

Use the existing `PUT /categories/{id}` with `{"item":{"type":"page"}}`.
The four admin choices are `product`, `page`, `news` and `site`; legacy `model`
remains supported by the API. A PAT requires the existing `catalog:write` scope;
the shop and allowed contour come from the authenticated context, not an `item`
company parameter. No new scope or endpoint is introduced.
The admin calls this area **Site structure** (Структура сайта); URLs stay unchanged.

A real type change requires a non-home section without live direct child sections.
`CATEGORY_HAS_CHILDREN` means move or remove those children first.
`CATEGORY_HAS_CONTENT` means the target cannot accept its direct products or
articles: product membership includes primary and additional categories, and
unpublished articles count. A retained site-folder document with source/assets or
any saved HTML release, including an inactive one, gives
`CATEGORY_HAS_SITE_CONTENT`. An empty service document alone does not block.
The same protection also prevents reviving a dormant site's old contents by
changing back to `site`. `HOME_CATEGORY_TYPE_FIXED` protects the home page.
The legacy `model` target keeps its existing `MODEL_MUST_BE_LEAF` child error.
Unknown types give `INVALID_CATEGORY_TYPE`; a foreign section gives NotFound
without disclosing its contents. Send properties in `item`; moving the section
still uses `/categories/move`, not `item.parent_id` on this PUT.

These refusals leave the edit unchanged. The API does not convert, move, detach or
delete content. Preserve the ID, slug, SEO and design; prepare the contents using
their existing APIs before retrying. Sending the current type, or omitting `type`,
still permits an ordinary properties edit. DEV edits are contour-local. Type changes and writes that attach or restore
products, articles, children or site content share a transactional category-tree
lock and recheck the effective category before committing. This includes batch
product imports and trusted DEV site-release writes. A stale content write may
be refused after another request changes its target type; choose a compatible
section before retrying. Publish validates the applied type changes and applied
new/moved/restored content references against the final state, excludes keep_prod
rows, and rolls back an incompatible publication.

The category PUT, a subsequent move, and card settings are separate operations.
A failed later operation does not undo a confirmed PUT. Use its returned category
to show saved properties, then retry only the unconfirmed operation.

## 3. Filters need the `filter.` prefix — this is not cosmetic

Measured on the live API, shop `kiberpank` (company 11), calls one apart:

```
GET /products?filter.company_id=11&page.number=1&page.limit=1        → total 426
GET /products?filter.company_id=11&…&category_id=181                 → total 426   ← swallowed
GET /products?filter.company_id=11&…&filter.category_id=181          → total 60    ← works
GET /products?filter.company_id=11&…&filter.slug=abc                 → total 426   ← swallowed
```

All four answered **200**. A parameter without the prefix — and any unknown
`filter.*` too — is dropped without a word: grpc-gateway's default query parser
ignores paths it cannot map, and the only top-level fields of the request are
`filter`, `page`, `sort`, `attribute_filters`, `attribute_codes` and `group_mode`
(the last two — §4.1).

**The check that catches this whole class:** compare `total` before and after
adding the filter. Unchanged means the parameter was not understood. One extra
call per new filter, and it is the only signal you get.

Anonymous callers get one break: without `filter.company_id` the tenant cannot be
resolved and the call fails loudly (`403 AUTH_COMPANY_PROBLEM`). With a token the
tenant comes from the key, so nothing fails — you simply get everything.

## 4. The filters that exist

| Parameter | Meaning |
|---|---|
| `filter.category_id` | products of one section |
| `filter.include_subtree` | with `category_id`: that section **and every section nested under it** |
| `filter.ids` | several products at once — **repeat the parameter** |
| `filter.query` | search by name |
| `filter.is_published` | published only |
| `filter.min_price` / `filter.max_price` | price range |
| `filter.company_id` | which shop (anonymous reads; with a token the key decides) |
| `page.number` / `page.limit` | paging, `number` starts at 1, `limit` 1–100 |
| `sort.field` / `sort.order` | ordering: `name`, `price`, `created_at`, `category_id`; `ids` — the order of `filter.ids` (§4.1) |
| `attribute_filters[0].attribute_id`, `.values`, `.min`, `.max` | facets, indexed syntax, max 20 |
| `attribute_codes` | selected attributes on every item (§4.1) — **repeat the parameter**, order matters, max 32 |
| `group_mode=collapse` | one position per group of linked products (§4.1) |

`filter.ids` is a repeated field, not a list: `filter.ids=1399,1400` answers
**400 `INVALID_REQUEST`**, `filter.ids=1399&filter.ids=1398` answers 200 with
`total: 2`. Measured both ways.

### 4.1 Selected attributes and one tile per product group

Two additive parameters of the same `GET /products`. Without them nothing extra
is read; the new fields still come back with their empty values (`group_id: "0"`,
no `attributes`, `groups`, `group_members`), so a client that ignores unknown
fields sees the old answer.

Buyer visibility is a prerequisite for selected attributes **and group axes**.
Configure it by `/docs/catalogue` §8.3 before diagnosing a plain tile as a group bug.

**`attribute_codes`** — attribute codes to return on every item, as
`ProductShort.attributes[]` in **the order you asked**:
`…&attribute_codes=bed-size&attribute_codes=material`. Each element is
`{attribute_id, code, name, data_type, section, display, value, options[]}`;
`display` is the ready string (units, option labels), `value` is the raw value
(`google.protobuf.Value`: a string, a number, a boolean or a list), `options[]`
are the chosen options of a select/multiselect (`id`, `value`, `label`,
`color_hex`, `image_url`). The int64 ids — `attribute_id`, `options[].id`, and
in `groups[]` `group_id` and `axes[].attribute_id` — travel **as strings**.
Only attributes a buyer may see **in lists** come back; a hidden code and an
unknown one are equally absent. The code rule is `^[a-z][a-z0-9_-]{0,63}$`; a code
that breaks it is treated as unknown, not as an error. More than 32 codes (counted
**before** duplicates are dropped) or a code longer than 64 characters is a `400`
with the validator's message in the body. `attribute_codes=a,b` is ONE code
`a,b` — it breaks the rule and is silently absent; repeat the parameter.

**`group_mode=collapse`** — one position per **product group** (linked products
with axes such as size and colour, `/product-groups`). A group is in the result
when any member passes all filters; the position is the best member for the
requested sort (by default — the first in the group's member order; a
catalog-hidden member never is). `total` and paging count **groups**, so a page
boundary never splits a family. With `filter.parent_product_id` it is a `400
GROUP_MODE_PARENT_UNSUPPORTED`.

Next to `result` the answer then carries:

| Field | What it is |
|---|---|
| `result.items[].group_id` | the position's group; **int64, so a string**: `"0"` — no group, or switching is not available |
| `groups[]` | only groups that **switch** (at least two members told apart by visible axes), **in the order of `result.items`**: `axes[]` (`attribute_id`, `code`, `name`, `display`, `options[]` — the whole dictionary: `id`, `value`, `label`, `color_hex`, `image_url`, `sort_order`) and **all** published members a buyer may see — `values` by axis code, `url`, `slug`, `preview_url`, `image_url`. `is_current` marks the member that stands as the position |
| `group_members[]` | full cards (`ProductShort`: own price, stock, picture, url) of members that are not positions — priced by the same code as `items` |

**Recipe — build a switcher from one answer:**

1. For a position `p` find `groups[]` with `group_id == p.group_id` (compare as strings).
2. A member's full card is the element of `result.items` **or** `group_members`
   whose `id` equals the member's `product_id`. No card — open the member by its
   `url` instead.
3. A chip of axis `a` for value `v`: the member whose `values` equal the current
   member's on every other axis and `v` on `a`. None — the combination does not
   exist: show it disabled, **never substitute** another SKU. Members of a sparse
   group may be unreachable by single-axis chips — offer an "all variants" list.
4. Members do **not** obey the request's filters (another section, catalog-hidden
   members are legal in a family) — never draw them as separate positions.

Limits: full cards — at most 16 per group and 200 per page together with the
positions; the matrix in `groups[]` is never cut. Do not rely on these numbers —
check whether a card is present. Members are re-checked (published, accessible)
when the cards are read; a group left with one member is a plain position.

**A pick list in your order — `filter.ids` + `sort.field=ids`.** The items come
in the order of the list, not by date. With `group_mode=collapse` a listed product
is **the position of its group**: list one member per group and you choose both the
order of the tiles and the variant shown first; the rest of each group still comes
in `groups[]` / `group_members`. If two selected ids belong to the same group,
they produce **one tile**: the first eligible id in the list chooses its position
and initial SKU. This also applies to insertion `productsIds`
(`/docs/own-markup` §2). A listed id that is not eligible (draft, catalog-hidden,
closed by access) gives no position and no substitute member is picked: if no
other listed id of that family is eligible, the family is absent from the feed.
`sort.field=ids` without `filter.ids` does nothing.

```
GET /products?filter.company_id=19&filter.ids=2503&filter.ids=2509&filter.ids=2498
    &sort.field=ids&group_mode=collapse&attribute_codes=bed_size&attribute_codes=bed_color
    &page.number=1&page.limit=48
→ items 2503, 2509, 2498 in that order — each the shown member of its group
```

**Both parameters answer as a buyer for every caller.** The result is meant for a
shop page that every visitor sees, so the shop's own key, the owner's session and
the dev preview get what an anonymous buyer gets: published, not catalog-hidden
products, buyer prices, buyer access, buyer-visible facets and attributes, no
internal price columns. For the full owner view use the plain request or
`GET /products/{id}`. An axis hidden from buyers in lists is removed with its
values and link pictures; if the members become indistinguishable, the group
comes back as a plain position (`group_id` `"0"`).

Pagination, measured: `page.limit=101` → 400 (`value must be inside range
[1, 100]`); `page.limit=100` alone → 200; `page.number=2` alone → 400, about the
missing `Limit`. Send both halves and the asymmetry stops mattering.
⚠️ `/docs/catalog-import` §2.2 states the reverse rule; the measurement above is
the current behaviour.

**`filter.category_id` alone means the section *literally*.** A product belongs
to a section through `category_id` or `categories[]` — nothing walks the tree. In
a shop whose goods hang on leaf sections (brands, models) and whose top section
is only a folder, that top section answers `total: 0` while its children hold the
whole catalogue. `filter.include_subtree=true` aggregates the section and every
section nested under it. Measured on a folder section 1128 holding two brand
sections, one of which has a nested line of its own:

```
GET /products?filter.company_id=12&filter.category_id=1128                           → total 0
GET /products?filter.company_id=12&filter.category_id=1128&filter.include_subtree=true → total 4
GET /products?filter.company_id=12&filter.category_id=1129                           → total 2   ← one brand
GET /products?filter.company_id=12&filter.category_id=1129&filter.include_subtree=true → total 3   ← + its nested line
```

The default is unchanged and stays the literal reading, so an admin list of "the
products of this section" keeps counting what is actually pinned there. The flag
only works together with `category_id` — on its own it is one of the silently
dropped parameters of §10. Deleted sections are not part of a subtree, and the
walk never leaves the shop that owns the root section. `page.*`, `sort.*` and
`attribute_filters` apply to the aggregated set exactly as they do to a single
section; `filterable_attributes` in the same answer still describes the section
you asked for (its own filterable definitions), not the union of its children's.
Same parameter name and same meaning as `GET /articles?include_subtree=true` for
news rubrics — the only difference is the `filter.` prefix, which `/products`
requires for every filter (§3).

## 5. Shapes that surprise

- **`result` is not the same type everywhere.** `/products` returns an object
  `{items, total, currentPage}`; `/categories` returns a plain **array** — 84
  categories for company 11, no `total`, no paging;
- **the shop parameter differs by endpoint.** `/products` wants
  `filter.company_id`; `/categories` wants top-level `company_id` and answers
  `403 COMPANY_ID_PROBLEM` to the prefixed form. Measured both;
- **`category.id` is a number and `category.parent_id` is a string** (`107` vs
  `"107"`), as is `root_id`. The rule generalises: proto3 JSON renders `int64` as
  a string and `int32` as a number, so the type of an id follows the field, not
  the endpoint. File ids are UUID strings;
- **a category is also a page.** `type` is `product`, `page` or `news` — landing
  pages live in the same list as sections. ⚠️ **But a `type: "page"` category
  does not render a bound `body` slot yet.** The binding is accepted, reads back
  as `source: "own"` and draws nothing; the storefront support for that type is
  not built. Until it is, a landing is a `type: "product"` category with no
  parent — same address, same slots, and the section simply has no products;
- **`parent_id` is changed only by `POST /categories/move`.** Sending a
  *different* parent to `PUT /categories/{id}` is refused with
  `CATEGORY_PARENT_USE_MOVE` (it used to be dropped silently, `200` and the old
  parent in the answer). Moving a node has to check the tree for cycles, depth
  and leaf types, and that lives in `move` alone. Sending the parent the
  category already has is accepted and does nothing;
- **a move changes addresses** — of the category, its subcategories and their
  products. Call `POST /categories/move` with `dry_run: true` first, show the
  user `url_changes` and ask whether to pin the old addresses with rules
  (`pin_rules: true`). URL lifecycle (move, rename, delete): `/docs/redirects`;
- `product.price` is `{price, old_price, currency, promotion_name}`; the last two
  are usually empty (§9);
- **`preview` may be borrowed.** `preview_auto: true` means the product has no
  preview of its own and the server showed the first image item of its first
  gallery instead. Do not write that id back into `preview_id` — you get a
  duplicate;
- `gallery_ids` and `gallery_id` are **arrays of strings / a string**, not
  numbers: they are `int64` fields, and proto3 JSON renders `int64` as a string.
  `"0"` in a variant's `gallery_id` means "no link".

### Asking the category list for fewer fields

`GET /categories` returns **every** category of the shop with **every** field —
descriptions, the whole SEO block, authors, timestamps, counters. A shop with
704 sections answers **679 375 bytes**, and a storefront asks for that list on
every page render, because it is the only public source of `parent_id` (menus,
breadcrumbs, tree URLs). Almost none of it is read.

```
GET /categories?company_id=182&view=menu
```

| `view=` | What comes back |
|---|---|
| absent or `full` | the complete `Category` — **byte-for-byte what it always was**, so nothing you already wrote changes |
| `menu` | only the fields a navigation needs |
| `tree` | the answer goes to **`nodes`** (not `result`): a light node per category — `id, parent_id` (absent on root categories: proto3 JSON omits `0`), `name, order, type, slug, is_published, is_show_menu, is_home, is_deleted, count_products` (owner only), `count_articles, dev_dirty, preview_url`. For drawing a category tree or a picker: about 8 KB compressed for 650 sections instead of ~75 KB. Open one category's full data with `GET /categories/{id}` |

`deleted_only=true` (owner of the shop only) returns **only** deleted categories,
without the live ones — the "show deleted" bin; on a storefront the parameter is
ignored. Deleted categories never come mixed into a normal list.

⚠️ **`is_show_menu` decides whether a category appears in the navigation.**
The menu, the header's fallback links and the footer's automatic catalogue
column all skip a category with `is_show_menu:false` — and skip its whole
branch, so a hidden parent never orphans its children into the top level.
Default is `true`: omit the field in `POST /categories` and the category is in
the menu. To build a landing page without touching the shop's navigation, send
`{"name":"Sale","type":"page","is_show_menu":false}` — the page is reachable by
its own URL and by any link you put on it. `PUT /categories/{id}` honours the
value you send and leaves it alone when you omit it.

`menu` fills exactly these: `id`, `name`, `seo.slug`, `parent_id`, `order`,
`type`, `is_published`, `is_show_menu`, `is_home`, `is_deleted`, `preview.url`,
`count_articles`, `sidebar_config`. Everything else comes back empty or zero:
`description`, the rest of `seo` (`page_title`, `meta_title`,
`meta_description`, `meta_keywords`, `og_image_id`, `og_image_url`, `noindex`,
`nofollow`), `preview_id`, `preview.id`, `count_products`, `root_id`,
`page_template`, `created_at` / `updated_at`, `created_by` / `updated_by`,
`comments_enabled`, `questions_enabled`, `gallery_ids`, `galleries`.

The one category whose description and SEO you actually render is the one the
visitor opened — ask for it by itself: `GET /categories/by-slug/{slug}` (or
`/categories/{id}`) answers with the full shape, and you were fetching it anyway
for the page's blocks and design.

Measured on the same 704-section shop: **679 375 → 542 801 bytes**, and the
SQL behind it reads 13 columns instead of 30. The remaining weight is real
content — section names and preview URLs — plus the JSON skeleton: this API
renders every field of a message, including the empty ones, so the keys
themselves stay in the answer.

An unknown value answers `400 CATEGORIES_VIEW_UNKNOWN` rather than quietly
falling back to `full`, for the same reason the gallery projection does: "I
passed the parameter and nothing got lighter" is the one failure you could not
debug.

### 5.1. `combo_items[]` — the contents of a set

**A set is priced by its composition.** On a `kind:"combo"` product the server
computes both figures and ignores the product's own stored price fields:
`price` is the sum of the in-set prices, `old_price` the sum of the components'
showcase prices. The same two numbers come back from the card, from every
listing and from the cart. `old_price` is **empty** when nothing is cheaper
inside the set — a card never strikes its own price. Writing `price` to a set
is still accepted and stored; the stored figure simply never reaches a buyer,
and comes back into use if the composition is emptied (the product turns back
into `kind:"standard"`).

`GET /products/{id}` on a `kind:"combo"` product carries its components:

| Field | What it is |
|---|---|
| `product_id`, `name`, `slug` | the component product |
| `url` | its storefront address, same canon as `Product.url` and the sitemap. Empty when there is no canonical path (no slug, or a slugless/dead ancestor) |
| `preview` | `MiniFile` of the component |
| `price`, `old_price` | the component's own showcase price |
| `quantity` | how many of it the set holds |
| `price_mode`, `price_value`, `effective_price`, `base_price` | its pricing inside the set |

⚠️ **Field names in your own markup are not these names.** The page scope
uses `c.effective_price` / `c.price_text` for a component in
`product.combo_items[]`;
`c.item_price` belongs to an offer in `product.in_combos[]`, not to the set’s
composition. Both expose `c.url`. The scope is built for writing markup,
not for mirroring REST; substitutions have no expressions. Read `/docs/own-markup` for the scope names;
do not assume one list translates to the other.

## 6. Images and the resizer

A file address comes back in one of two shapes, depending on how the shop
stores its files: an **absolute storage URL**
(`https://storage.yandexcloud.net/vizen-prod-files/sunset/view/product/…png`)
or a **path on the shop itself** (`/files/sunset/view/product/…png`). Either
way it is the original, never what you put on a page.

Do not write a recipe that assumes one shape. Take the address the API gave
you, strip the scheme and host if it has one, and put the resizer prefix in
front of what is left:

```
url  = item.file.url                        # or preview.url, gallery[].url
path = url.replace(/^https?:\/\/[^/]+/, '')  # absolute → path; a path stays a path
src  = `/w/${width}${webp}${path}`          # same-origin on the shop domain
```

The `/webp` segment is **not** always there: it is added for raster images that
are not already webp, and omitted when the source is a `.webp` or an SVG. So
`/w/640/webp/files/…jpg` and `/w/640/files/….webp` are both the resizer doing
the same thing — two different sources, one mechanism. Build the segment from
the source extension rather than hard-coding it, or simply accept both when
checking your work.

⚠️ A gallery item keeps its address in **`items[].file.url`**, not `items[].url`.

Pre-generated widths: **32 · 64 · 128 · 320 · 640 · 1024 · 1600 · 2048 · 2560**.
Others work but are computed on the fly and the first visitor waits. Take the
step at least twice the CSS size. Quality has a per-step default, overridable
with `?q=40..95`; SVGs are not resized. Measured on one product photo: original
`1 555 258` bytes against `9 836` for `/w/320/webp/…` — 158× for a 320px card.

For image URL checks with `HEAD` / `GET`, use `/docs/troubleshooting` §3
(the old HEAD-only 404 is fixed).

## 7. Galleries are a resource of their own

A gallery is **not a field on a product**. It is a company-level resource with
its own CRUD; products, categories (of every type) and articles merely *link* to
it. That single fact decides everything below.

| Call | What it does |
|---|---|
| `GET /galleries` | live galleries of the shop, newest first, items included; **paged** — `?page.number=&page.limit=` (1..100), first 50 without params; the response carries `total` and `currentPage` next to `result[]` |
| `GET /galleries/{id}` | one gallery with its items |
| `POST /galleries` | create the gallery together with its items |
| `PUT /galleries/{id}` | partial update: name and/or items |
| `DELETE /galleries/{id}` | soft delete; a gallery in use cannot be deleted |
| `GET /galleries/{id}/usage` | who uses it: products, categories, articles, variant sets |

```json
{ "id": "31", "name": "Blue", "source": "images", "canvas_id": "0",
  "items": [
    { "item_key": "8231acd9-…", "kind": "image",
      "file": { "id": "<file_id>", "url": "…" }, "poster": null,
      "html": "", "caption": "front" },
    { "item_key": "4fba917d-…", "kind": "html", "file": null,
      "html": "<b>Assembly</b>", "caption": "" } ] }
```

**The server decides the role.** `kind` is derived from the file's type —
`image | video | audio`. The only role you set by hand is `"html"`, which has no
file. A `kind` that disagrees with the file is **refused**, not quietly fixed:
`400 GALLERY_ITEM_KIND_MISMATCH`. Measured — `kind: "video"` on an image file
answers exactly that. A silent fix would produce a storefront that does not
match what you sent.

Linking is identical for all three owners, and follows the `categories_replace`
pattern:

```
PUT /products/808    { "item": { "gallery_ids": ["31","32"], "gallery_ids_replace": true } }
PUT /categories/85   { "item": { "gallery_ids": ["31"],      "gallery_ids_replace": true } }
PUT /articles/12     { "item": { "gallery_ids": ["31"],      "gallery_ids_replace": true } }
```

A non-empty list replaces the set in the order given; an **empty list without
the flag means "do not touch"** (so copying a `GET` body into a `PUT` is safe);
an empty list **with** the flag unlinks everything. The content of a gallery is
edited through `PUT /galleries/{id}`, never through its owner — and there
`items` follows the same rule, with its own flag: `items: []` plus
`items_replace: true` clears the gallery, and without the flag the last item
cannot be removed at all.

Consequences you cannot design around:

- **one gallery can sit on many objects.** Measured: gallery 9 answered `usage`
  with two products and one variant set at once. Editing it changes every card
  that shows it — that is the point, and it is also the trap;
- **a variant set links to any live gallery of the shop**, not only to a gallery
  of its own product (`PUT /products/{id}/variants`, `items[].gallery_id`). This
  is the supported way to "take the gallery from another product". A gallery
  deleted later degrades on read to `"0"` and the storefront falls back to the
  object's first gallery;
- **`galleries` can be longer than `gallery_ids`.** A gallery a variant set
  points at is returned in the card even when the product itself does not link
  it — appended at the end of `galleries`, deliberately kept out of
  `gallery_ids` so that echoing a `GET` body back into a `PUT` cannot attach a
  neighbour's gallery. Read the link set from `gallery_ids` and look content up
  in `galleries` **by id, never by position**. The preview fallback counts
  linked galleries only: a product whose gallery arrives solely through a
  variant reference answers `preview: null`, `preview_auto: false`. Measured;
- **a gallery in use cannot be deleted**: `400 GALLERY_IN_USE: used in N places`.
  Unlink first (`gallery_ids: []` + `gallery_ids_replace` on the owners,
  `gallery_id: "0"` on the sets), check `GET /galleries/{id}/usage`, then delete;
- **lists never carry galleries.** `GET /products` omits the fields altogether
  and `GET /categories` returns them empty. Both still carry `preview`, computed
  as the first image item of the first gallery when the object has no preview of
  its own. Read galleries from the card endpoints only.

`source: "canvas"` (with `canvas_id` ≠ 0) means the items are driven by a canvas
document and the ones you send are kept as a backup until you unlink. Turning
that mode on **and** sending items in the same request answers
`400 GALLERY_MANAGED_BY_CANVAS`; a document already driving another gallery
answers `400 GALLERY_DUPLICATE_CANVAS`. All measured.

### Asking the card for fewer frames

A product photographed in 360° for a dozen colours carries a dozen galleries of
35–36 frames. The card endpoints return all of them, and a page shows exactly
one. Measured on a live shop: `GET /products/by-slug/…` was **159 KB, of which
138 KB (87 %) was galleries and 91 % of that went unused on every render**.

Both card endpoints — `GET /products/{id}` and `GET /products/by-slug/{slug}` —
take an optional projection:

| `galleries=` | What comes back |
|---|---|
| absent or `full` | every gallery with every item — **the answer is byte-for-byte what it always was**, so nothing you already wrote changes |
| `cover` | every gallery keeps **its first item only** |
| `active` | the **shown** gallery keeps all its items, every other gallery keeps its cover |

`active` needs to know which set the page shows: pass `variant=<sku>` (or the
set's numeric id as a string when its `sku` is empty) — the same key the
storefront puts in `?v=`. Left out, or pointing at a set that no longer exists,
the server takes the **first active set**, and when that set has no gallery of
its own, the product's **first linked** gallery — the same rule the storefront
applies, so the card and the page never disagree about which gallery is shown.

Every gallery carries `items_total` — how many items it has **before** the
projection. Read counters ("1 of 36") from it, not from `items.length`, which
under a projection is 1 for the galleries you did not ask for.

Nothing else moves: the list of galleries, its order, `gallery_ids`, the preview
fallback and every other field of the card are identical in all three modes.
An unknown value answers `400 GALLERIES_PROJECTION_UNKNOWN` rather than quietly
falling back to `full` — "I passed the parameter and nothing got lighter" is the
one failure you could not debug.

```
# a grid of colour swatches: 13 covers instead of 432 frames
GET /products/by-slug/audi-rs-7?company_id=14&galleries=cover
# the page of one colour: that gallery in full, the rest as covers
GET /products/by-slug/audi-rs-7?company_id=14&galleries=active&variant=rs7-blue
# the buyer clicked a swatch and you want its frames without reloading the card
GET /galleries/31
```

`GET /galleries/{id}` knows nothing about projections and always answers with
the full set of items — that is the way to fill in a gallery you received as a
cover.

Error codes: `GALLERY_NOT_FOUND` (404 — the same answer for someone else's
gallery and for one that never existed), `GALLERIES_PROJECTION_UNKNOWN`,
`GALLERY_IN_USE`,
`GALLERY_NAME_REQUIRED` / `GALLERY_NAME_TOO_LONG`, `GALLERY_TOO_MANY_ITEMS`,
`GALLERY_DUPLICATE_ITEM_KEY`, `GALLERY_ITEM_KIND_MISMATCH`,
`GALLERY_ITEM_FILE_REQUIRED`, `GALLERY_ITEM_HTML_REQUIRED`,
`GALLERY_ITEM_HTML_TOO_LONG` (item `html` over 64 KB),
`GALLERY_FILE_NOT_FOUND` (404), `GALLERIES_TOO_MANY`, `GALLERY_DUPLICATE_LINK`,
`GALLERY_MANAGED_BY_CANVAS`, `GALLERY_DUPLICATE_CANVAS`,
`CANVAS_DOCUMENT_NOT_FOUND` (404), `CANVAS_TYPE_MISMATCH`. Everything except the
404s answers **400** — `FailedPrecondition` is mapped to 400, not 412.

## 8. Writing

- **prices are whole units of the shop's currency.** On create/update,
  omitted, empty or whitespace-only `price.currency` uses the shop's currency.
  A nonempty value must match it (case-insensitive, surrounding whitespace trimmed),
  otherwise the API returns `400 CURRENCY_IS_STORE_LEVEL`. No conversion occurs.
  The answer carries the shop's currency. The currency cannot be
  changed with a token — if the price list disagrees with `store.currency` from
  `GET /v1/account/token`, stop and ask the owner;
- `category_id` is the **primary** category, `categories[]` the extra ones; on
  edit a non-empty `categories` **replaces** the extra set (it does not add),
  and `[]` clears it only with `categories_replace: true`;
- `catalog_hidden`, `comments_enabled`, `questions_enabled` are tri-state
  **strings** (`""` / `"true"` / `"false"`); `is_published` is a real boolean;
- `order` on category creation is ignored — a new section goes last; order is set
  by `POST /categories/move`;
- the main image is `preview: {id}` on create, `preview_id: "…"` on edit
  (where `preview: {id}` is accepted as an alias);
- `PUT /products/{id}` is partial, except `price` (writes `old_price` too) and
  `seo` (rewrites `og_image_id`, `noindex`, `nofollow`); recipes for bulk edits,
  taking off sale, copy, delete, sets and moving a catalogue are in
  `/docs/catalog-import` §12;
- galleries are linked, not embedded: `gallery_ids[]` (+ `gallery_ids_replace`)
  on the owner, content through `PUT /galleries/{id}` — see §7;
- **a category's images must be your own live files.** `preview_id` and
  `seo.og_image_id` on `POST /categories` and `PUT /categories/{id}` are checked
  for ownership: someone else's or a deleted file answers **404
  `CATEGORY_FILE_NOT_FOUND`** — one code for both cases, so the answer never
  confirms that a foreign file exists. This is a **behaviour change**: before
  this wave a category write with a foreign `preview_id` went through. Only the
  fields you actually send are checked, so renaming a category needs no media.
  Same family: `PRODUCT_FILE_NOT_FOUND`, `ARTICLE_FILE_NOT_FOUND`,
  `GALLERY_FILE_NOT_FOUND`.

**Verify every write by reading the object back and comparing the fields you
sent.** A 200 proves only that the request parsed; §10 lists what a 200 can
quietly mean.

**Refilling a whole catalogue — `POST /products/import`.** Up to **5 000** rows
per request, and a row carries the *whole* product: attributes by **code**,
variant axes and sets, and the gallery order as a list of already-uploaded
`file_id`s. The idempotency key is `sku`: a row that finds its product updates
it, a row that does not creates one — no local `article → id` manifest needed
for a re-run. Absent scalar fields keep their current value; `attributes`,
`variants` and `images` are replace-sets with the usual `*_replace` flags;
fields outside the contract (SEO, `preview`, scene, groups, stock) are never
touched. The answer is HTTP 200 with a verdict per row — `results[i]` carries
`row` (1-based), `product_id`, `action` (`created` / `updated` / `skipped`) and
`errors[]`; a `skipped` row wrote **nothing at all**, because the batch is one
transaction. Every row is validated against its own contract first — a negative
price, an over-long field or a `display` outside the dictionary comes back as
`ROW_INVALID` on that row with the offending `field`, and the neighbours are
still written. Whole-request failures: empty or over-long `items` (400), a
**dev-contour key** (403 `PAT_CONTOUR_MISMATCH` — mass import writes to the live
shop only, and the key's contour rejects the call before the handler), a body
over 64 MiB (413 `REQUEST_TOO_LARGE` — with 100+ attributes per product keep
batches at ≤1 000 rows), the per-minute row budget (429 `IMPORT_RATE_LIMITED`)
and the plan's product ceiling (429 `PRODUCT_LIMIT_REACHED`, counted over the
batch's new rows). Images go into the import's **own** gallery — created once,
reused afterwards, and never a gallery anyone else uses, including a link that
so far lives only in a dev draft; that one stays untouched and the import
creates its own next to it. The import's gallery takes the first place among
the product's links **when it is created**; an order you set by hand is never
re-played by later runs. Inside a gallery the import owns the set of `file_id`s
and their order only — captions, posters and html items survive on frames that
survive. Changing customer-visible content of an approved shared
product sends it back to moderation, exactly as a normal edit does. `POST
/products/batch` is unchanged and still right for a short list of plain cards.

Full guide with recipes and error codes: `GET /docs/catalog-import`.

### 8.1. Article author (news)

`POST /articles` (`item.author`) and `PUT /articles/{id}` (`item.author`) accept
the article's editorial author. This is resource data, not a shared template author.
The news header only renders `Article.author`. `created_by`/`updated_by`
remain audit fields and are never used as the public byline.

```json
{"item":{"author":{"mode":"manual","name":"Анна Иванова","description":"Редактор","photo_id":"11111111-1111-4111-8111-111111111111"}}}
```

- `mode: "member"`: pass the `user_id` of an active member of **your own** shop
  with the role `owner`, `admin` or `editor`. The server stores a snapshot of the
  name built from the account's first and last name, falling back to the username;
  the email is never used. A name sent in the request is ignored in this mode.
- `mode: "manual"`: `name` is required (after trimming whitespace), at most 255
  characters. `description` is an optional caption, at most 500 characters.
- A photo is optional in both modes: `photo_id` is the UUID of a live media-library
  file of your own shop and the current contour; the account avatar is not copied
  automatically. No file URL is stored; the read-only `photo: MiniFile` is resolved
  by the server.
- `author` absent from a PATCH — the existing author is kept. A sent `author`
  replaces the byline entirely; `{"mode":"none"}` removes the author and the photo.
- Changing a profile or removing a member does not rewrite stored authorship. An
  unrelated PATCH does not re-check the old membership.
- Public reads expose only `name`, `description`, `photo`: `mode`, `user_id`,
  `photo_id` are not returned. There is no author profile link or page.
- Ineligible or foreign member: `404 ARTICLE_AUTHOR_NOT_FOUND`; missing
  `user_id` in member mode: `400 ARTICLE_AUTHOR_MEMBER_REQUIRED`; empty manual
  name: `400 ARTICLE_AUTHOR_NAME_REQUIRED`; a foreign, deleted or draft-only
  photo in prod: `400 ARTICLE_FILE_NOT_FOUND`.

The team list for picking an author in the admin is the existing
`GET /v1/orgs/{id}/members`, available to owner/admin. Being eligible as an author
does not widen a member's access to team management. A PAT integration may send an
already known `user_id` under the ordinary article-edit permission; no new scope
and no public staff list is created.

### 8.2. Product comparison

When a shop turns comparison on, its storefront gets a scales button on product
cards and on the product page, a "Comparison" item in the buyer's account menu,
a badge with a counter in the standard header and the page `/compare`, where a
buyer puts up to `max_items` products of one section side by side. **Off by
default:** a shop that never saved the settings has none of this, and
`/compare` stays an ordinary catalogue address. The storefront reads the
settings on every page (see the end of this section), so a change shows up at
once.

One record per shop, taken from the key — there is no `company_id` anywhere.

| Call | Scope | Shop role | What it does |
|---|---|---|---|
| `GET /compare/settings` | `catalog:read` | any member | the settings and the reference the cabinet screen draws |
| `PATCH /compare/settings` | `catalog:write` | owner, admin | changes the fields you send; answers like `GET` |

```http
PATCH /compare/settings
{ "expected_version": 3, "enabled": true, "max_items": 6,
  "excluded_group_ids": { "ids": [27] } }
```

| Field (`settings.*` in the answer) | Meaning | In `PATCH` |
|---|---|---|
| `enabled` | comparison is on | optional |
| `header_link` | the header badge; never shows without `enabled` | optional |
| `max_items` | products in one comparison, within `max_items_min`…`max_items_ceiling` (2…10) | optional; clamped, not refused — read it back |
| `excluded_group_ids` | characteristic groups left out whole | `{"ids": [...]}` replaces the list, `{"ids": []}` clears it, an absent key keeps it |
| `excluded_attribute_ids` | single characteristics left out | the same wrapping |
| `version` | `0` while the shop has no record | send it back as `expected_version` — **required** |
| `updated_at` | the last change; absent on defaults | — |

Defaults: `enabled: false`, `header_link: true`, `max_items: 5`, no exclusions,
`version: 0`.

- **Partial.** A field you do not send keeps its value; a request that changes
  nothing writes nothing and keeps `version`.
- Exclusion ids are narrowed to **live groups and characteristics of this
  shop**; foreign, deleted and unknown ids are dropped without an error (≤ 5 000
  per list). Compare what you sent with what came back.
- The settings have no draft copy: a write changes the live storefront at once.

The reference in both answers: `groups[]` — the shop's characteristic groups by
`sort_order` (`label` is the raw i18n JSON, `name` is ready to show), each with
`attributes[]` in specification order, so a characteristic in groups of two
specifications stands in both; `ungrouped[]` — characteristics outside every
specification. Each characteristic carries `card_visibility`: `public`,
`audience` (only part of the buyers) or `hidden`. **A hidden characteristic
never reaches a comparison, whatever you exclude or include** — comparison shows
only what the buyer sees on the card (§8.3). Why it is hidden (`hidden_reason`:
`not_configured`, `staff_only`, `card_off`, `api_off`, `audiences_off`,
`audience_inactive`) and which audience sees it (`audience_id`,
`audience_name`) come to owner/admin only.

| Answer | When |
|---|---|
| 409 `COMPARE_SETTINGS_VERSION_CONFLICT` | `expected_version` is stale, or `0` while the record exists: read again and re-apply |
| 400 `COMPARE_SLUG_CONFLICT` | switching on while the shop has a live section with the slug `compare` at the root of the tree or right under the home page — `/compare` would cover it. Resend with `"confirm_slug_conflict": true` only if the owner agreed |
| 400 `INVALID_REQUEST` | a bare array instead of `{"ids": [...]}` |
| 400 (validator text) | more than 5 000 ids in a list, a negative `expected_version` |
| 403 `COMPARE_SETTINGS_FORBIDDEN` | `PATCH` by an editor; any call by someone who is not a member of the key's shop |
| 403 `PAT_SCOPE_MISSING` | the key lacks `catalog:read` / `catalog:write` |
| 403 `PAT_CONTOUR_MISMATCH` | `PATCH` with a dev-contour key, refused before the handler |
| 400 `DEV_MODE_UNAVAILABLE` | `PATCH` from the cabinet in draft mode |

What the storefront reads: `GET /v1/storefronts/resolve` carries
`compare: {enabled, header_link, max_items, excluded_group_ids,
excluded_attribute_ids}`; `null` means off. The public spec
`GET /categories/{id}/spec` gives each item its `char_group_name`, so the
comparison table is grouped exactly like the settings tab. `/compare?ids=…` is
indexable when it holds 2…`max_items` products of one section; its canonical
lists the ids in ascending order. It is not in the sitemap, and `robots.txt`
carries `Clean-param: set /compare` (`set` only marks the buyer's own
comparison). Its header and footer are designed like any other
system page: `resource_type:"system"`, id `12` (`/docs/chrome`).

### 8.3. Who sees a characteristic

Setting characteristics up — types, options, filters in a section, groups, templates and section values — is
`GET /docs/characteristics`; this section covers only their visibility.

The `{id}` is the attribute id: find it by code in `GET /attributes` (the key selects the shop, not a `company_id` query)
(each item has `id` and `code`) or in `groups[].axes[].attribute_id` of a collapse answer.

Each characteristic has its own buyer visibility, and the product page,
listings, the buyer-facing API and comparison all obey it. The shop's owner
or administrator manages it, including through their PAT with `catalog:write`.
The scope does not bypass the owner/admin membership check.

| Call | Scope | Answer |
|---|---|---|
| `GET /attributes/{id}/access` | `catalog:read` | `{attribute_id, access}` |
| `PUT /attributes/{id}/access` | `catalog:write` | the same, after the write |

```http
PUT /attributes/412/access
{ "expected_version": 0,
  "access": { "visibility": "public", "show_storefront_card": true,
              "show_storefront_list": true, "show_admin_product": true,
              "show_admin_list": true, "show_api": true, "show_export": true } }
```

- `visibility`: `public` — every buyer; `audience` — only the buyers of
  `audience_id`, an active audience of this shop, and only while the shop has
  audiences switched on; `staff` — nobody outside the shop's team.
  `audience_id` goes with `audience` only.
- Where it shows: `show_storefront_card` — the product page and comparison;
  `show_storefront_list` — listing cards; `show_api` — the buyer-facing API the
  storefront reads, so turning it off hides the characteristic from buyers
  everywhere; `show_admin_product`, `show_admin_list` — the cabinet;
  `show_export` is stored, nothing reads it for characteristics yet.
- **`PUT` replaces the whole record:** a flag you leave out becomes `false`.
  Start from `GET` and send all of them.
- `expected_version` is `access.version` from `GET`. A characteristic never
  saved answers `visibility: "staff"`, `version: 0` — **a new characteristic
  stays hidden from buyers until its access is saved**; the first `PUT` goes
  with `expected_version: 0`.
- **Before using attributes or group axes in a custom list**, read their access,
  then PUT the complete record with `visibility: "public"`, `show_api: true`,
  `show_storefront_list: true`, keeping other flags as intended. You may keep
  `show_storefront_card: false` to leave the product page unchanged. Without
  visible axes, collapse silently returns plain tiles; a group with only one
  buyer-visible member is also a plain tile (§4.1).
- **Filter side effect:** an `is_filterable` attribute visible on the list surface
  can also appear in category filter panels. Check the section after opening
  access; list visibility is not a card-only cosmetic switch.
- Errors: 404 `ATTRIBUTE_NOT_FOUND` (not this shop's, deleted, or an
  `audience_id` that does not exist), 409 `ATTRIBUTE_ACCESS_VERSION_CONFLICT`,
  400 for an invalid combination (`audience` without `audience_id`,
  `audience_id` with `public` or `staff`, an inactive audience), 403
  `CUSTOMER_ACCESS_FORBIDDEN` for an editor.

## 9. What the data may not tell you

Shop `kiberpank` has a category **"Акции" (id 109)** that is published, shown in
the menu and **empty**: `count_products: 0`, `filter.category_id=109` → `total: 0`.
On the same run `GET /promotions` returned `{"result": []}` and every product
carried `promotion_name: ""` with `old_price: 0`.

The run concluded that the platform has no promotions at all. **That was wrong.**
Promotions are a real entity — CRUD at `/promotions`, `/promo-keys` and
`/cart/promo`, gated by the PAT scopes `promotions:read` / `promotions:write`. A
key without the scope gets `PAT_SCOPE_MISSING`, not an empty list, so `[]` is a
real answer: *this shop has defined no rules*. An emptiness was read as a fact
about the platform — the same class of defect this page is about, made while
documenting it.

**The rule:** when a task depends on a property of the data whose existence you
cannot verify, ask the owner. Do not curate a "Sale" row by hand and hand it over
as the thing that was asked for. Area document: `promotions.md`.

## 10. Silently ignored

| What | What actually happens |
|---|---|
| a filter without the `filter.` prefix | dropped; 200 with the whole catalogue |
| an unknown `filter.*` (e.g. `filter.slug`) | dropped; 200 with the whole catalogue |
| `price.currency` on write | validated, not ignored; see §8 for the currency contract |
| `company_id` in a write body | ignored; the shop comes from the key |
| `order` when creating a category | ignored; the section goes last |
| `catalog_hidden` on `POST /products` | dropped; set it with `PUT /products/{id}` after creating |
| `stock_quantity` on `PUT /products/{id}` | dropped; stock is `PUT /products/{id}/stock`, closed to keys |
| `categories: []` on edit without `categories_replace` | read as "do not change", not "clear" |
| `gallery_ids: []` without `gallery_ids_replace` | same rule: read as "do not change", not "unlink all" |
| `items: []` on a gallery without `items_replace` | read as "do not change"; clearing needs the flag |
| `items` sent to a gallery whose `canvas_id` ≠ 0 | dropped; the stored items stay as a backup until you unlink the document |
| reading `galleries` / `gallery_ids` from `GET /products` or `GET /categories` | absent or empty by design — lists carry `preview` only |

## 11. Limits

- `page.limit` **1–100**, `page.number` from **1**; `filter.ids` max **100**;
- `attribute_filters` max **20** entries, `values` max **100** per entry;
- resizer widths 32…2560, quality `?q=40..95`;
- a gallery holds **≤24 items**, its name is **1..64 characters**, an object
  links **≤15 galleries**;
- `POST /products/batch` answers per item: a 200 does not mean every item landed
  — and it is the only path that returns `GALLERIES_TOO_MANY` as a machine code,
  because the single-object calls reject an over-long `gallery_ids` with the
  validator's own message first;
- `POST /products/import` takes **≤5 000** rows, and per row **≤500**
  attributes, **≤20** variant axes, **≤500** variant sets, **≤24** images (the
  ceiling of an ordinary gallery — a wider import would build a gallery the
  normal editor refuses to save), **≤64** tags; it also answers per row, and the
  same warning applies. It returns `GALLERIES_TOO_MANY` per row too, when the
  product already holds the maximum of 15 galleries.

## 12. How this was verified

- 2026-08-19, storefront run against `api.vizen.shop` (company 11). Every number
  in §3–§6 and §9 re-measured live with `curl` on 2026-08-20: the 426/60/426
  filter triple, the `filter.ids` 400-vs-200 pair, the pagination boundaries, the
  `/categories` array shape, the `403` asymmetry, and the two image sizes from
  the same file (storage original vs `/w/320/webp/…`, plus `HEAD` and `GET` on
  that identical URL).
- 2026-08-30, galleries (§7) measured with `curl` against the wave-3 stand on a
  live database: the six `/galleries` routes; `kind` derived from the file type
  and `kind: "video"` on an image refused with `GALLERY_ITEM_KIND_MISMATCH`;
  `GALLERY_ITEM_FILE_REQUIRED`, `GALLERY_ITEM_HTML_REQUIRED`,
  `GALLERY_NAME_REQUIRED`, `GALLERY_FILE_NOT_FOUND` (404),
  `GALLERY_NOT_FOUND` (404 for an id that does not exist),
  `GALLERY_DUPLICATE_LINK`, `GALLERIES_TOO_MANY` (batch only),
  `GALLERY_MANAGED_BY_CANVAS`, `GALLERY_DUPLICATE_CANVAS`,
  `CANVAS_DOCUMENT_NOT_FOUND`, `CANVAS_TYPE_MISMATCH`; one gallery linked to a
  product, a category and an article at once, plus a variant set, and
  `GET /galleries/{id}/usage` listing all of them; `DELETE` on it answering
  `GALLERY_IN_USE`; `gallery_ids: []` without the flag behaving as a no-op;
  `preview_auto: true` with the preview borrowed from the gallery; the same
  product read back from `GET /products` carrying `preview` but no galleries.
  Test objects were removed afterwards.
- Contract details read in `backend-3D/api/catalog/catalog.proto`; the swallow
  mechanism in `pkg/queryparser` and grpc-gateway's default parser; the width
  ladder in `internal/api/imgserve/imgserve.go`. Promotions in the same proto
  (Б5 RPCs) and `internal/core/services/guard.go`; `GET /promotions` without a
  token answers 401 — evidence the route is live.
- 2026-09-25, comparison (§8.2) and characteristic access (§8.3): not measured
  live. The handlers were run against a disposable Postgres with every
  migration (integration tests `TestCompareSettings*`,
  `TestCompareResolveStorefront`); the HTTP shape of `PATCH /compare/settings`,
  including the 400 for a bare array, through the real gateway
  (`TestCompareSettingsRoutes`). §8.3 read from `attribute_field_access.go` and
  its handlers.
