---
name: vizen-catalog
description: Read and fill a Vizen catalogue through a token — products, categories, filters, images and the resizer, plus what a shop's data can silently fail to tell you. Use before building any page that shows real goods.
---

# Catalogue

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

```
GET /products?filter.category_id=168&page.limit=24
```

A parameter written **without** the prefix is swallowed silently: `?category_id=168`
answers 200 and returns the **entire catalogue**. No error, just the wrong data —
and a storefront built from it looks plausible.

| Filter | Meaning |
|---|---|
| `filter.category_id` | products of a section |
| `filter.ids` | several products at once |
| `filter.query` | search by name |
| `filter.is_published` | published only |
| `filter.min_price` / `filter.max_price` | price range |
| `page.number` / `page.limit`, `sort.field` / `sort.order` | paging and order |

**The check that catches this class of bug:** compare `total` before and after
adding a filter. Unchanged means the parameter was not understood.

For ordered picks, collapse, singleton groups and chosen attributes use
`/docs/catalogue` §4.1. Before using selected attributes or group axes, configure
buyer access; defaults and the filter-panel side effect are in §8.3 there.

## 2. Shapes that surprise

- `result` for lists is an object — `{items, total, currentPage}`, not an array;
- ids come back as strings from some endpoints and numbers from others;
- `category.parent_id` is a **string** while `category.id` is a number;
- a category is also a page: `type` is `product`, `page` or `news`.


## Record sets — repeating records (`group`)

Galleries, reviews, Q&A, authors, documents inside a product or a section. Full
reference and the component recipe: `/docs/record-sets`. What goes wrong without it:

- **The site reads `GET /record-sets`, not the product.** Card responses
  (`GET /products/{id}`, lists, feeds) never carry sets. Call it on the API
  origin (`api_base` of the key passport) — the storefront has no proxy. `result`
  is **base64 → bytes → UTF-8 → JSON**; `JSON.parse(atob(…))` breaks Cyrillic.
- **Always pass `code`.** Without it every set open to the reader is taken, and
  more than 10 answers `400 INVALID_ARGUMENT` — a site without `code` breaks the
  day the 11th set is opened.
- **Ask only for what you show.** `fields=question` for the list, then one answer by
  `item_ids=<_id>&fields=answer`; reviews of many products in one call with
  `product_ids=1,2,…&code=reviews&limit=3` (over 10 resources: one `code`,
  `limit` ≤ 20).
- **Open the set to buyers first**: read `GET /attributes/{id}/access`, then
  `PUT` the whole record (`expected_version`, `visibility:"public"`,
  `show_storefront_card` and `show_api` true — omitted flags become false).
  `catalog:*` plus owner/admin; no `customers:*` needed for a public set. A new
  characteristic is closed and `/record-sets` answers without it. A set for
  signed-in buyers or a role shows only with that buyer's token — a merchant key
  reads as an anonymous buyer.
- **Edit records, not the whole set.** `GET /products/{id}/records/{code}` (or
  `/categories/{id}/records/{code}`) returns every record as stored with a
  `revision`; `PATCH` the same path with `ops` — `add`, `update` (`set` merges,
  `null` drops a field), `remove`, `move` by `id`. Other records and
  characteristics stay. Pass `expected_revision` to refuse when the set changed.
  `POST /products/import` does not write sets.
- **`PUT` replaces a whole set.** Sets left out of `PUT …/attributes` or
  `PUT /categories/{id}/values` keep their records; `[]` deletes a set; records
  missing from a sent set are deleted. Read first: `/spec` of a product lists its
  own sets even outside the template. A section row needs `"state":"value"`.
  `/record-sets?all=true` gives `url`s, not `file_id`s — never send it back.
- **Load long sets in pages.** `limit` 10–20 for the first screen, then
  «Show more» with `offset=<next_offset>` until it is `null`.
- **Keep `_id`.** Send records back with their `_id`; a record without it is new and
  gets a new id and date.

## 3. Images

Files come back as absolute storage URLs. The resizer serves
`/w/{width}[/webp]{path}` where `path` is that URL **minus the storage host**.

In your own markup you do not build these paths: write a plain `<img src>` and
the server rewrites it, adding `srcset`. Do set `sizes` yourself — see
`vizen-own-markup`.

Image URL checks with `HEAD` / `GET`: `/docs/troubleshooting` §3.

## 4. What the data may not tell you

A shop can contain a published, promising, **empty** category — for example one
named "Sale" — while `GET /promotions` returns an empty list for this shop. An empty result does
not distinguish "this shop has no sales" from an incorrectly filtered request. Read `/docs/promotions` for the supported contract.

When a task depends on a data property you cannot verify exists, ask the owner
rather than curating something by hand and calling it the thing they asked for.

## 5. Galleries are a resource of their own

A gallery is **not a field on a product**. It lives at `/galleries` with its own
CRUD (`GET`, `POST`, `PUT /galleries/{id}`, `DELETE`, `GET /galleries/{id}/usage`),
and products, categories and articles only *link* to it:

```
POST /galleries    { "item": { "name": "Blue", "items": [ { "file": { "id": "<file_id>" } } ] } }
PUT  /products/808 { "item": { "gallery_ids": ["31"], "gallery_ids_replace": true } }
```

- `gallery_ids` follows the `categories_replace` pattern: a non-empty list
  replaces, an **empty list without the flag means "do not touch"**, an empty
  list with the flag unlinks everything. Ids are **strings** (`int64` in JSON).
  A gallery's own `items` follows the same rule with its own flag: clearing
  needs `items: []` plus `items_replace: true`;
- `galleries` may be **longer** than `gallery_ids`: a gallery a variant set
  points at is appended even when the product does not link it, and stays out of
  `gallery_ids` on purpose. Look content up **by id**, not by position;
- **one gallery can sit on several objects** — that is the model, not a bug, and
  editing it changes every card that shows it;
- a variant set links to **any live gallery of the shop**
  (`items[].gallery_id` in `PUT /products/{id}/variants`) — this is how you
  reuse the gallery of another product. Its `image_id` is a separate field: chip
  and cart row, not the slider;
- **the server derives the item's role** (`image | video | audio`) from the file
  type; you set `"html"` by hand and nothing else. A `kind` that disagrees with
  the file is refused with `GALLERY_ITEM_KIND_MISMATCH`, never quietly fixed;
- **a gallery in use cannot be deleted**: `GALLERY_IN_USE`. Unlink first, check
  `GET /galleries/{id}/usage`, then delete;
- lists (`GET /products`, `GET /categories`) never carry galleries — only
  `preview`, which falls back to the first image of the first gallery
  (`preview_auto: true` marks that fallback). Read galleries from card
  endpoints.

**Ask the card for fewer frames.** A product shot in 360° for a dozen colours
answers with a dozen galleries of ~36 frames — measured 159 KB, of which 138 KB
was galleries nobody looked at. Both card endpoints take `galleries=`:

```
GET /products/by-slug/{slug}?company_id=14&galleries=cover              # every gallery = its cover only
GET /products/{id}?galleries=active&variant=rs7-blue                    # shown gallery in full, rest as covers
```

`variant` is the set's `sku` (its id as a string when `sku` is empty) — the same
key the storefront puts in `?v=`. Omit it and the server takes the first active
set, then the product's first linked gallery. Without the parameter the answer
is exactly what it always was. Read frame counts from `items_total` (items
before the projection), never from `items.length` under a projection; fill a
cover in with `GET /galleries/{id}`, which always returns everything. An unknown
value answers `GALLERIES_PROJECTION_UNKNOWN` instead of silently ignoring you.

Limits: ≤24 items per gallery, name 1..64 characters, ≤15 galleries per object,
item `html` ≤64 KB (`GALLERY_ITEM_HTML_TOO_LONG`). `GET /galleries` is paged —
`?page.number=&page.limit=` (1..100), first 50 without params; `total` and
`currentPage` sit next to `result[]`.
Full recipe: `GET /docs/catalog-import` §7; the area page: `GET /docs/catalogue` §7.

## 6. Writing

Prices are whole units of the shop's currency; the currency cannot be changed
with a token, and a mismatch is rejected. Import guide: `GET /docs/catalog-import`.

Read every write back and compare the fields you sent: a 200 proves only that
the request parsed.

Changing existing goods — one field on many products, taking off sale,
copy, delete, sets, moving to another shop: `GET /docs/catalog-import` §12.
`PUT /products/{id}` is partial, except `price` (send `old_price` too), `seo`
(send `og_image_id`, `noindex`, `nofollow` too) and `categories` (replaces).

## Product comparison settings

`GET|PATCH /compare/settings` — one record per shop, taken from the key.
Partial: absent fields keep their value; exclusion lists go wrapped,
`{"excluded_group_ids": {"ids": [27]}}` (`{"ids": []}` clears).
`expected_version` from your last read is required (`0` before the first save).
Only what the buyer sees on the card is ever compared — see `card_visibility` in
the answer. Details: `GET /docs/catalogue` §8.2. Who sees a characteristic is
set on the characteristic itself: `GET|PUT /attributes/{id}/access` — the `PUT`
replaces the whole record, a flag left out becomes `false` (§8.3).

## Characteristics and filters

Before creating a characteristic, an option, a filter or a spec template, read `GET /docs/characteristics`.
The three things agents miss: a filter panel needs the characteristic bound to **that** section
(`PUT /categories/{id}/attributes`, not inherited) with `is_filterable`, or a template row with
`filterable_here` — plus list access in both cases; a filter matches the product's own values only, not
inherited ones; a dev key's rename reaches the live cards only after publication.

## Prices, discounts and sets

**The price a shopper sees is computed, not stored.** There is no discounted
price column: the server runs every card through the same engine that prices the
cart. That single fact decides everything else here.

Consequences you cannot design around:

- **never recompute a discount client-side.** Your number will disagree with the
  cart on the first product two rules touch, and the shopper trusts the cart;
- a product's price already includes per-item rules — `price`, `old_price` and
  `promotion_name` come back priced;
- rules behind a promo code **never reach a product card**. A storefront must not
  advertise a price that does not exist without the code, and a personal price in
  a shared page would leak someone else's coupon;
- `[]` from `GET /promotions` means "this shop has no rules", not "you lack
  access" — a missing scope answers `403 PAT_SCOPE_MISSING`. Do not treat them
  alike.

Where to look:

| You need | Ask |
|---|---|
| what is on sale right now, ranked | `GET /promotions/landscape` — the server ranks it |
| the rules themselves | `GET /promotions` |
| the machine list: kinds, classes, refusal codes, rounding | `GET /docs/promotions.json` |
| how any of it actually works | `GET /docs/promotions` — the area description |

Do not rebuild the ladder (`item → order → key`) yourself to find discounted
products. `landscape` exists precisely so you do not have to, and it reports
`scanned_products` and `truncated` so a ceiling never reads as "nothing else is
on sale".

For printing "cheaper in this set" on a card, see `vizen-own-markup` — the
numbers arrive as fields with ready strings beside them, because substitutions
have no expressions.
