Catalogue: products, categories, filters, images
/docs/cataloguecurrentEN· проверено 2026-08-19
Резюме по-русски. Каталог: чтение и наполнение по ключу. Главное — §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 ← swallowedAll 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 lineThe 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.
/productsreturns an object{items, total, currentPage};/categoriesreturns a plain array — 84 categories for company 11, nototal, no paging; - the shop parameter differs by endpoint.
/productswantsfilter.company_id;/categorieswants top-levelcompany_idand answers403 COMPANY_ID_PROBLEMto the prefixed form. Measured both; - `category.id` is a number and `category.parent_id` is a string (
107vs"107"), as isroot_id. The rule generalises: proto3 JSON rendersint64as a string andint32as 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.
typeisproduct,pageornews— landing pages live in the same list as sections; product.priceis{price, old_price, currency, promotion_name}; the last two are usually empty (§9);- `preview` may be borrowed.
preview_auto: truemeans 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 intopreview_id— you get a duplicate; gallery_idsandgallery_idare arrays of strings / a string, not numbers: they areint64fields, and proto3 JSON rendersint64as a string."0"in a variant'sgallery_idmeans "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=menuview= | 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/….pngPre-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 |
{ "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
usagewith 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 ofgallery_idsso that echoing aGETbody back into aPUTcannot attach a neighbour's gallery. Read the link set fromgallery_idsand look content up ingalleriesby id, never by position. The preview fallback counts linked galleries only: a product whose gallery arrives solely through a variant reference answerspreview: 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_replaceon the owners,gallery_id: "0"on the sets), checkGET /galleries/{id}/usage, then delete; - lists never carry galleries.
GET /productsomits the fields altogether andGET /categoriesreturns them empty. Both still carrypreview, 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/31GET /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.currencyin a request is ignored; the answer carries the shop's. The currency cannot be changed with a token — if the price list disagrees withstore.currencyfromGET /v1/account/token, stop and ask the owner; category_idis the primary category,categories[]the extra ones; on editcategoriesadds unless you sendcategories_replace: true;catalog_hidden,comments_enabled,questions_enabledare tri-state strings (""/"true"/"false");is_publishedis a real boolean;orderon category creation is ignored — a new section goes last; order is set byPOST /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 throughPUT /galleries/{id}— see §7; - a category's images must be your own live files.
preview_idandseo.og_image_idonPOST /categoriesandPUT /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 foreignpreview_idwent 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.limit1–100,page.numberfrom 1;filter.idsmax 100;attribute_filtersmax 20 entries,valuesmax 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/batchanswers per item: a 200 does not mean every item landed — and it is the only path that returnsGALLERIES_TOO_MANYas a machine code, because the single-object calls reject an over-longgallery_idswith 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 withcurlon 2026-08-20: the 426/60/426 filter triple, thefilter.ids400-vs-200 pair, the pagination boundaries, the/categoriesarray shape, the403asymmetry, and the two image sizes from the same file (storage original vs/w/320/webp/…, plusHEADandGETon that identical URL). - 2026-08-30, galleries (§7) measured with
curlagainst the wave-3 stand on a live database: the six/galleriesroutes;kindderived from the file type andkind: "video"on an image refused withGALLERY_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, andGET /galleries/{id}/usagelisting all of them;DELETEon it answeringGALLERY_IN_USE;gallery_ids: []without the flag behaving as a no-op;preview_auto: truewith the preview borrowed from the gallery; the same product read back fromGET /productscarryingpreviewbut no galleries. Test objects were removed afterwards. - Contract details read in
backend-3D/api/catalog/catalog.proto; the swallow mechanism inpkg/queryparserand grpc-gateway's default parser; the width ladder ininternal/api/imgserve/imgserve.go. Promotions in the same proto (Б5 RPCs) andinternal/core/services/guard.go;GET /promotionswithout a token answers 401 — evidence the route is live.
Исходник: https://api.vizen.shop/docs/catalogue