Vizen Shop

Импорт каталога Vizen по API

/docs/catalog-importcurrentRU

Гайд для внешнего разработчика или ИИ-агента, который наполняет магазин товарами по персональному ключу (PAT).

Соседний документ /docs/webcoding учит вёрстке страниц. Этот — данным: категории, товары, картинки, характеристики, варианты и связанные товары. Полная схема — /openapi.json.

Документ написан по итогам разбора реального импорта: каждый раздел «Подводный камень» — ошибка, которая уже стоила кому-то повторной работы.

0. Тридцать секунд: что нужно знать до первого запроса

ФактСледствие
Магазин определяется ключом, не параметромcompany_id в запросе игнорируется; чужой тенант не виден
Цена — целое число в валюте магазинаprice.currency в запросе игнорируется; сменить валюту ключом нельзя
Ключ пишет либо в живой сайт, либо в черновикСвойство ключа, заголовком не переключается
category_idосновная категория, categories[]дополнительныеДублировать основную в categories[] не нужно
Галерея — отдельный ресурс магазина, а не поле товараЗаводится в /galleries; товар, категория и статья ссылаются на неё gallery_ids[] (§7)
Списки требуют обе части пагинацииpage.limit без page.number400
Каталог живёт без префикса /v1/products, /categories; файлы и организация — под /v1

1. Шаг 0 — паспорт ключа (обязателен)

GET /v1/account/token
Authorization: Bearer vz_pat_…
{
  "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)…",
    "Сменить валюту, язык или название магазина этим ключом НЕЛЬЗЯ…"
  ]
}

Что с этим делать перед массовой записью:

  1. writes_to_live: true → сказать владельцу прямым текстом: *«записи сразу увидят посетители»* — и дождаться подтверждения.
  2. store.currency → сверить с валютой прайса. Не совпало — остановиться. Валюту меняет владелец в админке; ключом это невозможно (capabilities.store_settings_write: false).
  3. capabilities → проверить, что нужные права есть. Наличие тематического scope ещё не значит, что метод открыт для ключа: список capabilities считается по фактической карте доступа, ему и верить.
  4. warnings → передать владельцу целиком, не пересказывая.

Подводный камень. Раньше контур приходилось выяснять опытом: записать товар и пойти смотреть витрину. Теперь этого делать не нужно — и не надо.


2. Пять правил, которые чаще всего ломают импорт

2.1. Валюта магазина сильнее присланной

// запрос
{ "item": { "price": { "price": 6400, "currency": "RUB" } } }
// ответ: currency = "USD" — валюта магазина

Цена — целое число в валюте магазина. Копейки/центы не отделяются отдельным полем: 6400 — это 6400 единиц валюты.

price.currencyread-only по смыслу: сервер вернёт валюту магазина независимо от присланного значения. Не полагайтесь на это поле при записи и сверяйте валюту на шаге 0.

2.2. Пагинация — обе части или ни одной

GET /products?page.limit=100              → 400 (Number must be >= 1)
GET /products?page.limit=100&page.number=1 → 200
GET /products                              → 200 (дефолтная страница)

Передаёте page.limit — обязательно передавайте и page.number (нумерация с 1).

2.3. Основная и дополнительные категории — разные поля

ПолеЧто значит
category_idОсновная категория. Определяет основной URL товара и его листинг
categories[]Дополнительные категории (id). Товар покажется и в них
categories_replace: trueПолная замена набора дополнительных. [] + флаг = очистить

Основную категорию не нужно повторять в categories[] — это создаёт лишнюю связь, а не «усиливает» привязку.

// создание — только основная
POST /products   { "item": { "category_id": 85 } }
// создание — основная + две дополнительные
POST /products   { "item": { "category_id": 85, "categories": [86, 87] } }
// правка — полная замена дополнительных
PUT /products/808 { "item": { "categories": [86, 87], "categories_replace": true } }
// правка — очистить дополнительные, основную оставить
PUT /products/808 { "item": { "categories": [], "categories_replace": true } }

categories_replace работает только при правке. Без него categories при правке дополняет набор, а пустой массив означает «не менять», а не «снять все». При создании флаг не нужен — набор и так задаётся с нуля.

2.4. Тройные флаги — это строки, а не boolean

Часть переключателей наследуется от магазина, поэтому у них три состояния, и в JSON они приходят строкой:

ПолеЗначенияСмысл
catalog_hidden"" / "true" / "false""" — как настроено выше; "true" — скрыть из сеток витрины
comments_enabled, questions_enabled"" / "true" / "false"то же
reviews_scope"" / own / category / shop / off

is_published — обычный boolean. Не перепутайте: catalog_hidden: false (boolean) — ошибка типа, нужно "false" строкой либо "" для сброса.

Безопасная последовательность: создать товар с is_published: false → проверить чтением → опубликовать.

2.5. Порядок категорий задаётся перемещением, а не полем

order в теле создания игнорируется: новая категория всегда встаёт ПОСЛЕДНЕЙ среди соседей. Прочитанное значение поэтому закономерно отличается от отправленного — не считайте это ошибкой и не пытайтесь «додавить» нужное число повторной записью.

Так сделано, чтобы позиции соседей оставались плотным набором 0..n-1 без повторов: на нём держится арифметика перемещений before/after. Раньше поле писалось как прислано, и каталог быстро набирал группы, где у всех соседей order = 0 — тогда порядок фактически определялся внутренним id, а перетаскивание разделов в админке переставало работать.

Нужен конкретный порядок — расставьте его явно после создания всех разделов:

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

3. Модель каталога

Магазин (ключ)
├── Галерея (именованный набор медиа; своя ручка /galleries)
│      ↑ ссылаются товар, категория, статья и набор варианта
└── Категория (раздел витрины; дерево через parent_id)
    └── Товар  ── основная категория + дополнительные
        ├── Характеристики  (значения атрибутов: материал, страна, …)
        ├── Варианты внутри товара  (одна карточка, переключатели)
        └── Членство в склейке      (несколько карточек, связанных осями)

Галерея не принадлежит товару: она живёт сама по себе, и одну и ту же галерею можно поставить на несколько товаров, на категорию и на статью сразу. Это и есть штатный способ «взять галерею из другого товара» (§7).

Атрибут (/attributes) — общий для магазина справочник: код, тип, виджет, список опций. Он используется и как характеристика, и как ось переключателя.


4. Варианты или связанные товары: как выбрать

Платформа поддерживает обе модели. Выбор делается один раз и меняется дорого — сравните по таблице.

Что нужноВарианты внутри товараСклейка отдельных товаров
Отдельный URL у каждой комбинации✗ один URL + параметр✓ у каждого свой
Отдельные SEO-заголовки и описание
Отдельная галерея у комбинации✓ ссылка набора на галерею (gallery_id, §5 шаг 4б)✓ свой набор галерей у каждого
Свой SKU и цена
Отдельный складской остаток
Отдельная карточка в сетке каталога✗ одна✓ каждая (лишние прячутся catalog_hidden)
Стоимость заведенияниже: 1 товар + наборвыше: N товаров + группа

Правило выбора одной строкой: различаются только цена, SKU и остаток → варианты; различаются ещё и фотографии → варианты + своя галерея у набора; различается текст, URL или место в сетке каталога → склейка.

Показать «другой цвет» можно тремя способами, и они не заменяют друг друга:

Если нужно…МеханизмКуда пишетсяЦена решения
у варианта другой чип и фото строки в корзине, слайдер общийкартинка набора image_idPUT /products/{id}/variantsодна картинка на набор
при выборе варианта другие фотографии в слайдере, товар один, URL одингалерея + gallery_id набораPOST /galleriesPUT /products/{id}PUT /products/{id}/variants≤10 галерей на объект × ≤24 элемента; SEO общий
у каждого варианта свой URL, SEO, описание и карточка в сеткесклейка товаровN × POST /products + /product-groupsN товаров вести; лишние карточки прячутся catalog_hidden

image_id и gallery_idнезависимые поля набора: первый отвечает за чип и строку корзины, второй — только за слайдер.

Промежуточный случай (нужны отдельные URL, но контент одинаковый) — берите склейку: добавить контент потом дёшево, разрезать один товар на девять — нет.


5. Сценарий А — один товар с вариантами

Когда: футболка в трёх размерах, отличается только цена и остаток.

# 1) Справочник: атрибут-ось с опциями
POST /attributes
{ "item": {
    "code": "size", "name": "Размер",
    "data_type": "select",        # ось переключателя — всегда select
    "widget": "select",           # text | select | icon | swatch | photo
    "options": [ { "value": "s", "label": "S" },
                 { "value": "m", "label": "M" },
                 { "value": "l", "label": "L" } ] } }
# → result.id = 12, у опций свои id

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

# 3) Оси товара (replace-set)
PUT /products/900/variant-axes
{ "items": [ { "attribute_id": 12, "display": "text", "sort_order": 0 } ] }

# 4) Наборы (replace-set): values = attribute_id → value опции
PUT /products/900/variants
{ "items": [
    { "values": { "12": "s" }, "price": 2400, "sku": "TERRA-TSHIRT-S" },
    { "values": { "12": "m" }, "price": 2400, "sku": "TERRA-TSHIRT-M" },
    { "values": { "12": "l" }, "price": 2600, "sku": "TERRA-TSHIRT-L" } ] }

# 4б) галереи и привязка набора (опционально)
#     Галерея — самостоятельный ресурс: сначала заводим её, потом ссылаемся.
POST /galleries
{ "item": { "name": "Синие",
            "items": [ { "file": { "id": "<file_id_blue_1>" } },
                       { "file": { "id": "<file_id_blue_2>" } } ] } }
# → result.id = "31"   (id галереи — СТРОКА: в JSON int64 приходит строкой)

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

# галереи товара — это ССЫЛКИ, полный набор в нужном порядке
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" },
    { "values": { "12": "m" }, "price": 2400, "sku": "TERRA-TSHIRT-M" },
    { "values": { "12": "l" }, "price": 2600, "sku": "TERRA-TSHIRT-L" } ] }
# → variants_info.variants[].gallery_id в ответе GET /products/900 подтверждает привязку

# 5) Проверка и публикация
GET  /products/900/variants
PUT  /products/900   { "item": { "is_published": true } }

Подводные камни:

  • variant-axes и variantsreplace-set: присылайте полный набор, отсутствующее удаляется.
  • display у оси уже: "" / text / icon / swatch. Значение select допустимо у атрибута, но не у оси товара.
  • price у набора необязателен: пусто → берётся базовая цена товара.
  • Дубль комбинации значений отклоняется — сервер считает подпись набора.
  • Максимум: 20 осей, 500 наборов на товар.
  • gallery_ids в PUT /products/{id}набор ссылок по паттерну categories_replace: непустой массив → полная замена в присланном порядке; пустой массив или поле не прислано → привязки не трогаются (безопасно копировать тело GET в PUT); пустой массив + "gallery_ids_replace": true → отвязать все. Содержимое галереи правится своей ручкой PUT /galleries/{id}, а не через товар.
  • gallery_id набора — ссылка на любую живую галерею магазина, не обязательно привязанную к этому товару. Это и есть «взять галерею из другого товара». Чужой или несуществующий id → 404 GALLERY_NOT_FOUND. Галерею удалили позже — набор не ломается: на чтении gallery_id вернётся "0", витрина покажет первую галерею товара.
  • Одна галерея может стоять на нескольких товарах, на категории и на статье одновременно — GET /galleries/{id}/usage покажет весь список.
  • Картинка набора (image_id, шаг 4) и галерея набора (gallery_id) — независимые поля: image_id остаётся чипом и превью строки корзины, gallery_id отвечает только за слайдер витрины.

6. Сценарий Б — отдельные товары, связанные склейкой

Когда: тарелка в 3 цветах × 3 размерах, у каждой свои фото и свой URL.

# 1) Две оси-атрибута
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" }, { "value": "26" }, { "value": "32" } ] } }

# 2) Девять товаров — обычные POST /products, у каждого свой slug, фото, цена
#    (или один POST /products/batch, см. §8)

# 3) Каждому товару — значения по обеим осям (replace-set)
PUT /products/808/attributes
{ "items": [ { "attribute_id": 13, "value": "light" },
             { "attribute_id": 14, "value": "20"    } ] }

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

# 5) Оси группы (replace-set) — порядок переключателей на витрине
PUT /product-groups/5/axes
{ "items": [ { "attribute_id": 13, "display": "swatch", "sort_order": 0 },
             { "attribute_id": 14, "display": "text",   "sort_order": 1 } ] }

# 6) Участники (replace-set)
PUT /product-groups/5/members
{ "items": [ { "product_id": 808, "sort_order": 0 },
             { "product_id": 809, "sort_order": 1 } ] }

# 7) Проверка: карточка отдаёт готовую связь
GET /products/808
# → group_info: оси, значения, слаги соседей, текущий участник

Подводные камни:

  • Значения осей ставятся товару (PUT /products/{id}/attributes), а не участнику группы: в members поле values — только для чтения.
  • Все три PUT — replace-set. Добавляете десятый товар в группу — присылайте всех десятерых.
  • Чтобы в сетке каталога была одна карточка вместо девяти, остальным поставьте catalog_hidden: "true". Из склейки они не выпадут — переключатель на карточке продолжит на них вести.
  • group_info в ответе товара — сосед поля result, а не его вложение. Там же лежат variants_info, attribute_sections, categories.

7. Картинки товара

Загрузка — три шага (или один, если файл уже в интернете); подробности и правила нарезки — в /docs/webcoding, §3.

POST /v1/storages/files
{ "section": "product", "file_type": "image", "original_name": "plate.jpg" }
# → result.id = <file_id>, upload.id, upload.url
PUT  <upload.url>                       # байты, без Authorization
POST /v1/storages/files/<upload.id>/done  { }

# файл уже в сети — одним запросом
POST /v1/storages/files/from-url
{ "source_url": "https://…/plate.jpg", "section": "product", "file_type": "image" }

# галерея заводится ОТДЕЛЬНО и один раз
POST /galleries
{ "item": { "name": "Основная",
            "items": [ { "file": { "id": "<file_id_2>" } },
                       { "file": { "id": "<file_id_3>" } } ] } }
# → result.id = "31"

# привязка при СОЗДАНИИ — превью объектом, галереи ссылками
POST /products
{ "item": { "name": "Тарелка", "preview": { "id": "<file_id>" },
            "gallery_ids": ["31"] } }

# привязка при ПРАВКЕ — главная картинка строкой-идентификатором
PUT /products/808
{ "item": { "preview_id": "<file_id>",
            "gallery_ids": ["31"], "gallery_ids_replace": true } }

Подводные камни:

  • Создание и правка принимают главную картинку по-разному: при создании — preview: { "id": … }, при правке — preview_id: "…" строкой. Галереи в обоих случаях — gallery_ids, массив id-строк. Прислать при правке preview вместо preview_id — тихо ничего не изменить: запрос ответит 200, картинка останется прежней. Всегда перечитывайте товар после записи.
  • Снять главную картинку при правке — preview_id: "" (пустая строка).
  • В /done идёт `upload.id`, а к товару цепляется `result.id`. Это разные значения.
  • file_type — категория (image | video | audio | model | file), не MIME.
  • Один file_id можно использовать в нескольких галереях и товарах — повторно тот же файл не загружайте.
  • public_path в ответе хранилища — полный адрес объектного хранилища. На витрине ставьте относительный путь витрины с нарезкой (/w/{ширина}/webp{путь}), а не этот URL и не оригинал.
  • gallery_ids — replace-set ссылок: присылайте полный список в нужном порядке.

7.1. Галерея — самостоятельный ресурс

Галерея не лежит внутри товара. Это отдельная сущность магазина со своим CRUD; товар, категория (любого типа) и статья только ссылаются на неё.

РучкаЧто делает
GET /galleriesживые галереи магазина вместе с элементами, свежие сверху; страницами?page.number=&page.limit= (1..100), без параметров первые 50; в ответе рядом с result[] есть total и currentPage
GET /galleries/{id}одна галерея с элементами
POST /galleriesсоздать галерею вместе с элементами
PUT /galleries/{id}правка: имя и/или элементы
DELETE /galleries/{id}мягкое удаление; занятую удалить нельзя
GET /galleries/{id}/usageгде используется: товары, категории, статьи и наборы вариантов

Форма галереи:

{ "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": "" } ] }

Что важно знать про элементы:

  • роль (`kind`) ставит сервер, а не вы. Она выводится из типа файла: image | video | audio. Вручную задаётся только "html" — у него нет файла, есть разметка. Прислали kind, не совпавший с типом файла (например "video" на картинке) → 400 GALLERY_ITEM_KIND_MISMATCH. Тихой починки нет: молчаливая подмена роли даёт витрину, не похожую на то, что вы отправляли;
  • у не-html элемента файл обязателен (400 GALLERY_ITEM_FILE_REQUIRED), у html обязателен непустой html (400 GALLERY_ITEM_HTML_REQUIRED) не длиннее 64 КБ (400 GALLERY_ITEM_HTML_TOO_LONG);
  • poster — постер видео или обложка звука, тот же { "id": … };
  • item_key — стабильный ключ элемента. Пусто на входе → сервер выдаст uuid; прислали свой — сохранится. По нему элемент остаётся «тем же» при перестановке, поэтому при повторе запроса после таймаута присылайте собственные ключи;
  • items в PUT /galleries/{id}replace-set: непустой список заменяет все элементы целиком; пустой список (или отсутствие поля) означает «не трогать». Чтобы очистить галерею — пустой список плюс "items_replace": true: без флага удалить последний элемент нельзя вовсе. Имя правится отдельно и независимо: { "item": { "name": "…" } } элементы не тронет.

Привязка к объектам — одинаковая у товара, категории и статьи:

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

На чтении карточки приходят оба поля: gallery_ids — набор ссылок по порядку, galleries — их содержимое. Одна галерея законно стоит на нескольких объектах — правка её содержимого меняет вид всех сразу, и это ожидаемо.

⚠️ galleries может быть длиннее gallery_ids. Если набор варианта ссылается на галерею, которая к самому товару не привязана, её содержимое всё равно приезжает в карточку — в конце galleries, но не в gallery_ids. Так сделано нарочно: вернув тело GET обратно в PUT, вы не привяжете к товару чужую галерею. Из этого следует правило: набор привязок читайте из gallery_ids, а содержимое для показа ищите в galleries по id, а не по позиции. Фолбэк превью считается только по ПРИВЯЗАННЫМ галереям — товар, у которого галерея есть только по ссылке набора, отдаёт preview: null и preview_auto: false. Проверено вживую.

Удалить занятую галерею нельзя: 400 GALLERY_IN_USE: used in N places. Сначала снимите ссылки (у объектов — gallery_ids: [] + gallery_ids_replace, у наборов — gallery_id: "0"), сверьтесь по GET /galleries/{id}/usage, и только потом удаляйте.

Лимиты: ≤24 элемента в галерее, имя 1..64 символа, ≤10 галерей на один объект.

7.2. Что игнорируется молча — и как проверить, что запись состоялась

Сервер отвечает 200 и в этих случаях, но делает не то, что вы ожидали:

Что прислалиЧто произошло на самом деле
preview вместо preview_id при правкекартинка не изменилась
gallery_ids: [] без gallery_ids_replaceпрочитано как «не трогать», а не «отвязать все»
categories: [] без categories_replaceто же самое: дополняем, не заменяем
price.currencyотброшено, валюта берётся из магазина
company_id в теле запросаотброшено, магазин определяет ключ
order при создании категорииотброшено, раздел встаёт последним
items у галереи, которой управляет канвас-документ (canvas_id ≠ 0)отброшены; хранимые элементы остаются резервной копией до отвязки — включая запрос с canvas_id: 0 и элементами вместе: отвязка сработает, элементы нет
items: [] у галереи без items_replaceпрочитано как «не трогать»; очистить — только с флагом
чтение gallery_ids / galleries из спискову GET /products полей нет вовсе, у GET /categories они приходят пустыми — списки галереи не отдают, там есть только preview

Проверка после каждой записи — одна и та же: перечитать объект и сверить поля, которые отправляли. Ответ 200 доказывает только то, что запрос разобран.

# записали
PUT /products/808 { "item": { "gallery_ids": ["31"], "gallery_ids_replace": true } }
# перечитали и сверили
GET /products/808        # result.gallery_ids == ["31"], result.galleries[0].id == "31"
GET /galleries/31        # items — те, что отправляли, в том же порядке
GET /galleries/31/usage  # в списке есть product 808

Три поля, которые особенно легко прочитать неверно:

  • preview_auto: true — превью показано фолбэком: своего у товара нет, и сервер взял первый элемент-изображение первой галереи. Не записывайте это превью обратно в preview_id — получите дубль;
  • gallery_id набора равен "0" — ссылки нет либо галерею удалили; витрина покажет первую галерею товара;
  • source: "canvas" у галереи — её элементы ведёт канвас-документ (canvas_id), и присланные вами элементы туда не попадут;
  • galleries длиннее gallery_ids — в конце стоят галереи, на которые ссылаются наборы вариантов, а к товару они не привязаны.

8. Массовый импорт

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

Ответ — частичный успех: по каждой позиции отдельно приходит результат или ошибка. Разбирайте ответ поштучно; общий 200 не значит, что прошли все.

Порядок безопасного импорта:

  1. Паспорт ключа (§1) → сверка валюты и контура, подтверждение владельца.
  2. Категории → зафиксировать порядок через /categories/move.
  3. Атрибуты и опции → запомнить их id, они нужны везде дальше.
  4. Файлы → собрать карту локальное имя → file_id.
  5. Галереи (POST /galleries) → собрать карту набор фото → gallery_id. Галерея заводится до товара: товар ссылается на неё, а не наоборот.
  6. Один товар-канарейка, is_published: false → прочитать обратно → сверить каждое поле, которое отправляли → показать владельцу.
  7. Остальной каталог батчами.
  8. Связи (варианты или склейки).
  9. Публикация: is_published: true.
  10. Проверка витрины: страница категории и карточка товара отвечают 200 и содержат ожидаемое.

Идемпотентность. Сервер не хранит ваш внешний ключ товара. Ведите локальный манифест внешний артикул → id товара → gallery_id → file_id → slug и перед созданием проверяйте наличие: GET /products/by-slug/{slug}. Иначе повторный запуск создаст дубли.

Откат. Не удаляйте — скрывайте: is_published: false + catalog_hidden: "true". Это обратимо и не рвёт ссылки.


9. Ошибки

Основная форма — RPC-конверт:

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

Машинный код — первое слово после desc = . Частые:

КодЧто значит
PAT_METHOD_NOT_ALLOWEDМетод вообще недоступен ключам этого типа
PAT_SCOPE_MISSINGНе хватает права; требуемое указано в тексте
AUTH_COMPANY_PROBLEMМагазин не определён — проверьте ключ
INVALID_WIDGETВиджет вне списка text · select · icon · swatch · photo
INVALID_DATA_TYPEТип вне списка string · number · bool · select · multiselect · date · color · url · group
INVALID_CODE, INVALID_OPTION_VALUEКод атрибута и value опции — латиница-слаг, не текст для людей
FILE_IN_USEФайл нельзя удалить: он привязан
CATEGORY_FILE_NOT_FOUND (404)preview_id или seo.og_image_id категории — не живой файл вашего магазина. Проверка появилась в волне «Галереи»: раньше такая запись проходила молча
URL_NOT_ALLOWED, URL_FETCH_FAILED, FILE_TOO_LARGEЗагрузка по ссылке

Галереи (§7) отвечают своими кодами:

КодHTTPЧто значит
GALLERY_NOT_FOUND404Нет такой галереи — либо она чужая, либо удалена. Один код на оба случая: существование чужого ресурса сервер не подтверждает
GALLERY_IN_USE: used in N places400Галерея занята; снимите ссылки, GET /galleries/{id}/usage покажет где
GALLERY_NAME_REQUIRED / GALLERY_NAME_TOO_LONG400Имя обязательно, максимум 64 символа
GALLERY_TOO_MANY_ITEMS400Больше 24 элементов в галерее
GALLERY_DUPLICATE_ITEM_KEY400Один item_key встретился дважды
GALLERY_ITEM_KIND_MISMATCH400Присланная роль не совпала с типом файла (или файл такой категории в галерее не показывается)
GALLERY_ITEM_FILE_REQUIRED400У элемента без kind: "html" нет файла
GALLERY_ITEM_HTML_REQUIRED400У kind: "html" пустая разметка
GALLERY_ITEM_HTML_TOO_LONG400Разметка элемента больше 64 КБ
GALLERY_FILE_NOT_FOUND404file_id или poster.id — не живой файл вашего магазина
GALLERIES_TOO_MANY400Больше 10 галерей на один объект
GALLERY_DUPLICATE_LINK400Одна галерея указана в gallery_ids дважды
GALLERY_MANAGED_BY_CANVAS400Нельзя одним запросом включить канвас-режим и прислать элементы: сначала привязка, потом элементы
GALLERY_DUPLICATE_CANVAS400Этот канвас-документ уже ведёт другую галерею магазина
CANVAS_DOCUMENT_NOT_FOUND404canvas_id — не живой документ вашего магазина
CANVAS_TYPE_MISMATCH400Документ есть, но он не того типа

⚠️ Больше 10 значений в gallery_ids одиночная ручка отклоняет раньше машинного кода — сообщением валидатора (value must contain no more than 10 item(s)). Код GALLERIES_TOO_MANY в чистом виде приходит из POST /products/batch, где ошибка возвращается по каждой позиции отдельно.

Есть вторая форма — для неизвестного пути. Она приходит с 404 и содержит подсказку:

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

Клиент должен уметь разобрать обе. Признак: наличие поля path.

Ограничитель нагрузки отвечает 429 и сообщает время ожидания — уважайте его и повторяйте запрос после паузы.


10. Стоп-условия

Остановиться и спросить владельца, если:

  • валюта магазина не совпала с валютой прайса;
  • ключ пишет в живой магазин, а подтверждения на массовую запись не было;
  • товар после создания не находится в фильтре своей категории;
  • прочитанное значение отличается от отправленного, и это не описано здесь;
  • публичная страница отвечает не 200;
  • пришёл 429, а повтор с паузой не помогает.

И правило, которое не обсуждается: ключ не попадает ни в один файл, лог, HTML, отчёт или сообщение. Он живёт в отдельном хранилище с правами 600.


11. Справочник полей с сюрпризами

ПолеТипЧто важно знать
price.priceцелоеВ валюте магазина. Без дробной части
price.currencyстрокаИгнорируется при записи; ответ — валюта магазина
category_idцелоеОсновная категория
categories[]массив целыхДополнительные. При правке дополняет набор; полная замена — только с categories_replace: true
preview / preview_idобъект / строкаСоздание — preview: {id}; правка — preview_id: "…". Перепутать = тихий 200 без изменения
catalog_hiddenстрока"" / "true" / "false" — не boolean
is_publishedbooleanОбычный флаг
order (категория)целоеПри создании игнорируется — раздел встаёт последним. Порядок задавать через /categories/move
widget (атрибут)строкаtext · select · icon · swatch · photo
display (ось)строка"" · text · icon · swatch — уже, чем widget
data_type (атрибут)строкаОсь переключателя — всегда select
values (набор)объектattribute_id (строкой) → value опции
gallery_idsмассив строкСсылки на галереи (int64 приходит строкой). Непустой → замена; пустой без gallery_ids_replace → «не трогать»
galleriesмассив объектовТолько на чтении карточки. В списках пуст. Может быть длиннее gallery_ids: в конце — галереи по ссылкам наборов
items_replace (галерея)booleanНужен, чтобы очистить элементы: пустой items без флага = «не трогать»
gallery_id (набор)строкаЛюбая живая галерея магазина, не только «своя». "0" = ссылки нет или галерею удалили
kind (элемент галереи)строкаСтавит сервер из типа файла. Вручную — только "html". Несовпадение = отказ
item_key (элемент галереи)строкаСтабильный ключ. Пусто → выдаст сервер; свой — сохранится при повторе запроса
source, canvas_id (галерея)строка"canvas" = элементы ведёт канвас-документ, присланные игнорируются
preview_autobooleantrue = превью подставлено фолбэком из галереи. Не записывать обратно в preview_id
public_path (файл)строкаПолный адрес хранилища. На витрине — относительный путь с нарезкой
page.numberцелоеС 1. Обязателен, если передан page.limit
group_info, variants_info, attribute_sections, categoriesобъектыСоседи result в ответе товара, а не его поля

12. Куда дальше

ДокументО чём
/docs/webcodingВёрстка страниц, HTML-блоки, картинки и нарезка, лимиты
/openapi.jsonПолная схема API
/v1/account/tokenПаспорт ключа: контур, магазин, валюта, права
/llms.txtКраткая карта API для ИИ-агента

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

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