Wildberries / API / автоматизация / маркетплейсы

Автоматизация товаров на Wildberries через API: карточки, цены, остатки и SEO без ручной рутины

Когда товаров десятки — карточки ведут руками. Когда сотни и тысячи — ручное ведение ломается: карточка не создаётся при ответе 200, обновление затирает поля, цены уходят в карантин, остатки «не применяются». Нормальная автоматизация WB — это не один HTTP-запрос, а pipeline со статусами, валидацией, журналом ошибок и повторами. Ниже — рабочая схема: справочники, создание карточек, проверка, медиа, цены, остатки, отчёты, контроль поставок и семантика.

Коротко. Автоматизация товаров на Wildberries — это pipeline, а не одиночный запрос. Сначала получаем справочники и характеристики, валидируем карточку, создаём её через Content API, проверяем появление nmID, разбираем ошибки через /content/v2/cards/error/list, отдельно грузим медиа, цены и остатки, а затем используем поисковую аналитику WB и рекламные кластеры для SEO-обновлений. Самые опасные места: асинхронное создание карточек, полная перезапись при /content/v2/cards/update, карантин цен, 429 по лимитам и отсутствие проверки остатков после 204.

Проблема ручного ведения карточек

Когда товаров 10–20, карточки можно вести руками. Когда товаров 300, 1000 или 10 000, ручное ведение ломается по предсказуемым причинам:

  • характеристики по предметам меняются;
  • карточка может не создаться, хотя API вернул 200;
  • обновление карточки может затереть поля, которые вы не собирались менять;
  • фото загружаются отдельным этапом — после появления nmID;
  • цены и остатки нельзя ставить сразу после создания, пока карточка синхронизируется;
  • поисковая семантика живёт отдельно от карточки, рекламы и аналитики;
  • лимиты API превращают массовую загрузку в хаос без очереди запросов.
Главный тезис. Нормальная автоматизация WB — это не один HTTP Request, а pipeline со статусами, валидацией, журналом ошибок и повторными попытками. Тот же принцип мы разбираем в материале про автоматизацию учёта товаров.

Решение: рабочий pipeline

Базовый production-процесс идёт строго по порядку: сначала справочники и валидация, потом создание карточки, потом проверка, и только затем медиа, цены, остатки и SEO-итерации.

  1. Источник товаров — ERP / 1С / Google Sheets / PIM.
  2. Нормализация данных под формат WB.
  3. WB-справочники: предметы, характеристики, цвета, НДС, ТН ВЭД.
  4. Валидация карточки до отправки.
  5. Создание карточкиPOST /content/v2/cards/upload.
  6. Ожидание синхронизации (до 30 минут).
  7. Проверка карточкиPOST /content/v2/get/cards/list.
  8. Если nmID найден → загрузка медиа. Если нет → разбор /content/v2/cards/error/list.
  9. Цены и скидки — Prices and Discounts API.
  10. Остатки FBS / склад продавца.
  11. Аналитика поиска и продаж.
  12. SEO-рекомендации и рекламные кластеры → безопасное обновление карточки → возврат к проверке.

Что реально автоматизируется через WB API

Автоматизация строится вокруг пяти API-категорий: Content, Prices and Discounts, Marketplace, Analytics, Promotion. Ниже — карта задач и подводных камней.

ЗадачаКатегорияМетод / разделЧто учитывать
Получить категорииContentGET /content/v2/object/parent/allНужен токен категории Content.
Получить предметыContentGET /content/v2/object/allПредмет определяет набор характеристик.
Характеристики предметаContentGET /content/v2/object/charcs/{subjectId}Важны required, hasFilter, тип значения и справочники.
Сгенерировать баркодыContentPOST /content/v2/barcodesИспользуются для размеров карточки.
Создать карточкиContentPOST /content/v2/cards/uploadДо 100 карточек или 100 групп; до 30 nmID в группе; до 10 МБ; создание асинхронное.
Список карточекContentPOST /content/v2/get/cards/listПроверка nmID, imtID, chrtID, фото, размеров.
Ошибки карточекContentPOST /content/v2/cards/error/listОбязательный шаг, если карточка не появилась после 200.
Обновить карточкиContentPOST /content/v2/cards/updateПерезаписывает карточку целиком. Нужен read-merge-update.
Медиа ссылкамиContentPOST /content/v3/media/saveНужен nmID; медиа не смешивать с созданием карточки.
Медиа файломContentPOST /content/v3/media/fileДля прямой загрузки файла.
Привязать тегиContentPOST /content/v2/tag/nomenclature/linkДо 15 тегов на карточку.
Цены и скидкиPrices and DiscountsPOST /api/v2/upload/taskДо 1000 товаров; цена может уйти в карантин.
Цены по размерамPrices and DiscountsPOST /api/v2/upload/task/sizeДля товаров с редактируемой ценой размера.
Обновить остаткиMarketplacePUT /api/v3/stocks/{warehouseId}До 1000 chrtId; после 204 желательно проверять остатки.
Поисковые запросы по товарамAnalytics/api/v2/search-report/...Нужна подписка «Джем».
Поисковые кластеры рекламыPromotion/adv/v0/normquery/...Ставки, минус-фразы, кластеры.

Контроль поставок и остатков (FBS/склад продавца) держится на категории Marketplace, а связка SEO+реклама — на Analytics и Promotion.

Архитектура автоматизации

Минимальная схема для MVP

Подходит для каталога до нескольких сотен товаров.

Google Sheetsисточник товаров
n8nоркестратор
WB APIContent / Prices / Stocks
Sheets: logstatus_log + errors
Минус MVP. Google Sheets быстро становится узким местом: нет нормальных блокировок, истории изменений и очереди.

Схема для production

Главное отличие: у каждого товара есть статус, а у каждого API-запроса — журнал выполнения.

Источник
ERP / 1С / PIMисходные данные
Хранилище
PostgreSQLтовары + статусы
Очередь
Redis / RabbitMQ / n8n queuerate-limit и retry
Воркеры
Worker Contentкарточки
Worker Pricesцены
Worker Stocksостатки
Worker Analyticsотчёты
WB API
content-api
discounts-prices-api
marketplace-api
seller-analytics-api
Результат
PostgreSQLжурнал ответов
Dashboard / alertsмониторинг

Минимальная база данных для интеграции

Не начинайте с HTTP-запросов. Начните с таблиц — они дают статусы, защиту от повторов и журнал ошибок.

products_source

ПолеПримерЗачем
vendor_codeTSHIRT-BLACK-001Главный ключ на стороне продавца.
subject_id192Предмет WB.
brandBrandNameБренд.
titleФутболка мужская хлопковаяНаименование.
descriptionОписание.
dimensions_json{…}Габариты с упаковкой.
characteristics_json[…]Характеристики по WB id.
sizes_json[…]Размеры, баркоды.
media_json[…]Ссылки на фото/видео.
price1990Базовая цена.
discount35Скидка.
stock42Остаток.

wb_cards

vendor_codeСвязка с исходным товаром.
nm_idАртикул WB. Нужен для медиа, рекламы, аналитики.
imt_idID объединённой карточки.
chrt_idID размера. Нужен для остатков.
nm_uuidUUID карточки, если нужен в процессах.
last_payload_hashЧтобы не обновлять карточку без изменений.
last_synced_atКогда карточка была синхронизирована.

wb_jobs

job_typecreate_card, check_card, upload_media, set_price, set_stock, update_card.
statusqueued, processing, success, retry, failed.
attempt1, 2, 3…
next_retry_atКогда повторять.
request_json / response_jsonЧто отправляли и что получили.
http_status200, 204, 400, 429…

Создание карточки через API

Последовательность перед созданием

  1. Получить родительские категории: GET /content/v2/object/parent/all.
  2. Получить предметы: GET /content/v2/object/all.
  3. Выбрать subjectID.
  4. Получить характеристики предмета: GET /content/v2/object/charcs/{subjectId}.
  5. Для характеристик из справочников использовать значения WB: цвета, пол, страна, сезон, НДС, ТН ВЭД.
  6. Сгенерировать или подготовить баркоды.
  7. Собрать payload.
  8. Отправить POST /content/v2/cards/upload.
  9. Подождать и проверить карточку через список карточек.
  10. Если карточки нет — проверить /content/v2/cards/error/list.

Важные ограничения

  • В одном запросе — максимум 100 индивидуальных карточек или 100 групп объединённых карточек.
  • В одной объединённой группе — до 30 карточек.
  • Максимальный размер запроса — 10 МБ.
  • Габариты — в сантиметрах, вес с упаковкой — в килограммах.
  • Создание асинхронное; синхронизация может занимать до 30 минут.
  • Во время синхронизации нельзя добавлять остатки и задавать цены.

Пример payload для создания карточки

Это шаблон. subjectID, characteristics.id и допустимые значения нужно получать из WB-справочников, а не копировать из примера.

[
  {
    "subjectID": 192,
    "variants": [
      {
        "vendorCode": "TSHIRT-BLACK-001",
        "brand": "BrandName",
        "title": "Футболка мужская хлопковая",
        "description": "Базовая мужская футболка из хлопка. Подходит для повседневной носки, спорта и дома.",
        "dimensions": {
          "length": 30,
          "width": 25,
          "height": 3,
          "weightBrutto": 0.25
        },
        "characteristics": [
          {
            "id": 14177449,
            "value": ["черный"]
          },
          {
            "id": 88952,
            "value": ["хлопок"]
          },
          {
            "id": 90630,
            "value": ["мужской"]
          }
        ],
        "sizes": [
          {
            "techSize": "M",
            "wbSize": "48",
            "price": 1990,
            "skus": ["4600000000001"]
          },
          {
            "techSize": "L",
            "wbSize": "50",
            "price": 1990,
            "skus": ["4600000000002"]
          }
        ]
      }
    ]
  }
]

Что валидировать до отправки

ПроверкаПочему важно
subjectID найден в WBНельзя создавать карточку в несуществующем или неверном предмете.
Все required характеристики заполненыИначе карточка уйдёт в ошибки.
hasFilter:true заполнены, если критичны для выдачиФильтруемые характеристики влияют на поиск и подбор товара.
Значения справочников взяты из WBОсобенно цвет, пол, страна, сезон, НДС, ТН ВЭД.
Название без запрещённых символов, email, телефона, ссылокИначе карточка попадёт в ошибки.
Габариты в см, вес в кгНеверные единицы ломают логистику и стоимость.
Цена не нольПри новых размерах без цены цена может стать 0.
Баркоды уникальныДубли создают конфликты.
Размер запроса меньше 10 МБИначе 413.
Партия не больше лимита методаИначе часть процесса придётся повторять.

Code node для n8n: валидация и сбор payload

const row = $input.first().json;

const required = [
  'vendorCode',
  'subjectID',
  'brand',
  'title',
  'description',
  'length',
  'width',
  'height',
  'weightBrutto',
  'barcode'
];

const missing = required.filter((key) => row[key] === undefined || row[key] === null || row[key] === '');

if (missing.length) {
  throw new Error(`Не заполнены поля: ${missing.join(', ')}`);
}

if (String(row.title).length > 60) {
  throw new Error('Название больше 60 символов. Сократите title до отправки в WB.');
}

const dimensions = {
  length: Number(row.length),
  width: Number(row.width),
  height: Number(row.height),
  weightBrutto: Number(row.weightBrutto)
};

for (const [key, value] of Object.entries(dimensions)) {
  if (!Number.isFinite(value) || value <= 0) {
    throw new Error(`Некорректное значение dimensions.${key}: ${value}`);
  }
}

const characteristics = JSON.parse(row.characteristicsJson || '[]');
const sizes = JSON.parse(row.sizesJson || '[]');

if (!characteristics.length) {
  throw new Error('characteristicsJson пустой. Сначала получите характеристики subjectID.');
}

if (!sizes.length) {
  sizes.push({
    techSize: String(row.techSize || '0'),
    wbSize: String(row.wbSize || ''),
    price: Number(row.price),
    skus: [String(row.barcode)]
  });
}

return [
  {
    json: {
      vendorCode: row.vendorCode,
      createPayload: [
        {
          subjectID: Number(row.subjectID),
          variants: [
            {
              vendorCode: String(row.vendorCode),
              brand: String(row.brand),
              title: String(row.title),
              description: String(row.description),
              dimensions,
              characteristics,
              sizes
            }
          ]
        }
      ]
    }
  }
];

Проверка ошибок после создания

Ошибка новичка: отправить /content/v2/cards/upload, получить 200 и считать, что карточка создана. Правильный порядок:

  1. Сохраняем vendorCode и payload в wb_jobs.
  2. Ждём паузу. Для MVP — 2–5 минут, для production — отдельная задача check_card с повтором.
  3. Запрашиваем /content/v2/get/cards/list с textSearch по vendorCode или barcode.
  4. Нашли карточку — сохраняем nmID, imtID, chrtID.
  5. Не нашли — запрашиваем /content/v2/cards/error/list.
  6. Ошибки пишем в БД и не двигаем товар дальше в медиа / цены / остатки.

Запрос списка карточек

{
  "settings": {
    "sort": {
      "ascending": false
    },
    "filter": {
      "textSearch": "TSHIRT-BLACK-001",
      "allowedCategoriesOnly": true,
      "tagIDs": [],
      "objectIDs": [],
      "brands": [],
      "imtID": 0,
      "withPhoto": -1
    },
    "cursor": {
      "limit": 100
    }
  }
}

Запрос ошибок карточек

{
  "cursor": {
    "limit": 100
  },
  "order": {
    "ascending": false
  }
}

Как маппить ошибки на товар

В ответе /content/v2/cards/error/list есть vendorCodes и объект errors, где ключом может быть артикул продавца. Поэтому в БД нужен vendor_code, а не только nmID: для неуспешно созданной карточки nmID может ещё не существовать.

const response = $input.first().json;
const items = response.data?.items || [];
const result = [];

for (const batch of items) {
  const errors = batch.errors || {};
  for (const [vendorCode, messages] of Object.entries(errors)) {
    result.push({
      json: {
        vendorCode,
        batchUUID: batch.batchUUID,
        updatedAt: batch.updatedAt,
        errors: messages
      }
    });
  }
}

return result;

Безопасное обновление карточек

POST /content/v2/cards/update — опасный метод. Он не обновляет одно поле как PATCH: карточка перезаписывается целиком. Поэтому нельзя отправлять только новое описание или новое название.

Правильный паттерн — read-merge-update:

  1. Получить текущую карточку из списка карточек.
  2. Собрать изменения (только то, что меняем).
  3. Слить current + changes.
  4. Проверить обязательные поля.
  5. Проверить, что не потеряли размеры и характеристики.
  6. Отправить /content/v2/cards/update.
  7. Проверить /cards/error/list.
  8. Сравнить итоговую карточку с ожидаемой.

Что нельзя обновлять через /content/v2/cards/update

  • нельзя обновлять или удалять баркоды размеров;
  • нельзя обновлять photos, video, tags;
  • нельзя менять цены существующих товаров этим методом;
  • цену можно указать только при добавлении нового размера; существующие цены обновляются через Prices and Discounts API.

Пример payload для обновления

[
  {
    "nmID": 123456789,
    "vendorCode": "TSHIRT-BLACK-001",
    "brand": "BrandName",
    "title": "Футболка мужская хлопок",
    "description": "Обновлённое описание без потери характеристик и размеров.",
    "dimensions": {
      "length": 30,
      "width": 25,
      "height": 3,
      "weightBrutto": 0.25
    },
    "characteristics": [
      {
        "id": 14177449,
        "value": ["черный"]
      },
      {
        "id": 88952,
        "value": ["хлопок"]
      },
      {
        "id": 90630,
        "value": ["мужской"]
      }
    ],
    "sizes": [
      {
        "chrtID": 111111111,
        "techSize": "M",
        "wbSize": "48",
        "skus": ["4600000000001"]
      },
      {
        "chrtID": 222222222,
        "techSize": "L",
        "wbSize": "50",
        "skus": ["4600000000002"]
      }
    ]
  }
]

Read-merge-update в Code node

const currentCard = $('Get WB Card').first().json.cards[0];
const changes = $('Prepare Changes').first().json;

if (!currentCard) {
  throw new Error('Карточка не найдена. Нельзя обновлять без текущего состояния.');
}

const updated = {
  nmID: currentCard.nmID,
  vendorCode: currentCard.vendorCode,
  brand: changes.brand ?? currentCard.brand,
  title: changes.title ?? currentCard.title,
  description: changes.description ?? currentCard.description,
  dimensions: changes.dimensions ?? currentCard.dimensions,
  characteristics: changes.characteristics ?? currentCard.characteristics,
  sizes: currentCard.sizes
};

if (!updated.sizes?.length) {
  throw new Error('Защита от потери sizes: в payload нет размеров.');
}

if (!updated.characteristics?.length) {
  throw new Error('Защита от потери characteristics: в payload нет характеристик.');
}

return [{ json: { updatePayload: [updated] } }];

Медиа, цены, скидки и остатки

Медиа

После создания карточки получите nmID, затем загрузите фото ссылками:

{
  "nmId": 123456789,
  "data": [
    "https://example.com/images/tshirt-black-1.jpg",
    "https://example.com/images/tshirt-black-2.jpg",
    "https://example.com/images/tshirt-black-3.jpg"
  ]
}

Практический контроль:

  • проверяйте, что ссылки открываются без авторизации;
  • используйте стабильные URL, а не временные ссылки CDN;
  • храните порядок изображений;
  • после загрузки снова получите карточку и проверьте photos.

Цены и скидки

Пример для POST /api/v2/upload/task:

{
  "data": [
    {
      "nmID": 123456789,
      "price": 1990,
      "discount": 35
    }
  ]
}

Перед отправкой цен нужен защитный валидатор — он спасает от карантина и продаж в минус:

const item = $input.first().json;
const oldFinalPrice = Number(item.oldPrice) * (1 - Number(item.oldDiscount || 0) / 100);
const newFinalPrice = Number(item.price) * (1 - Number(item.discount || 0) / 100);

if (!Number.isFinite(newFinalPrice) || newFinalPrice <= 0) {
  throw new Error('Итоговая цена некорректна.');
}

if (oldFinalPrice > 0 && newFinalPrice <= oldFinalPrice / 3) {
  throw new Error('Новая цена со скидкой в 3+ раза ниже прежней. Риск карантина цены.');
}

if (newFinalPrice < Number(item.minMarginPrice)) {
  throw new Error(`Цена ниже минимальной маржинальной цены: ${newFinalPrice}`);
}

return [{ json: item }];

Остатки и контроль поставок

Для остатков FBS / склада продавца используется chrtId, а не nmID. Пример для PUT /api/v3/stocks/{warehouseId}:

{
  "stocks": [
    {
      "chrtId": 111111111,
      "amount": 42
    },
    {
      "chrtId": 222222222,
      "amount": 18
    }
  ]
}

После 204 не считайте задачу завершённой. Сразу или через короткую паузу проверьте остатки:

{
  "chrtIds": [111111111, 222222222]
}
Практический риск. В методе обновления остатков WB указывает, что имена query-параметров не валидируются. Можно получить успешный 204, но остатки не обновятся. Поэтому в production обязателен verify-step — иначе контроль поставок «врёт».

Лимиты API и retry

WB API использует token bucket. В интеграции нельзя отправлять тысячи запросов простым циклом — нужна очередь.

Минимальная логика retry

function getRetryDelaySeconds(responseHeaders, attempt) {
  const retryHeader = responseHeaders['x-ratelimit-retry'] || responseHeaders['X-Ratelimit-Retry'];
  const retryAfter = Number(retryHeader);

  if (Number.isFinite(retryAfter) && retryAfter > 0) {
    return retryAfter;
  }

  return Math.min(60, 2 ** attempt);
}

function shouldRetry(status) {
  return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
}

Правила очереди

СитуацияЧто делать
429Читать X-Ratelimit-Retry, ждать, повторять.
409Не спамить повтором: один такой запрос может стоить больше лимита. Проверить payload.
400Не retry. Ошибка запроса — нужно исправить данные.
401Проверить токен и категорию токена.
403Проверить доступ, подписку, роль, категорию токена.
413Уменьшить размер партии.
422Проверить противоречивые параметры.
5xxRetry с backoff, но не бесконечно.

Семантическое ядро для Wildberries

Семантика WB должна собираться не из «ключевых слов вообще», а из данных, которые влияют на карточку и продажи. Принцип тот же, что в SEO карточек товаров и в SEO-текстах.

Источники семантики

ИсточникЧто братьКак использовать
Отчёт «Поисковые запросы на WB»запрос, количество, динамика, среднее в день, предмет с заказамиСбор спроса по нише и первичная карта запросов.
Отчёт «Поисковые запросы: ваши товары»позиция, видимость, переходы, корзина, заказы, конверсииОптимизация конкретных карточек.
Analytics API /search-report/product/search-textsтоп поисковых текстов по товаруАвтообновление ядра по nmID.
Analytics API /search-report/product/ordersзаказы и позиции по поисковым текстамПонимание, какие запросы реально продают.
Promotion API /adv/v0/normquery/listактивные и неактивные кластерыСвязка SEO и рекламы.
Promotion API /normquery/get-minus, /set-minusминус-фразыИсключение нерелевантного спроса в рекламе.

Классификация запросов

КлассПримерКуда использовать
Основной товарныйфутболка мужскаяНазвание, предмет, реклама.
Атрибутныйфутболка хлопковая, футболка чернаяХарактеристики, описание, фото.
Сезонныйфутболка летняяНазвание, описание, кампании по сезону.
Сценарныйфутболка для спортаОписание, инфографика, rich-контент.
Брендовыйbrandname футболкаНазвание, реклама, защита бренда.
Мусорныйвыкройка футболки, бесплатноМинус-фразы.
Сомнительныймного показов, нет корзины/заказовТестировать отдельно, не пихать в title.

Как не испортить карточку ключами

Плохой title — простыня из всех ключей:

Футболка мужская хлопковая черная летняя спортивная повседневная базовая одежда

Лучше — короткий и читаемый:

Футболка мужская хлопковая черная

Остальное уходит в описание, характеристики и изображения.

Практическая формула title

[предмет] + [пол/назначение] + [материал/ключевой атрибут] + [цвет/сезон]
НишаTitle
ОдеждаФутболка мужская хлопковая черная
ДомОрганайзер для белья в шкаф
Детские товарыПижама детская хлопковая
КосметикаКрем для рук увлажняющий
Ограничение. Title должен быть коротким и читаемым. В карточках WB для обновления через API поле title ограничено 60 символами.

Алгоритм семантического обновления карточки

  1. Получить поисковые запросы по nmID.
  2. Посчитать метрики (позиция, переходы, корзина, заказы, конверсии).
  3. Есть заказы? → кандидаты в title / характеристики / описание.
  4. Нет заказов, но есть корзины? → кандидаты в описание и фото.
  5. Ни заказов, ни корзин? → минус-фразы или тест рекламы.
  6. Проверить релевантность предмету.
  7. Сформировать изменения и применить read-merge-update карточки.

Структура семантической таблицы

nm_id / queryАртикул WB и поисковый запрос.
query_countЧастотность / количество запросов.
avg_position / visibilityСредняя позиция и видимость.
clicks / cart_count / ordersПереходы, добавления в корзину, заказы.
cart_conversion / order_conversionКонверсии в корзину и в заказ.
decisiontitle, description, characteristic, ads, minus, ignore.

Code node: классификация запросов

const rows = $input.all().map(i => i.json);

function classify(q) {
  const query = String(q.query || q.searchText || '').toLowerCase().trim();
  const orders = Number(q.orders || q.orderCount || 0);
  const carts = Number(q.carts || q.cartCount || 0);
  const clicks = Number(q.clicks || q.openCardCount || 0);
  const position = Number(q.avgPosition || q.position || 999);

  const trashPatterns = ['бесплатно', 'скачать', 'выкройка', 'ремонт', 'бу'];
  if (trashPatterns.some(p => query.includes(p))) {
    return 'minus';
  }

  if (orders > 0 && position <= 50) {
    return 'title_or_characteristic';
  }

  if (orders > 0) {
    return 'ads_or_description';
  }

  if (carts > 0 || clicks > 20) {
    return 'description_or_media';
  }

  return 'ignore';
}

return rows.map(row => ({
  json: {
    ...row,
    decision: classify(row)
  }
}));

n8n workflow

Ниже skeleton workflow для импорта в n8n. Он показывает рабочую последовательность: взять товар → собрать payload → создать карточку → подождать → проверить карточку → если nmID найден, загрузить медиа; если нет — получить ошибки.

Перед импортом замените: WB_API_TOKEN на credentials или env-переменную; тестовые значения товара; subjectID, characteristics, warehouseId, URL фото; endpoints — при изменении документации WB.

{
  "name": "WB Product Create Pipeline - Content Media Errors",
  "nodes": [
    {
      "parameters": {},
      "id": "manual-trigger",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -900,
        0
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "vendorCode",
              "name": "vendorCode",
              "value": "TSHIRT-BLACK-001",
              "type": "string"
            },
            {
              "id": "subjectID",
              "name": "subjectID",
              "value": 192,
              "type": "number"
            },
            {
              "id": "brand",
              "name": "brand",
              "value": "BrandName",
              "type": "string"
            },
            {
              "id": "title",
              "name": "title",
              "value": "Футболка мужская хлопковая",
              "type": "string"
            },
            {
              "id": "description",
              "name": "description",
              "value": "Базовая мужская футболка из хлопка для повседневной носки.",
              "type": "string"
            },
            {
              "id": "length",
              "name": "length",
              "value": 30,
              "type": "number"
            },
            {
              "id": "width",
              "name": "width",
              "value": 25,
              "type": "number"
            },
            {
              "id": "height",
              "name": "height",
              "value": 3,
              "type": "number"
            },
            {
              "id": "weightBrutto",
              "name": "weightBrutto",
              "value": 0.25,
              "type": "number"
            },
            {
              "id": "barcode",
              "name": "barcode",
              "value": "4600000000001",
              "type": "string"
            },
            {
              "id": "price",
              "name": "price",
              "value": 1990,
              "type": "number"
            },
            {
              "id": "characteristicsJson",
              "name": "characteristicsJson",
              "value": "[{\"id\":14177449,\"value\":[\"черный\"]},{\"id\":88952,\"value\":[\"хлопок\"]}]",
              "type": "string"
            },
            {
              "id": "sizesJson",
              "name": "sizesJson",
              "value": "[]",
              "type": "string"
            },
            {
              "id": "mediaJson",
              "name": "mediaJson",
              "value": "[\"https://example.com/tshirt-1.jpg\",\"https://example.com/tshirt-2.jpg\"]",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "set-sample-product",
      "name": "Set Sample Product",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -680,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const row = $input.first().json;\nconst required = ['vendorCode','subjectID','brand','title','description','length','width','height','weightBrutto','barcode'];\nconst missing = required.filter((key) => row[key] === undefined || row[key] === null || row[key] === '');\nif (missing.length) throw new Error(`Не заполнены поля: ${missing.join(', ')}`);\nif (String(row.title).length > 60) throw new Error('Название больше 60 символов');\nconst dimensions = { length: Number(row.length), width: Number(row.width), height: Number(row.height), weightBrutto: Number(row.weightBrutto) };\nfor (const [key, value] of Object.entries(dimensions)) { if (!Number.isFinite(value) || value <= 0) throw new Error(`Некорректное dimensions.${key}`); }\nconst characteristics = JSON.parse(row.characteristicsJson || '[]');\nconst sizes = JSON.parse(row.sizesJson || '[]');\nif (!characteristics.length) throw new Error('Нет characteristics');\nif (!sizes.length) { sizes.push({ techSize: String(row.techSize || '0'), wbSize: String(row.wbSize || ''), price: Number(row.price), skus: [String(row.barcode)] }); }\nreturn [{ json: { vendorCode: row.vendorCode, media: JSON.parse(row.mediaJson || '[]'), createPayload: [{ subjectID: Number(row.subjectID), variants: [{ vendorCode: String(row.vendorCode), brand: String(row.brand), title: String(row.title), description: String(row.description), dimensions, characteristics, sizes }] }] } }];"
      },
      "id": "build-create-payload",
      "name": "Build Create Payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -440,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://content-api.wildberries.ru/content/v2/cards/upload",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{$env.WB_API_TOKEN}}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{JSON.stringify($json.createPayload)}}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true
            }
          }
        }
      },
      "id": "create-card",
      "name": "Create WB Card",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -180,
        0
      ]
    },
    {
      "parameters": {
        "amount": 120,
        "unit": "seconds"
      },
      "id": "wait-sync",
      "name": "Wait Sync",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        60,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://content-api.wildberries.ru/content/v2/get/cards/list?locale=ru",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{$env.WB_API_TOKEN}}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{JSON.stringify({settings:{sort:{ascending:false},filter:{textSearch:$('Build Create Payload').first().json.vendorCode,allowedCategoriesOnly:true,tagIDs:[],objectIDs:[],brands:[],imtID:0,withPhoto:-1},cursor:{limit:100}}})}}"
      },
      "id": "get-cards-list",
      "name": "Get Cards List",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        300,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const vendorCode = $('Build Create Payload').first().json.vendorCode;\nconst cards = $input.first().json.cards || [];\nconst card = cards.find(c => String(c.vendorCode) === String(vendorCode));\nreturn [{ json: { found: Boolean(card), vendorCode, nmID: card?.nmID, imtID: card?.imtID, sizes: card?.sizes || [], media: $('Build Create Payload').first().json.media } }];"
      },
      "id": "extract-card",
      "name": "Extract Card Data",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        540,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "card-found",
              "leftValue": "={{$json.found}}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-card-found",
      "name": "IF Card Found",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        760,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://content-api.wildberries.ru/content/v3/media/save",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{$env.WB_API_TOKEN}}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{JSON.stringify({nmId:$json.nmID,data:$json.media})}}"
      },
      "id": "upload-media",
      "name": "Upload Media Links",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1020,
        -120
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://content-api.wildberries.ru/content/v2/cards/error/list?locale=ru",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{$env.WB_API_TOKEN}}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{JSON.stringify({cursor:{limit:100},order:{ascending:false}})}}"
      },
      "id": "get-card-errors",
      "name": "Get Card Errors",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1020,
        120
      ]
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Set Sample Product",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Sample Product": {
      "main": [
        [
          {
            "node": "Build Create Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Create Payload": {
      "main": [
        [
          {
            "node": "Create WB Card",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create WB Card": {
      "main": [
        [
          {
            "node": "Wait Sync",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait Sync": {
      "main": [
        [
          {
            "node": "Get Cards List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Cards List": {
      "main": [
        [
          {
            "node": "Extract Card Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Card Data": {
      "main": [
        [
          {
            "node": "IF Card Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Card Found": {
      "main": [
        [
          {
            "node": "Upload Media Links",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Get Card Errors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}

Что добавить к workflow для production

БлокЧто добавить
Источник данныхGoogle Sheets, PostgreSQL, Airtable, ERP webhook.
ОчередьSplit In Batches + Wait или отдельный queue mode.
Rate limitОбработка 429 по headers.
ЛогиЗапись request/response в БД.
ОшибкиОтдельная таблица wb_card_errors.
Повторattempt, next_retry_at, max 3–5 попыток.
Сравнение payloadХэш payload, чтобы не обновлять карточку без изменений.
УведомленияTelegram/Email при 400, 409, 413, 422.

Production-чек-лист

Перед созданием карточек

  • Токен имеет категорию Content и права Read and Write.
  • Есть локальная копия справочников WB.
  • Для каждого товара определён subjectID.
  • Все required характеристики заполнены.
  • Значения справочников взяты из WB.
  • Размеры и баркоды валидны.
  • Габариты в сантиметрах, вес в килограммах.
  • Payload меньше 10 МБ и партия не превышает лимит метода.

После создания карточек

  • Не ставить цены и остатки сразу.
  • Дождаться синхронизации.
  • Проверить карточку через /content/v2/get/cards/list.
  • Сохранить nmID, imtID, chrtID.
  • Если карточки нет — получить /content/v2/cards/error/list.
  • Не загружать медиа, пока нет nmID.

Перед обновлением карточек

  • Получить текущую карточку.
  • Сделать merge текущих данных и изменений.
  • Проверить, что не потерялись sizes и characteristics.
  • Не обновлять photos, video, tags через /cards/update.
  • Не менять цены существующих товаров через /cards/update.

Перед ценами и остатками

  • Проверить минимальную маржу.
  • Проверить, что цена не стала в 3+ раза ниже старой цены со скидкой.
  • Проверить, что есть nmID для цены и chrtID для остатка.
  • После 204 по остаткам выполнить проверочный запрос.

Для семантики

  • Разделить запросы на title / характеристики / описание / реклама / минус-фразы.
  • Не добавлять все ключи в title.
  • Проверять, есть ли по запросу заказы, корзины или хотя бы переходы.
  • Не обновлять SEO чаще, чем можно оценить эффект.
  • Хранить историю изменений title и описания.

Частые ошибки

1. Отправили карточку и сразу ставите остатки

Так нельзя. После создания карточка синхронизируется. До завершения синхронизации цены и остатки могут не примениться.

2. Используете /cards/update как PATCH

Метод перезаписывает карточку. Если отправить только новое описание, можно потерять данные. Делайте read-merge-update.

3. Не храните chrtID

Для остатков нужен chrtID. Если хранить только nmID, автоматизация остатков и контроль поставок будут неполными.

4. Не проверяете /cards/error/list

200 не означает, что карточка появилась в каталоге. Если карточка не найдена — смотрите ошибки.

5. Семантика собирается вручную один раз

Запросы меняются. Нужна регулярная выгрузка: хотя бы раз в неделю для активных товаров и рекламных кампаний.

6. Все ключи идут в название

Title должен быть коротким и читаемым. Частотные, но второстепенные запросы лучше отправлять в описание, характеристики, инфографику и рекламу.

FAQ

Можно ли полностью автоматизировать создание карточек на Wildberries?

Да, если данные подготовлены: предмет, характеристики, размеры, баркоды, габариты, описание и цены. Но карточки создаются асинхронно, поэтому нужен этап проверки через список карточек и список ошибок.

Можно ли загружать фото сразу при создании карточки?

Практичнее сначала создать текстовую часть карточки, получить nmID, затем загрузить медиа через методы медиа. Это надёжнее и проще отлаживается.

Почему карточка не появилась, хотя API вернул 200?

Потому что создание пакетное и асинхронное. Если карточка не появилась в /content/v2/get/cards/list, нужно смотреть /content/v2/cards/error/list.

Можно ли менять только описание карточки?

Технически можно, но payload обновления должен содержать и остальные параметры карточки, которые нельзя потерять. Иначе есть риск перезаписать карточку неполными данными.

Где брать ключевые слова для карточек WB?

Основные источники: отчёт «Поисковые запросы на WB», отчёт «Поисковые запросы: ваши товары», Analytics API с подпиской «Джем», рекламные поисковые кластеры Promotion API.

Подходит ли n8n для автоматизации Wildberries?

Да, для MVP и средних процессов. Для больших каталогов лучше использовать n8n как оркестратор, а хранение статусов, очереди и логов вынести в PostgreSQL/Redis или отдельный backend.

Источники

  1. WB API — Work with products: dev.wildberries.ru/docs/openapi/work-with-products
  2. WB API — General API information, tokens, rate limits: api-information
  3. WB API — Analytics and Data: openapi/analytics
  4. WB API — Marketing and Promotions: openapi/promotion
  5. WB API Knowledge Base — Token Bucket, лимиты и ошибка 429: limity-zaprosov-wb-api
  6. WB Partners — Отчёт «Поисковые запросы на WB»: search-analytics-report
  7. WB Partners — Отчёт «Поисковые запросы: ваши товары»: search-queries-report
  8. WB API Forum — Медиа, создание и обновление карточек: dev.wildberries.ru/forum/1971

Читать также

Автоматизация Wildberries

Нужно автоматизировать карточки WB без хаоса в таблицах?

Соберём pipeline под ваш каталог: справочники, валидация, создание карточек, медиа, цены, остатки, контроль поставок, журнал ошибок и SEO-аналитика. Начать можно с одного процесса — например, с проверки карточек после загрузки или с автообновления остатков, — чтобы быстро увидеть эффект.

Другие статьи