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

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

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

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

## 4. What the data may not tell you

A shop can contain a published, promising, **empty** category — for example one
named "Sale" — while the platform has no promotions entity at all
(`GET /promotions` → `[]`, no promo field on any product). An empty result does
not distinguish "this shop has no sales" from "there is no such concept".

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, ≤10 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.

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