# Vizen catalogue import via the API

A guide for an external developer or AI agent filling a shop with products
using a personal access token (PAT).

The sibling document `/docs/webcoding` covers **page layout**. This one covers
**data**: categories, products, images, attributes, variants and linked
products. Full schema: `/openapi.json`.

> This document is based on a review of a real import: every "Pitfall"
> section is a mistake that has already cost someone a redo.

---

## 0. Thirty seconds: what to know before the first request

| Fact | Consequence |
|---|---|
| The shop is determined by the **key**, not a parameter | `company_id` in the request is ignored; other tenants are invisible |
| **Price is an integer in the shop currency** | `price.currency` rules: [catalogue §8](/docs/catalogue#8-writing); a mismatch is rejected |
| A key writes either to the **live site** or to the **draft** | A property of the key; no header switches it |
| `category_id` is the **primary** category, `categories[]` are **additional** ones | Do not repeat the primary one in `categories[]` |
| **A gallery is a separate shop resource**, not a product field | Created in `/galleries`; products, categories and articles reference it via `gallery_ids[]` (§7) |
| Lists require **both** pagination parts | `page.limit` without `page.number` → `400` |
| The catalogue lives **without** the `/v1` prefix | `/products`, `/categories`; files and organisation are under `/v1` |

---

## 1. Step 0 — the key passport (mandatory)

```http
GET /v1/account/token
Authorization: Bearer vz_pat_…
```

```json
{
  "company_id": 7,
  "contour": "prod",
  "writes_to_live": true,
  "dev_header_allowed": false,
  "publish_allowed": false,
  "expires_at": null,
  "scopes": ["catalog:read", "catalog:write", "storage:read", "storage:write"],
  "store": {
    "name": "promo",
    "slug": "promo",
    "storefront_url": "https://promo.vizen.shop",
    "currency": "USD",
    "language": "ru",
    "custom_domain": ""
  },
  "capabilities": {
    "catalog_read": true, "catalog_write": true,
    "storage_read": true, "storage_write": true,
    "publish_dev": false, "store_settings_write": false
  },
  "warnings": [
    "Ключ пишет в ЖИВОЙ магазин: изменения видны посетителям сразу…",
    "Цена товара — целое число в валюте магазина (USD)…",
    "Сменить валюту, язык или название магазина этим ключом НЕЛЬЗЯ…"
  ]
}
```

**What to do with it before a bulk write:**

1. `writes_to_live: true` → tell the owner plainly: *"visitors will see the
   writes immediately"* — and wait for confirmation.
2. `store.currency` → compare with the price list currency. **If they differ,
   stop.** The owner changes the currency in the admin panel; a key cannot
   (`capabilities.store_settings_write: false`).
3. `capabilities` → check that the required rights are present. Having a
   topical scope does not mean the method is open to the key: the
   `capabilities` list is computed from the actual access map, so trust it.
4. `warnings` → pass them to the owner in full, without paraphrasing.

**Pitfall.** The contour used to be discovered by trial: write a product and
go look at the storefront. That is no longer necessary — and should not be done.

---

## 2. Five rules that break imports most often

### 2.1. The shop currency overrides the one you send

Price is an integer in the shop currency: `6400` means 6400 units, not cents.
The `price.currency` contract is described in `/docs/catalogue` §8: an empty
value is allowed; a non-empty mismatch returns `400 CURRENCY_IS_STORE_LEVEL`.
Check the price list currency against the key passport before importing; there
is no conversion.

### 2.2. Pagination — both parts or neither

```http
GET /products?page.limit=100              → 400 (Number must be >= 1)
GET /products?page.limit=100&page.number=1 → 200
GET /products                              → 200 (default page)
```

If you pass `page.limit`, you must also pass `page.number` (numbering starts
at **1**).

### 2.3. Primary and additional categories are different fields

| Field | Meaning |
|---|---|
| `category_id` | **Primary** category. Defines the product's main URL and its listing |
| `categories[]` | **Additional** categories (ids). The product also appears in them |
| `categories_replace: true` | Needed only to clear: `[]` + flag removes every additional category. A non-empty list replaces the set with or without it |

You **do not need** to repeat the primary category in `categories[]` — it
creates a redundant link rather than "strengthening" the assignment.

```jsonc
// create — primary only
POST /products   { "item": { "category_id": 85 } }
// create — primary + two additional
POST /products   { "item": { "category_id": 85, "categories": [86, 87] } }
// update — full replacement of additional ones
PUT /products/808 { "item": { "categories": [86, 87], "categories_replace": true } }
// update — clear additional ones, keep the primary
PUT /products/808 { "item": { "categories": [], "categories_replace": true } }
```

On update a non-empty `categories` **replaces** the additional set — it does
not add to it. To add one category, read the product and send the whole list
(measured 2026-10-28: `[86, 87]` then `{"categories":[88]}` leaves `[88]`). An
empty array without `categories_replace` means "do not change"; with the flag it
clears the set. On create the flag is not needed.

### 2.4. Tri-state flags are strings, not booleans

Some switches inherit from the shop, so they have three states and arrive in
JSON as a **string**:

| Field | Values | Meaning |
|---|---|---|
| `catalog_hidden` | `""` / `"true"` / `"false"` | `""` and `"false"` — shown; `"true"` — hidden from storefront grids, search and section counts, the card still opens by its address. Written by `PUT /products/{id}` only: `POST /products` ignores it |
| `comments_enabled`, `questions_enabled` | `""` / `"true"` / `"false"` | same |
| `reviews_scope` | `""` / `own` / `category` / `shop` / `off` | |

`is_published` is a regular **boolean**. Do not mix them up: `catalog_hidden: false`
(boolean) is a type error; use the string `"false"`, or `""` to reset.

**Safe sequence:** create the product with `is_published: false` → verify by
reading it → publish.

### 2.5. Category order is set by moving, not by a field

`order` in the create body is **ignored**: a new category always goes LAST
among its siblings. The value you read back therefore legitimately differs from
the one you sent — do not treat this as an error and do not try to "force" the
number with another write.

This keeps sibling positions a dense `0..n-1` set without duplicates, which the
`before`/`after` move arithmetic relies on. The field used to be stored as
sent, and the catalogue quickly accumulated groups where every sibling had
`order` = 0 — order was then effectively decided by internal id, and
drag-and-drop of sections in the admin panel stopped working.

If you need a specific order, set it explicitly after creating all sections:

```http
POST /categories/move
{ "from_id": "85", "to_id": "86", "type": "before" }   // before | after | inside
```

---

## 3. Catalogue model

```
Shop (key)
├── Gallery (named media set; its own endpoint /galleries)
│      ↑ referenced by product, category, article and variant set
└── Category (storefront section; tree via parent_id)
    └── Product  ── primary category + additional ones
        ├── Attributes  (attribute values: material, country, …)
        ├── Variants within the product  (one card, switchers)
        └── Grouping membership          (several cards linked by axes)
```

A gallery **does not belong** to a product: it exists on its own, and the same
gallery can be placed on several products, a category and an article at once.
This is the standard way to "take the gallery from another product" (§7).

An **attribute** (`/attributes`) is a shop-wide dictionary entry: code, type,
widget, list of options. It is used both as a characteristic and as a switcher
axis.

---

## 4. Variants or linked products: how to choose

The platform supports **both** models. The choice is made once and is costly to
change — compare using the table.

| Need | Variants within a product | Grouping of separate products |
|---|---|---|
| Separate URL per combination | ✗ one URL + parameter | ✓ each has its own |
| Separate SEO titles and description | ✗ | ✓ |
| Separate gallery per combination | ✓ set reference to a gallery (`gallery_id`, §5 step 4b) | ✓ each has its own set of galleries |
| Own SKU and price | ✓ | ✓ |
| Separate stock level | ✓ | ✓ |
| Separate card in the catalogue grid | ✗ one | ✓ each (extra ones are hidden with `catalog_hidden`) |
| Setup cost | lower: 1 product + sets | higher: N products + a group |

**The choice in one line:** only price, SKU and stock differ → **variants**;
photos differ too → **variants + a gallery per set**; text, URL or position in
the catalogue grid differ → **grouping**.

"Another colour" can be shown in three ways, and they do not replace each other:

| If you need… | Mechanism | Where it is written | Cost |
|---|---|---|---|
| a different **chip** and cart-line photo for the variant, shared slider | set image `image_id` | `PUT /products/{id}/variants` | one image per set |
| **different photos in the slider** when a variant is selected, one product, one URL | gallery + set `gallery_id` | `POST /galleries` → `PUT /products/{id}` → `PUT /products/{id}/variants` | ≤15 galleries per object × ≤24 items; shared SEO |
| each variant has **its own URL, SEO, description and grid card** | product grouping | N × `POST /products` + `/product-groups` | N products to maintain; extra cards hidden with `catalog_hidden` |

`image_id` and `gallery_id` are **independent** set fields: the first controls
the chip and the cart line, the second only the slider.

For the in-between case (separate URLs needed but identical content), use a
grouping: adding content later is cheap; splitting one product into nine is not.

---

## 5. Scenario A — one product with variants

**When:** a T-shirt in three sizes, differing only in price and stock.

```bash
# 1) Dictionary: axis attribute with options
POST /attributes
{ "item": {
    "code": "size", "name": "Размер",
    "data_type": "select",        # a switcher axis is always select
    "widget": "select",           # text | select | icon | swatch | photo
    "options": [ { "value": "s", "label": "S" },
                 { "value": "m", "label": "M" },
                 { "value": "l", "label": "L" } ] } }
# → result.id = 12, options have their own ids

# 2) Product
POST /products
{ "item": { "name": "Футболка TERRA", "category_id": 85,
            "price": { "price": 2400 }, "sku": "TERRA-TSHIRT",
            "is_published": false } }
# → result.id = 900

# 3) Product axes (replace-set)
PUT /products/900/variant-axes
{ "items": [ { "attribute_id": 12, "display": "text", "sort_order": 0 } ] }

# 4) Sets (replace-set): values = attribute_id → option value
#    is_active may be left out: a set without it is on (since 2026-10-28, as in the import);
#    "is_active": false switches one off
PUT /products/900/variants
{ "items": [
    { "values": { "12": "s" }, "price": 2400, "sku": "TERRA-TSHIRT-S", "is_active": true },
    { "values": { "12": "m" }, "price": 2400, "sku": "TERRA-TSHIRT-M", "is_active": true },
    { "values": { "12": "l" }, "price": 2600, "sku": "TERRA-TSHIRT-L", "is_active": true } ] }

# 4b) galleries and set binding (optional)
#     A gallery is a standalone resource: create it first, then reference it.
POST /galleries
{ "item": { "name": "Синие",
            "items": [ { "file": { "id": "<file_id_blue_1>" } },
                       { "file": { "id": "<file_id_blue_2>" } } ] } }
# → result.id = "31"   (gallery id is a STRING: int64 arrives as a string in JSON)

POST /galleries
{ "item": { "name": "Чёрные", "items": [ { "file": { "id": "<file_id_black_1>" } } ] } }
# → result.id = "32"

# product galleries are REFERENCES — the full set, in the desired order
PUT /products/900
{ "item": { "gallery_ids": ["31", "32"], "gallery_ids_replace": true } }

PUT /products/900/variants
{ "items": [
    { "values": { "12": "s" }, "price": 2400, "sku": "TERRA-TSHIRT-S",
      "gallery_id": "31", "is_active": true },
    { "values": { "12": "m" }, "price": 2400, "sku": "TERRA-TSHIRT-M", "is_active": true },
    { "values": { "12": "l" }, "price": 2600, "sku": "TERRA-TSHIRT-L", "is_active": true } ] }
# → variants_info.variants[].gallery_id in the GET /products/900 response confirms the binding

# 5) Verify and publish
GET  /products/900/variants
PUT  /products/900   { "item": { "is_published": true } }
```

**Pitfalls:**

- `variant-axes` and `variants` are **replace-set**: send the full set; anything
  missing is deleted.
- An axis `display` is narrower: `""` / `text` / `icon` / `swatch`. The value
  `select` is valid for an attribute but **not** for a product axis.
- A set's `price` is optional: empty → the product's base price is used.
- A set's `is_active`: left out means on, here and in `POST /products/import` alike (since 2026-10-28;
  before, a set written here without it was saved switched off). `false` switches it off.
- An option without `label` gives an empty switcher button; the characteristic's `unit` is not added to it.
  Types, labels, filters and templates of characteristics — `GET /docs/characteristics`.
- A duplicate value combination is rejected — the server computes a set signature.
- Maximum: 20 axes, 500 sets per product; up to 10000 values (`options`) per characteristic — the 10001st is
  refused with `400` (since 2026-10-29; before, 200). Above 200 options, the storefront axis lists only the values
  the product's sets use.
- `gallery_ids` in `PUT /products/{id}` is a **set of references** following the
  `categories_replace` pattern: non-empty array → full replacement in the order
  sent; empty array or field omitted → bindings **are left untouched** (safe to
  copy a `GET` body into a `PUT`); empty array + `"gallery_ids_replace": true`
  → unbind all. Gallery contents are edited via their own endpoint
  `PUT /galleries/{id}`, not through the product.
- A set's `gallery_id` references **any live gallery of the shop**, not
  necessarily one bound to this product. This is how you "take the gallery from
  another product". Another tenant's or a non-existent id → `404 GALLERY_NOT_FOUND`.
  If the gallery is deleted later, the set does not break: on read `gallery_id`
  returns `"0"` and the storefront shows the product's first gallery.
- One gallery can sit on several products, a category and an article at the
  same time — `GET /galleries/{id}/usage` shows the full list.
- The set image (`image_id`, step 4) and the set gallery (`gallery_id`) are
  **independent** fields: `image_id` remains the chip and the cart-line preview,
  `gallery_id` controls only the storefront slider.

---

## 6. Scenario B — separate products linked by a grouping

**When:** a plate in 3 colours × 3 sizes, each with its own photos and URL.

```bash
# 1) Two axis attributes
POST /attributes
{ "item": { "code": "color", "name": "Цвет", "data_type": "select",
            "widget": "swatch",
            "options": [ { "value": "light",  "label": "Светлый",   "color_hex": "#E8DCC8" },
                         { "value": "brown",  "label": "Коричневый","color_hex": "#8B5A3C" },
                         { "value": "black",  "label": "Чёрный",    "color_hex": "#2B2B2B" } ] } }
POST /attributes
{ "item": { "code": "diameter", "name": "Диаметр", "data_type": "select",
            "widget": "select", "unit": "см",
            "options": [ { "value": "20", "label": "20 см" }, { "value": "26", "label": "26 см" },
                         { "value": "32", "label": "32 см" } ] } }
# label is what the switcher button shows: without it the button is EMPTY, and unit is not added to buttons

# 2) Nine products — regular POST /products, each with its own slug, photos, price
#    (or one POST /products/batch, see §8)

# 3) Each product gets values on both axes (replace-set)
PUT /products/808/attributes
{ "items": [ { "attribute_id": 13, "value": "light" },
             { "attribute_id": 14, "value": "20"    } ] }

# 4) Group
POST /product-groups
{ "item": { "name": "Тарелка TERRA", "group_type": "variant_group" } }
# → result.id = 5

# 5) Group axes (replace-set) — order of switchers on the storefront
PUT /product-groups/5/axes
{ "items": [ { "attribute_id": 13, "display": "swatch", "sort_order": 0 },
             { "attribute_id": 14, "display": "text",   "sort_order": 1 } ] }

# 6) Members (replace-set)
PUT /product-groups/5/members
{ "items": [ { "product_id": 808, "sort_order": 0 },
             { "product_id": 809, "sort_order": 1 } ] }

# 7) Verify: the card returns the ready link
GET /products/808
# → group_info: axes, values, neighbour slugs, current member
```

**Pitfalls:**

- Axis values are set on the **product** (`PUT /products/{id}/attributes`), not
  on the group member: in `members` the `values` field is read-only.
- All three `PUT`s are replace-set. Adding a tenth product to the group — send
  all ten.
- To have **one** card in the catalogue grid instead of nine, set
  `catalog_hidden: "true"` on the others. They do not drop out of the grouping —
  the switcher on the card still leads to them.
- `group_info` in the product response is a **sibling** of the `result` field,
  not nested in it. `variants_info`, `attribute_sections` and `categories` sit
  there too.

---

## 7. Product images

Upload takes three steps (or one if the file is already online); details and
cropping rules are in `/docs/webcoding`, §3. An end-to-end "product + photo"
example is in §7.3.

```bash
POST /v1/storages/files
{ "section": "content", "file_type": "image", "original_name": "plate.jpg" }
# → result.id = <file_id>, upload.id, upload.url
PUT  <upload.url>                       # bytes, no Authorization
POST /v1/storages/files/<upload.id>/done  { }

# file already online — a single request
POST /v1/storages/files/from-url
{ "source_url": "https://…/plate.jpg", "section": "content", "file_type": "image" }

# the gallery is created SEPARATELY and once
POST /galleries
{ "item": { "name": "Основная",
            "items": [ { "file": { "id": "<file_id_2>" } },
                       { "file": { "id": "<file_id_3>" } } ] } }
# → result.id = "31"

# binding on CREATE — preview as an object, galleries as references
POST /products
{ "item": { "name": "Тарелка", "preview": { "id": "<file_id>" },
            "gallery_ids": ["31"] } }

# binding on UPDATE — main image as an id string
PUT /products/808
{ "item": { "preview_id": "<file_id>",
            "gallery_ids": ["31"], "gallery_ids_replace": true } }
```

**Pitfalls:**

- **Create and update accept the main image differently:** on create —
  `preview: { "id": … }`, on update — `preview_id: "…"` as a string. Galleries
  in both cases use `gallery_ids`, an array of id strings. On update
  `preview: {id}` is accepted too, as an alias; an explicit `preview_id` wins
  over it. Always re-read the product after a write.
- To remove the main image on update — `preview_id: ""` (empty string).
- **`upload.id`** goes to `/done`, while **`result.id`** is attached to the
  product. These are different values.
- `file_type` is a category (`image | video | audio | model | file`), not a MIME type.
- One `file_id` can be used in several galleries and products —
  **do not upload the same file again**.
- `public_path` in the storage response is the **full object-storage URL**.
  On the storefront use the storefront's relative path with cropping
  (`/w/{width}/webp{path}`), not this URL and not the original.
- `gallery_ids` is a replace-set of references: send the full list in the
  desired order.

### 7.1. A gallery is a standalone resource

A gallery does not live inside a product. It is a separate shop entity with its
own CRUD; products, categories (of any type) and articles only **reference** it.

| Endpoint | What it does |
|---|---|
| `GET /galleries` | the shop's live galleries with their items, newest first; **paginated** — `?page.number=&page.limit=` (1..100), without parameters the first 50; the response has `total` and `currentPage` next to `result[]` |
| `GET /galleries/{id}` | one gallery with its items |
| `POST /galleries` | create a gallery together with its items |
| `PUT /galleries/{id}` | update: name and/or items |
| `DELETE /galleries/{id}` | soft delete; a gallery in use cannot be deleted |
| `GET /galleries/{id}/usage` | where it is used: products, categories, articles and variant sets |

Gallery shape:

```json
{ "id": "31", "name": "Синие", "source": "images", "canvas_id": "0",
  "items": [
    { "item_key": "8231acd9-…", "kind": "image",
      "file": { "id": "<file_id>", "url": "…" }, "poster": null,
      "html": "", "caption": "вид спереди" },
    { "item_key": "4fba917d-…", "kind": "html", "file": null,
      "html": "<b>Схема сборки</b>", "caption": "" } ] }
```

What matters about items:

- **the role (`kind`) is set by the server**, not by you. It is derived from the
  file type: `image | video | audio`. Only `"html"` is set manually — it has no
  file, only markup. A `kind` that does not match the file type (e.g. `"video"`
  on an image) → `400 GALLERY_ITEM_KIND_MISMATCH`. There is no silent fix:
  silently substituting the role produces a storefront that does not look like
  what you sent;
- a non-html item **requires** a file (`400 GALLERY_ITEM_FILE_REQUIRED`); an
  html item requires a non-empty `html` (`400 GALLERY_ITEM_HTML_REQUIRED`) of at
  most **64 KB** (`400 GALLERY_ITEM_HTML_TOO_LONG`);
- `poster` is a video poster or audio cover, the same `{ "id": … }`;
- `item_key` is a stable item key. Empty on input → the server assigns a uuid;
  your own is kept. It keeps an item "the same" across reordering, so when
  retrying a request after a timeout, send your own keys;
- `items` in `PUT /galleries/{id}` is **replace-set**: a non-empty list replaces
  all items entirely; an empty list (or a missing field) means "do not touch".
  To **clear** a gallery — an empty list plus `"items_replace": true`: without
  the flag the last item cannot be removed at all. The name is edited
  separately and independently: `{ "item": { "name": "…" } }` does not touch
  the items.

Binding to objects is the same for products, categories and articles:

```bash
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 } }
```

On a card read both fields arrive: `gallery_ids` — the ordered set of
references, `galleries` — their contents. **One gallery legitimately sits on
several objects** — editing its contents changes how all of them look, and this
is expected.

⚠️ `galleries` can be **longer** than `gallery_ids`. If a variant set references
a gallery that is not bound to the product itself, its contents still arrive in
the card — at the end of `galleries`, but **not** in `gallery_ids`. This is
deliberate: by sending a `GET` body back in a `PUT` you will not bind someone
else's gallery to the product. Hence the rule: read the set of bindings from
`gallery_ids`, and look up display contents in `galleries` **by id**, not by
position. The preview fallback is computed only from BOUND galleries — a
product whose only gallery comes via a set reference returns `preview: null`
and `preview_auto: false`. Verified live.

A gallery in use cannot be deleted: `400 GALLERY_IN_USE: used in N places`.
First remove the references (on objects — `gallery_ids: []` + `gallery_ids_replace`,
on sets — `gallery_id: "0"`), check with `GET /galleries/{id}/usage`, and only
then delete.

Limits: **≤24 items** per gallery, name **1..64 characters**, **≤15 galleries**
per object.

### 7.2. What is silently ignored — and how to verify a write took effect

The server returns `200` in these cases too, but does something other than you
expected:

| What you sent | What actually happened |
|---|---|
| `gallery_ids: []` without `gallery_ids_replace` | read as "do not touch", not "unbind all" |
| `categories: []` without `categories_replace` | same: "do not touch". A non-empty list replaces the set (§2.3) |
| `price.currency` | empty — the shop's currency is used; any other value than the shop's is `400 CURRENCY_IS_STORE_LEVEL` |
| `company_id` in the request body | dropped; the key determines the shop |
| `order` when creating a category | dropped; the section goes last |
| `items` on a gallery managed by a canvas document (`canvas_id ≠ 0`) | dropped; the stored items remain a backup until unbinding — including a request with `canvas_id: 0` and items together: the unbinding works, the items do not |
| `items: []` on a gallery without `items_replace` | read as "do not touch"; clearing requires the flag |
| reading `gallery_ids` / `galleries` from **lists** | `GET /products` has no such fields at all, `GET /categories` returns them empty — lists do not return galleries, only `preview` |

**The check after every write is the same: re-read the object and compare the
fields you sent.** A `200` response proves only that the request was parsed.

```bash
# wrote
PUT /products/808 { "item": { "gallery_ids": ["31"], "gallery_ids_replace": true } }
# re-read and compared
GET /products/808        # result.gallery_ids == ["31"], result.galleries[0].id == "31"
GET /galleries/31        # items — the ones sent, in the same order
GET /galleries/31/usage  # the list contains product 808
```

Three fields that are especially easy to misread:

- `preview_auto: true` — the preview is shown as a **fallback**: the product has
  none of its own, and the server took the first image item of the first
  gallery. Do not write this preview back into `preview_id` — you will get a
  duplicate;
- a set's `gallery_id` equals `"0"` — there is no reference or the gallery was
  deleted; the storefront shows the product's first gallery;
- `source: "canvas"` on a gallery — its items are managed by a canvas document
  (`canvas_id`), and items you send will not get there;
- `galleries` longer than `gallery_ids` — at the end are galleries referenced by
  variant sets but not bound to the product.

---

### 7.3. First product with a photo — end-to-end example

This example was moved from the quickstart, which keeps only safe reads. Before
running it, complete §1 and agree the write with the owner. You need Bash, curl,
jq, rights to create products/galleries and upload files, a JPEG `product.jpg`,
and an existing shop category in the `CATEGORY_ID` variable. `VIZEN_TOKEN` has
already been passed to the process securely per the
[authentication guide](/docs/authentication); shell tracing and header logging
are off. Running it again creates new objects — this is an example for one
agreed trial, not an idempotent import.

```bash
set -euo pipefail
: "${VIZEN_TOKEN:?Supply the key securely first}"
: "${CATEGORY_ID:?Choose an existing shop category}"
API=https://api.vizen.shop

# 1. Create an unpublished product. The currency comes from the shop settings (§2.1).
PID=$(curl --fail-with-body -sS -X POST "$API/products" \
  -H "Authorization: Bearer $VIZEN_TOKEN" -H 'Content-Type: application/json' \
  -d "$(jq -n --argjson category "$CATEGORY_ID" \
    '{item:{name:"Sample product",price:{price:100},category_id:$category,is_published:false}}')" \
  | jq -er '.result.id | select(. != null)')

# 2. Get an upload link: result.id and upload.id are different identifiers.
UPLOAD=$(curl --fail-with-body -sS -X POST "$API/v1/storages/files" \
  -H "Authorization: Bearer $VIZEN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"section":"content","file_type":"image","original_name":"product.jpg"}')
FILE_ID=$(jq -er '.result.id' <<< "$UPLOAD")
UPLOAD_ID=$(jq -er '.upload.id' <<< "$UPLOAD")
UPLOAD_URL=$(jq -er '.upload.url' <<< "$UPLOAD")

# 3. Upload the raw bytes without the authorization header, then confirm upload.id.
curl --fail-with-body -sS -X PUT "$UPLOAD_URL" \
  -H 'Content-Type: image/jpeg' --data-binary @product.jpg
curl --fail-with-body -sS -X POST "$API/v1/storages/files/$UPLOAD_ID/done" \
  -H "Authorization: Bearer $VIZEN_TOKEN"

# 4. Read the final metadata: conversion may change public_path.
curl --fail-with-body -sS "$API/v1/storages/files/$FILE_ID" \
  -H "Authorization: Bearer $VIZEN_TOKEN"

# 5. Create a gallery and bind it to the product; a separate preview is not needed.
GALLERY_ID=$(curl --fail-with-body -sS -X POST "$API/galleries" \
  -H "Authorization: Bearer $VIZEN_TOKEN" -H 'Content-Type: application/json' \
  -d "$(jq -n --arg file "$FILE_ID" \
    '{item:{name:"Sample gallery",items:[{file:{id:$file}}]}}')" \
  | jq -er '.result.id')
curl --fail-with-body -sS -X PUT "$API/products/$PID" \
  -H "Authorization: Bearer $VIZEN_TOKEN" -H 'Content-Type: application/json' \
  -d "$(jq -n --arg gallery "$GALLERY_ID" \
    '{item:{gallery_ids:[$gallery],gallery_ids_replace:true}}')"

# 6. Verify the result by reading; the product is still unpublished.
curl --fail-with-body -sS "$API/products/$PID" \
  -H "Authorization: Bearer $VIZEN_TOKEN"
```

Compare the name, price, category, `is_published:false` and the bound gallery
with the request you sent; `GET /galleries/{id}` must contain the uploaded file.
The gallery's first photo serves as the product preview. In content sections the
original may be converted to WebP; use the final `public_path` after `/done`,
and store links by the stable `file_id`. For a file at a public URL, replace
steps 2–4 with a single `from-url` call from §7; it does not need confirming
via `/done`. For all other gallery operations use §7.1–7.2, with no second set
of binding rules. After verifying, hand the result to the owner; this example
publishes nothing.

### 7.4. New pictures for existing products: replace the bytes, keep every link

To put a new picture where an old one is, replace the file's **bytes**. The
`file_id` stays the same, so the gallery (`gallery_id`, each `item_key`, order,
caption), `preview_id`, variant sets, cards and scenes keep pointing at the same
file and show the new picture. Nothing on the product is written: no
`PUT /products`, no `PUT /galleries`. Name, price, categories, SKU and
publication stay as they are. Needs `storage:write`.

```bash
# 1. Read the file and keep what you will compare: public_path (and the old bytes, for a rollback)
GET  /v1/storages/files/<file_id>
# 2. Ask for an upload link for the SAME file
POST /v1/storages/files/<file_id>  { "original_name": "front.png" }
# → result.id = <file_id> (unchanged), upload.id = <file_id>, upload.url
# 3. Bytes without Authorization, then confirm with upload.id
PUT  <upload.url>                          # Content-Type: image/png
POST /v1/storages/files/<file_id>/done  { }
# 4. Verify: same id, new name, new public_path
GET  /v1/storages/files/<file_id>
```

Rules:

- **The new bytes show everywhere the file is used.** One file can sit in
  several galleries and products (§7). Before a bulk run, build the map
  `file_id → uses` from `GET /galleries` (all pages) and the products'
  `preview_id`. If a shared file must become different pictures for different
  products, upload a new file for those (§7) and put it into the gallery with
  `PUT /galleries/{id}`: the full `items` list, every `item_key` sent back
  unchanged (§7.1).
- **Finish each file at once.** Right after the replace the file already points
  at the new, still empty object, so until the `PUT` and `done` succeed the
  picture may be missing on the site. A failed `PUT` → repeat steps 2–3 for
  that file.
- **Check the bytes before the replace.** `done` refuses an empty upload
  (`400 EMPTY_FILE`) or a picture above the image limit (`400 FILE_TOO_LARGE`,
  10 MiB by default), and the refusal **removes the file**: galleries and the
  preview lose the picture. Send only non-empty files within the limit.
- **PNG and JPEG become WebP** at `done` (quality 90, transparency kept) when
  that is smaller: `public_path` and the extension change, the `file_id` does
  not. Store links by `file_id`, never by URL.
- **There is no undo on the server.** The old bytes are not kept as a version
  you can restore. For a rollback, download the file from `public_path` before
  the replace and keep it with its `file_id`. A rollback is the same steps with
  the old bytes.
- **Conflicts and resume.** The replace has no "expected old version" field.
  Read the file just before step 2 and compare `public_path` with the value in
  your list. Different means someone changed the file, so skip that row. Keep a
  journal `file_id → done`. On a rerun, skip finished rows: a repeated replace
  is harmless but costs two more calls.
- **Refusals:** a draft key on a live file → `400 FILE_CONTOUR_MISMATCH` (use a
  live key); another shop's file or an unknown id → `404 NOT_FOUND`; a file of a
  site-folder release → `FILE_IN_RELEASE` (publish a new release instead);
  `409 FILE_CHANGED_RETRY` → the file changed at the same moment, read it again.
- **Speed.** The replace and `done` share the shop's file budget of **180
  calls a minute** (all keys and people of the shop together), so ~4 000
  files take about 45–60 minutes. Reads (`GET`) use the separate read budget.
  On `429`, wait for `Retry-After`.

## 8. Bulk import

```bash
POST /products/batch
{ "items": [ { … }, { … } ] }
```

The response is a **partial success**: each item gets its own result or error.
Parse the response item by item; an overall `200` does not mean everything
succeeded. In `results[]` match `index` to the original position: a successful
item contains `id`, a failed one contains `error`. Retry only the failed items
so you do not duplicate products that were already accepted.

**Safe import order:**

1. Key passport (§1) → check currency and contour, owner confirmation.
2. Categories → fix the order via `/categories/move`.
3. Attributes and options → record their `id`s; they are needed everywhere after.
4. Files → build a `local name → file_id` map.
5. Galleries (`POST /galleries`) → build a `photo set → gallery_id` map.
   The gallery is created **before** the product: the product references it,
   not the other way round.
6. **One canary product**, `is_published: false` → read it back → compare every
   field you sent → show the owner.
7. The rest of the catalogue in batches.
8. Links (variants or groupings).
9. Publication: `is_published: true`.
10. Storefront check: the category page and the product card return `200` and
    contain what is expected.

**Idempotency.** The server does not store your external product key. Keep a
local manifest `external SKU → product id → gallery_id → file_id → slug` and
check for existence before creating: `GET /products/by-slug/{slug}`. Otherwise a
rerun will create duplicates.

**Rollback.** Do not delete — unpublish: `is_published: false`. It is
reversible, but the product's address stops answering (404, or a permanent 308
to its section — §12.3); `catalog_hidden: "true"` alone keeps the address and
only takes the card out of grids.

### 8.1. The whole catalogue — `POST /products/import`

`POST /products/batch` stores only cards, 100 at a time, and every rerun creates
new products. For a catalogue that is re-uploaded regularly there is a second
endpoint:

```bash
POST /products/import
{ "items": [ {
  "sku": "ART-001",                      # KEY: the row finds its product by it
  "name": "Лампа настольная",
  "slug": "lampa-nastolnaya",            # empty on a new product → generated from the name
  "description": "…",
  "base_price": 4900, "old_price": 6900, # integers, in the SHOP currency
  "category_id": 12,                     # or "category_slug": "svet"
  "status": "new", "is_published": true,
  "tags": ["распродажа"],
  "attributes": [                        # attribute CODES, not ids
    { "code": "height", "value": 42 },
    { "code": "color",  "value": "red" },
    { "code": "tags_multi", "value": ["a", "b"] }
  ],
  "variant_axes": [ { "code": "color", "display": "swatch" } ],
  "variants": [
    { "values": { "color": "red" },  "sku": "ART-001-R", "price": 4900 },
    { "values": { "color": "blue" }, "sku": "ART-001-B" }
  ],
  "images": ["<file_id>", "<file_id>"]   # array order = gallery order
} ] }
```

**How it differs from `/products/batch`** (that endpoint stays; it is not replaced):

| | `/products/batch` | `/products/import` |
|---|---|---|
| Rows per request | 100 | **5,000** |
| What a row holds | card | card + attributes + axes and sets + image order |
| Rerun | creates duplicates | **updates by `sku`** |
| Product addressing | your local manifest `SKU → id` | the `sku` itself |

**Idempotency.** `sku` is required and unique within the shop. A row finds its
product by it: none — one is created (`created`); exists — it is updated
(`updated`). A local manifest for re-uploads is no longer needed.

**Empty = untouched.** A scalar field absent from the row stays unchanged on an
existing product. Lists (`attributes`, `variants`, `images`) are replace-set: a
non-empty list fully replaces the set, an empty one leaves it untouched, an
empty one together with `attributes_replace` / `variants_replace` /
`images_replace` clears it. Fields not in the contract (SEO, `preview`, scene,
groupings, stock) are NEVER touched by the import — edit them via their own
endpoints.

**The response is per row.** HTTP `200`; each row's outcome is in `results[i]`:

```json
{ "rows": 3, "created": 1, "updated": 1, "skipped": 1,
  "results": [
    { "row": 1, "product_id": "5001", "action": "created", "errors": [] },
    { "row": 2, "product_id": "4812", "action": "updated", "errors": [] },
    { "row": 3, "product_id": "0",    "action": "skipped",
      "errors": [ { "code": "CATEGORY_NOT_FOUND", "field": "category_id",
                    "message": "категории 99 нет в этом магазине" } ] }
  ] }
```

`row` is the ROW number, counting from one (`items[0]` → `row: 1`); the order of
`results` matches `items`. `action: "skipped"` means **nothing** from that row
reached the database — no product, no attributes, no sets: the batch is written
in one transaction, and there can be no "half a product" in it.

Row rejection codes: `SKU_REQUIRED`, `DUPLICATE_SKU_IN_BATCH` (BOTH rows are
rejected — only the file's author knows which one is right),
`PRODUCT_NAME_REQUIRED` and `CATEGORY_REQUIRED` (new products only),
`CATEGORY_NOT_FOUND`, `CATEGORY_TYPE_MISMATCH`, `OLD_PRICE_LESS_THAN_PRICE`,
`SLUG_TAKEN`, `ATTRIBUTE_NOT_FOUND`, `ATTRIBUTE_VALUE_INVALID`,
`DUPLICATE_ATTRIBUTE_IN_ROW`, `NO_VARIANT_AXES`, `VARIANT_AXIS_NOT_SELECT`,
`VARIANT_VALUES_MUST_COVER_AXES`, `VARIANT_VALUE_UNKNOWN_OPTION`,
`DUPLICATE_VARIANT_COMBO`, `DUPLICATE_VARIANT_SKU`, `AXIS_ALREADY_GROUP_AXIS`
(the attribute is already a grouping axis of the product), `IMAGE_NOT_FOUND`,
`GALLERY_ITEM_KIND_MISMATCH`, `GALLERIES_TOO_MANY` (the product already has 15
galleries — the import has no room for its own), `TOO_MANY_*` and `ROW_INVALID`.

`ROW_INVALID` — the row violated a contract constraint: negative product or set
price, a field above its length cap, `display` outside the vocabulary
`text · icon · swatch`. `field` carries the path to the field (`base_price`,
`variants[0].price`), `message` the reason. Row constraints are checked as a
whole, including nested attributes, axes and sets: a bad row is rejected on its
own and does not bring down its neighbours.

`OLD_PRICE_LESS_THAN_PRICE` compares prices AFTER merging with the product's
current values. A price list that sends only `base_price` above the stored
`old_price` gets a row rejection — send both prices together.

Only a request invalid AS A WHOLE gets an HTTP error:

| HTTP | Code | When |
|---|---|---|
| `400` | validator message | empty `items` or more than 5,000 rows |
| `400` | `IMPORT_TOO_MANY_ATTRIBUTES` / `IMPORT_TOO_MANY_VARIANTS` / `IMPORT_TOO_MANY_IMAGES` | total batch cap (1,200,000 / 200,000 / 200,000) |
| `401` / `403` | `AUTH_*`, `PAT_SCOPE_MISSING` | no token, no shop, no `catalog:write` right |
| `403` | `PAT_CONTOUR_MISMATCH` | **dev key**: bulk import writes only to the live shop, and the key's contour cuts the request off before the handler |
| `413` | `REQUEST_TOO_LARGE` | body larger than 64 MiB (the gRPC side accepts the same) |
| `429` | `IMPORT_RATE_LIMITED` | per-minute ROW budget exceeded; the message says how long to wait |
| `429` | `PRODUCT_LIMIT_REACHED` | the plan's product cap; counts the batch's NEW rows; if there is not enough room, none is written |

**Body size.** The request cap is 64 MiB, and 5,000 rows do not always fit: a row
with 100+ attributes weighs about 10 KB, so a batch of 5,000 such rows approaches
the limit. Recommendation: with 100+ attributes per product send **no more than
1,000 rows** per request (keep 5,000-row batches for price lists with a dozen
fields). Exceeding it gives an explicit `413 REQUEST_TOO_LARGE`, not a dropped
connection: when you see it, split the file into parts rather than repeating
the same request.

**Images.** `images` takes `file_id`s of ALREADY uploaded images (§5); there is no
upload by URL here. The cap is **24 frames** per row: exactly what a regular
gallery accepts (§7), so the import does not assemble anything the standard
editor would later refuse to save.

All of a row's frames go into ONE gallery of the product — **its own**: created
on the first import, reused on later ones; no second gallery appears. "Its own"
means created by the import and not used anywhere else — neither bound to
another product, category or article, nor referenced by a variant set, including
when that binding so far exists only in a dev draft. Everything else is foreign
to the import: a hand-built gallery, a gallery placed on several cards at once
(the standard model — "take the gallery from another product"), a canvas
gallery. The import does not modify a foreign gallery: it creates its own
alongside. If the product already has 15 galleries (the per-object cap), the
row gets `GALLERIES_TOO_MANY`.

**Gallery order is yours.** The import puts its own gallery FIRST in the
product's bindings (the first gallery is the face of the card) **only when it
creates it**. An order you set manually via `PUT /products/{id}` (`gallery_ids`)
is not touched by re-uploads: if you put your manual gallery first, it stays
first however many times the price list runs. If you need the imported one
first, reorder the bindings yourself.

**What the import changes in frames.** Only the set of `file_id`s and their
order. Frame captions (`caption`), posters, html items and the frame role are
yours: on a frame that survives the upload (same `file_id`) they are kept. A
frame no longer in `images` is deleted together with its caption — the frame is
simply gone.

**Moderation.** If the product is submitted to the shared marketplace
(`is_shared`) and already approved, changing buyer-facing content — name,
description, prices, category, tags, attributes, gallery frames — removes
"Approved" and returns the card to the moderation queue. The rule is the same
as for regular product editing. A change is detected by comparing before/after,
not by the field being sent: re-uploading the same file does not touch statuses.

**What the import does not do.** It does not delete products absent from the
file (a skipped product simply stays as it was); it does not create categories
and attributes — create them beforehand (§2, §3); it does not fetch files by URL.

---

### 8.2. Import speed

On `429 RATE_LIMITED` wait for the pause given in `Retry-After`; do not repeat
the successful part of the batch. The request budget is shared across the
company, including other keys and staff; numeric limits —
[/docs/webcoding §10](/docs/webcoding#10-errors-and-limits). A presigned upload
uses several requests, `from-url` one; limit parallelism. On `503` honour
`Retry-After` if it is returned.

## 9. Errors

The main shape is the RPC envelope:

```json
{ "error": "rpc error: code = InvalidArgument desc = INVALID_WIDGET" }
```

The machine code is the **first word** after `desc = `. Common ones:

| Code | Meaning |
|---|---|
| `PAT_METHOD_NOT_ALLOWED` | The method is not available to keys of this type at all |
| `PAT_SCOPE_MISSING` | A right is missing; the required one is named in the message |
| `AUTH_COMPANY_PROBLEM` | The shop is not determined — check the key |
| `INVALID_WIDGET` | Widget outside `text · select · icon · swatch · photo` |
| `INVALID_DATA_TYPE` | Type outside `string · number · bool · select · multiselect · date · color · url · group` |
| `INVALID_CODE`, `INVALID_OPTION_VALUE` | An attribute code and an option `value` are Latin slugs, not human-readable text |
| `FILE_IN_USE` | The file cannot be deleted: it is bound |
| `CATEGORY_FILE_NOT_FOUND` (404) | A category's `preview_id` or `seo.og_image_id` is not a live file of your shop. The check arrived with the "Galleries" release: such a write used to pass silently |
| `URL_NOT_ALLOWED`, `URL_FETCH_FAILED`, `FILE_TOO_LARGE` | Upload by URL |

Galleries (§7) respond with their own codes:

| Code | HTTP | Meaning |
|---|---|---|
| `GALLERY_NOT_FOUND` | `404` | No such gallery — either it belongs to another tenant or it was deleted. One code for both cases: the server does not confirm that another tenant's resource exists |
| `GALLERY_IN_USE: used in N places` | `400` | The gallery is in use; remove the references, `GET /galleries/{id}/usage` shows where |
| `GALLERY_NAME_REQUIRED` / `GALLERY_NAME_TOO_LONG` | `400` | Name is required, at most 64 characters |
| `GALLERY_TOO_MANY_ITEMS` | `400` | More than 24 items in the gallery |
| `GALLERY_DUPLICATE_ITEM_KEY` | `400` | The same `item_key` appears twice |
| `GALLERY_ITEM_KIND_MISMATCH` | `400` | The role sent does not match the file type (or files of that category are not shown in galleries) |
| `GALLERY_ITEM_FILE_REQUIRED` | `400` | An item without `kind: "html"` has no file |
| `GALLERY_ITEM_HTML_REQUIRED` | `400` | A `kind: "html"` item has empty markup |
| `GALLERY_ITEM_HTML_TOO_LONG` | `400` | Item markup exceeds 64 KB |
| `GALLERY_FILE_NOT_FOUND` | `404` | `file_id` or `poster.id` is not a live file of your shop |
| `GALLERIES_TOO_MANY` | `400` | More than 15 galleries per object |
| `GALLERY_DUPLICATE_LINK` | `400` | The same gallery is listed twice in `gallery_ids` |
| `GALLERY_MANAGED_BY_CANVAS` | `400` | You cannot enable canvas mode and send items in one request: bind first, then items |
| `GALLERY_DUPLICATE_CANVAS` | `400` | This canvas document already manages another gallery of the shop |
| `CANVAS_DOCUMENT_NOT_FOUND` | `404` | `canvas_id` is not a live document of your shop |
| `CANVAS_TYPE_MISMATCH` | `400` | The document exists but is of the wrong type |

⚠️ More than 15 values in `gallery_ids` are rejected by the single-item endpoint
**before** the machine code — with a validator message (`value must contain no
more than 15 item(s)`). The bare `GALLERIES_TOO_MANY` code comes from
`POST /products/batch`, where errors are returned per item.

There is a **second** shape — for an unknown path. It comes with `404` and
contains a hint:

```json
{ "error": "not_found", "path": "/v1/products/808",
  "hint": "catalog is unprefixed (/products); storage/account/org under /v1",
  "docs": "/openapi.json" }
```

A client must be able to parse both. The distinguishing sign: the presence of
the `path` field.

The load limiter responds with `429` and states the wait time — respect it and
retry the request after the pause.

---

## 10. Stop conditions

Stop and ask the owner if:

- the shop currency does not match the price list currency;
- the key writes to the live shop and there was no confirmation for a bulk write;
- after creation the product is not found in its category filter;
- a value read back differs from the one sent, and this is not described here;
- a public page responds with something other than `200`;
- a `429` arrives and retrying after a pause does not help.

And a non-negotiable rule: **the key never ends up in any file, log, HTML,
report or message**. It lives in a separate store with `600` permissions.

---

## 11. Reference: fields with surprises

| Field | Type | What matters |
|---|---|---|
| `price.price` | integer | In the shop currency. No fractional part |
| `price.currency` | string | Empty on write = the shop currency; another currency is `400 CURRENCY_IS_STORE_LEVEL`. The response carries the shop currency |
| `category_id` | integer | Primary category |
| `categories[]` | array of integers | Additional ones. On update a non-empty list **replaces** the set; clearing needs `[]` + `categories_replace: true` |
| `preview` / `preview_id` | object / string | Create — `preview: {id}`; update — `preview_id: "…"` (`preview: {id}` is an alias there; `preview_id: ""` removes the image) |
| `catalog_hidden` | string | `""` / `"true"` / `"false"` — not a boolean |
| `is_published` | boolean | A regular flag |
| `order` (category) | integer | **Ignored on create** — the section goes last. Set order via `/categories/move` |
| `widget` (attribute) | string | `text · select · icon · swatch · photo` |
| `display` (axis) | string | `"" · text · icon · swatch` — narrower than `widget` |
| `data_type` (attribute) | string | A switcher axis is always `select` |
| `values` (set) | object | `attribute_id` (as a string) → option `value` |
| `gallery_ids` | array of **strings** | Gallery references (`int64` arrives as a string). Non-empty → replacement; empty without `gallery_ids_replace` → "do not touch" |
| `galleries` | array of objects | Only on a card read. Empty in lists. May be longer than `gallery_ids`: at the end — galleries referenced by sets |
| `items_replace` (gallery) | boolean | Needed to clear items: empty `items` without the flag = "do not touch" |
| `gallery_id` (set) | string | Any live gallery of the shop, not only "its own". `"0"` = no reference or the gallery was deleted |
| `kind` (gallery item) | string | Set by the **server** from the file type. Manually — only `"html"`. Mismatch = rejection |
| `item_key` (gallery item) | string | Stable key. Empty → the server assigns one; your own is kept on retry |
| `source`, `canvas_id` (gallery) | string | `"canvas"` = items are managed by a canvas document; items sent are ignored |
| `preview_auto` | boolean | `true` = preview filled in by fallback from a gallery. Do not write it back into `preview_id` |
| `public_path` (file) | string | Full storage URL. On the storefront — a relative path with cropping |
| `page.number` | integer | Starts at 1. Required if `page.limit` is passed |
| `group_info`, `variants_info`, `attribute_sections`, `categories` | objects | Siblings of `result` in the product response, not its fields |

---

## 12. Changing what already exists

Measured on a stand on 2026-10-28.

### 12.1. `PUT /products/{id}` changes only what you send

An absent field keeps its value — `is_published`, `catalog_hidden`, `name`,
`category_id` included. Send the fields you change and nothing else. Watch
these fields: this is where a small fix loses data.

| You send | What happens |
|---|---|
| `price: {price: 900}` without `old_price` | the strikethrough price becomes `0`: `price` writes both. Send both, or leave `price` out. **Never send back a `price` you read** while `price.promotion_name` is set or `price.price` differs from `price.catalog_price`: a read shows the price after promotions and price rules, and writing it makes the discount permanent. The stored base price is `price.catalog_price` |
| `seo` with one field | empty text fields (`slug`, titles, description, keywords) are kept, but `og_image_id`, `noindex` and `nofollow` are rewritten — an `seo` without them clears the image and both flags. Send all three with every `seo` |
| `categories: [X]`, `tags: [X]` | **replace** the whole list with `[X]` (§2.3); `[]` changes nothing |
| `description: ""`, `sku: ""`, `preview_id: ""` | clears that field. `name: ""` is `400` |
| `stock_quantity` | ignored; stock is `PUT /products/{id}/stock`, closed to keys (`/docs/stock`) |

Attributes are not in this body (an unknown field is `400 INVALID_REQUEST`):
`PUT /products/{id}/attributes` replaces the product's whole set of values.

### 12.2. One field on many products

1. **Snapshot.** Read every product you will touch and keep the JSON — it is
   your rollback. Keep `price.catalog_price` as the base price, not
   `price.price` (§12.1). `GET /products?attribute_codes=…` answers as a buyer:
   it leaves out unpublished and hidden products and attributes hidden from
   buyers (`/docs/characteristics` §5) — read those per product.
2. **Canary.** Write one product, re-read it, open its page.
3. **Write.** Products with an `sku`: `POST /products/import` with rows
   `{"sku": …, <the field>}` — absent scalars keep their value, one request per
   5 000 rows. Without an `sku`: `PUT /products/{id}` per product (§12.1).
   **Attributes are a replace-set in both**: to change one, send the product's
   full set built from the snapshot, or the others are lost.
4. **Verify** by reading everything back and comparing with the plan.
5. **Rollback** = the same write with the snapshot values (prices as in step 1).

### 12.3. Take a product off sale and bring it back

| For a buyer | `is_published: false` | `catalog_hidden: "true"` |
|---|---|---|
| the card (`GET /products/{id}`) | `404` | opens |
| its storefront address | not found: a permanent `308` to the nearest live section of the address when the shop's `miss_fallback` is on (`/docs/redirects`), otherwise `404` | opens |
| section grids, search, section counts | gone | gone |
| grouping switcher | gone | stays |
| sitemap | gone | stays |
| adding to a cart | refused | allowed |

A product already in a cart stays there and can still be ordered. Bring it back
with `is_published: true` or `catalog_hidden: ""`. A `308` is permanent: a
browser that followed it may keep going to the section after the product is
back. `catalog_hidden` is a pause in grids and search only — the product is
still sold by its address; only `is_published: false` stops the sale.

### 12.4. Copy and delete

- **`POST /products/{id}/copy`**, body `{}` → `{"new_id": …}`. Same shop, and
  a **prod** key only (a dev key is `403 PAT_CONTOUR_MISMATCH`). The copy is
  `Copy of <name>`, unpublished, with an empty `sku` and a new slug. It keeps
  description, prices, categories, tags, preview, SEO, the scene (as a new
  copy), attributes, content blocks, variant axes and sets (their `sku`
  cleared), combo contents and **links to the same galleries** — editing such a
  gallery changes both products. Not copied: `catalog_hidden`, the grouping,
  reviews, stock levels, relations, multi-product contents (the copy is
  `standard`).
- **`DELETE /products/{id}`**, body `{}` (required). Soft: the answer carries
  `deleted_after` (+30 days), but there is **no restore call** and a deleted
  product no longer reads back. Its address 308s to its last category
  (`miss_fallback`, `/docs/redirects`). Orders
  keep their lines; it leaves carts, groupings and combos (and the combo price).
- **`DELETE /categories/{id}`**, body `{}`. Refused while it has subcategories
  (`CATEGORY_HAS_CHILDREN`), products — primary or extra, unpublished too
  (`CATEGORY_HAS_PRODUCTS`) — or articles (`CATEGORY_HAS_ARTICLES`); the home
  page is `HOME_CATEGORY_UNDELETABLE`. Move the products first (§12.1).

### 12.5. Sets and multi-products

- **A set (combo):** `PUT /products/{id}/combo-items`
  `{"items":[{"component_product_id":6,"quantity":1,"price_mode":"inherit"}]}` —
  a replace-set of at most 20 items. A non-empty list makes the product a
  `combo`, an empty one returns it to `standard`. `price_mode`: `inherit` ·
  `fixed` · `percent` · `free` with `price_value`. No nesting
  (`COMBO_IS_COMPONENT`, `COMPONENT_IS_COMBO`), no duplicates, no foreign or
  deleted components (`COMPONENT_NOT_FOUND`). Read: `GET /products/{id}/combo`.
- **A multi-product (container):** `PUT /products/{id}/kind {"kind":"multi"}`,
  then `PUT /products/{id}/children {"items":[{"product_id":5,"sort_order":1}]}`
  — a replace-set of at most 500 `standard` products, each in one container
  only. Back to `standard` only when the contents are empty
  (`PRODUCT_HAS_CHILDREN`). `combo` is never set through `/kind`.
- **Do not send `combo-items` to a multi-product**: it is not refused and
  silently turns the container into a set.

### 12.6. Groupings

`PUT /product-groups/{id}` changes the name only; `…/members` and `…/axes` are
replace-sets (§6). `DELETE /product-groups/{id}` with `{}` removes the members
and axes at once: the products are free for another grouping and their cards do
not change otherwise.

### 12.7. Move a catalogue to another shop

There is no export and no cross-shop copy. A key works in one shop, so you need
a key per shop.

1. **Read the source:** the list, then every card — the list carries no
   description, galleries or attributes hidden from buyers.
2. **Structure in the target:** categories, then attributes with their options
   and labels, then each attribute's buyer access (`PUT /attributes/{id}/access`
   — new attributes are hidden, `/docs/characteristics`).
3. **Files:** a file of one shop is not visible to another. Re-create each
   from its public address with `POST /v1/storages/files/from-url`, or upload
   the bytes (§7).
4. **Products:** `POST /products/import` rows by `sku`, with the new category
   and file ids. Canary first. The import does not carry `catalog_hidden` — set
   it with `PUT /products/{id}` afterwards.
5. **Links between products** — groupings (§6), sets and multi-products
   (§12.5) — are re-created after all products exist, with the new ids.
6. **Verify** the target field by field against the source.

Not movable by key: stock levels (cabinet only, `/docs/stock`) and view
counters. Promotions (`promotions:*`) and price rules (`pricing:*`) are created
again in the target shop, with the new product ids.

---

## 13. Where next

| Document | About |
|---|---|
| `/docs/webcoding` | Page layout, HTML blocks, images and cropping, limits |
| `/openapi.json` | Full API schema |
| `/v1/account/token` | Key passport: contour, shop, currency, rights |
| `/llms.txt` | Short API map for an AI agent |
