Vizen Shop

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 pathCost
a product row on a pageproductListing widget — widgets.mdlive data, look bounded by props
a product row in your own designown markup — own-markup.mdprices freeze into the markup
one card, many combinationsvariants inside one productone URL, no separate SEO
a URL and content per combinationlinked products in a groupN products plus a group to maintain

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

You wantMechanismWhere it is writtenCost
a different chip and cart-row photo, one shared sliderthe set's image_idPUT /products/{id}/variantsone picture per set
different photos in the slider, one product, one URLa gallery plus the set's gallery_idPOST /galleriesPUT /products/{id}PUT /products/{id}/variants≤10 galleries per object × ≤24 items; SEO stays shared
a URL, SEO, text and grid card of its ownlinked productsN × POST /products + /product-groupsN 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

ParameterMeaning
filter.category_idproducts of one section
filter.include_subtreewith category_id: that section and every section nested under it
filter.idsseveral products at once — repeat the parameter
filter.querysearch by name
filter.is_publishedpublished only
filter.min_price / filter.max_priceprice range
filter.company_idwhich shop (anonymous reads; with a token the key decides)
page.number / page.limitpaging, number starts at 1, limit 1–100
sort.field / sort.orderordering
attribute_filters[0].attribute_id, .values, .min, .maxfacets, 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 fullthe complete Categorybyte-for-byte what it always was, so nothing you already wrote changes
menuonly 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 URLshttps://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.

CallWhat it does
GET /gallerieslive 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 /galleriescreate 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}/usagewho 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 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 fullevery gallery with every item — the answer is byte-for-byte what it always was, so nothing you already wrote changes
coverevery gallery keeps its first item only
activethe 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 400FailedPrecondition 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=109total: 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

WhatWhat actually happens
a filter without the filter. prefixdropped; 200 with the whole catalogue
an unknown filter.* (e.g. filter.slug)dropped; 200 with the whole catalogue
price.currency on writeignored; the shop's currency is returned
company_id in a write bodyignored; the shop comes from the key
order when creating a categoryignored; the section goes last
preview instead of preview_id on edit200, the image does not change
categories: [] on edit without categories_replaceread as "do not change", not "clear"
gallery_ids: [] without gallery_ids_replacesame rule: read as "do not change", not "unlink all"
items: [] on a gallery without items_replaceread as "do not change"; clearing needs the flag
items sent to a gallery whose canvas_id ≠ 0dropped; the stored items stay as a backup until you unlink the document
reading galleries / gallery_ids from GET /products or GET /categoriesabsent 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.

Исходник: https://api.vizen.shop/docs/catalogue

Работает в демо-режиме