# Catalogue: products, categories, filters, images

> **Резюме по-русски.** Каталог: чтение и наполнение по ключу. Главное — §3:
> фильтр без префикса `filter.` проглатывается молча. Замерено на живом API:
> `?category_id=181` вернул 426 товаров вместо 60, ответ 200, ошибки нет — есть
> не те данные, и витрина из них выглядит правдоподобно. Единственная проверка,
> которая ловит этот класс: сверить `total` до и после добавления фильтра.
>
> Второе по важности — §7: **галерея не поле товара, а отдельный ресурс**
> (`/galleries`). Товар, категория и статья только ссылаются на неё
> (`gallery_ids[]`), одна галерея законно стоит на нескольких объектах, а набор
> варианта берёт любую галерею магазина — это и есть «галерея из другого
> товара». Занятую галерею удалить нельзя. Роль элемента (картинка · видео ·
> звук) выводит сервер из типа файла; вручную задаётся только `html`.

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

## 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 — `own-markup.md` | **prices freeze** 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` | ≤10 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.

## 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` and `attribute_filters`.

**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 |
| `attribute_filters[0].attribute_id`, `.values`, `.min`, `.max` | facets, indexed syntax, max 20 |

`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.

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;
- `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 |

`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.

## 6. Images and the resizer

Files come back as **absolute storage URLs** —
`https://storage.yandexcloud.net/vizen-prod-files/sunset/view/product/…png` —
and that URL is the original, never what you put on a page. The resizer is
same-origin on the shop domain and serves `/w/{width}[/webp]{path}`, where
`path` is that URL **minus the storage host**:

```
https://{shop}.vizen.shop/w/320/webp/vizen-prod-files/sunset/view/product/e3/83/….png
```

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.

⚠️ **`HEAD` on a resizer URL answers 404 while `GET` answers 200.** Measured on
the same URL in the same minute. Do not conclude the resizer is broken.

## 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.** `price.currency` in a
  request is ignored; the answer carries the shop's. 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 `categories` adds unless you send `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. Mixing
  them is a silent 200;
- 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.

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

## 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 | ignored; the shop's currency is returned |
| `company_id` in a write body | ignored; the shop comes from the key |
| `order` when creating a category | ignored; the section goes last |
| `preview` instead of `preview_id` on edit | 200, the image does not change |
| `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 **≤10 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.

## 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.
