Интеграция с Wildberries отличается от большинства API одной особенностью: это несколько разных сервисов с разными базовыми адресами, разными лимитами и разной логикой ответов, объединённых общим токеном. Держать это в голове нужно с самого начала — универсальный клиент «на все случаи» здесь получается хуже, чем несколько специализированных.
Разберём реализацию и то, что определяет надёжность обмена с маркетплейсом.
Архитектура Wildberries API
Wildberries имеет несколько API:
- Контент API — создание/редактирование карточек товаров
- Marketplace API — цены, остатки, заказы, поставки
- Статистика API — отчёты, продажи, аналитика
- Рекламные API — управление рекламой
Каждый API требует свой токен, полученный в личном кабинете.
Что реализуем: Синхронизацию остатков с 1С/CMS → WB, получение новых заказов WB → CRM, автообновление цен по расписанию.
Шаг 1: HTTP-клиент для WB API
<?php
// src/Wildberries/Client.php
namespace App\Wildberries;
use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Exception\RequestException;
use Psr\Log\LoggerInterface;
class Client
{
private HttpClient $http;
private string $token;
private LoggerInterface $logger;
// Базовые URL разных API
private const ENDPOINTS = [
'content' => 'https://content-api.wildberries.ru',
'marketplace' => 'https://marketplace-api.wildberries.ru',
'statistics' => 'https://statistics-api.wildberries.ru',
'advert' => 'https://advert-api.wildberries.ru',
];
public function __construct(
string $token,
LoggerInterface $logger
) {
$this->token = $token;
$this->logger = $logger;
$this->http = new HttpClient([
'timeout' => 30,
'headers' => [
'Authorization' => $token,
'Content-Type' => 'application/json',
],
]);
}
/**
* GET-запрос к API
*/
public function get(
string $api,
string $path,
array $query = []
): array {
return $this->request('GET', $api, $path, ['query' => $query]);
}
/**
* POST-запрос к API
*/
public function post(
string $api,
string $path,
array $data = []
): array {
return $this->request('POST', $api, $path, ['json' => $data]);
}
/**
* Базовый метод запроса с retry-логикой
*/
private function request(
string $method,
string $api,
string $path,
array $options = []
): array {
$url = self::ENDPOINTS[$api] . $path;
$attempts = 0;
$maxAttempts = 3;
while ($attempts < $maxAttempts) {
try {
$response = $this->http->request($method, $url, $options);
$body = $response->getBody()->getContents();
return json_decode($body, true) ?? [];
} catch (RequestException $e) {
$attempts++;
$code = $e->getResponse()?->getStatusCode() ?? 0;
// 429 = Rate limit — ждём и повторяем
if ($code === 429 && $attempts < $maxAttempts) {
$retryAfter = $e->getResponse()
->getHeaderLine('Retry-After') ?: 60;
$this->logger->warning("WB API rate limit, waiting {$retryAfter}s");
sleep((int) $retryAfter);
continue;
}
// Логируем и пробрасываем ошибку
$this->logger->error("WB API error", [
'url' => $url,
'code' => $code,
'message' => $e->getMessage(),
'attempt' => $attempts,
]);
if ($attempts >= $maxAttempts) {
throw $e;
}
sleep(2 ** $attempts); // Exponential backoff
}
}
return [];
}
}Шаг 2: Синхронизация остатков
<?php
// src/Wildberries/StockService.php
namespace App\Wildberries;
class StockService
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
/**
* Получение остатков со склада WB
*/
public function getStocks(int $warehouseId): array
{
$response = $this->client->post('marketplace', '/api/v3/stocks/' . $warehouseId);
return $response['stocks'] ?? [];
}
/**
* Обновление остатков на складе WB
*
* @param int $warehouseId ID склада WB
* @param array $stocks Массив [['sku' => 'ABC123', 'amount' => 10], ...]
*/
public function updateStocks(int $warehouseId, array $stocks): bool
{
// WB принимает максимум 1000 позиций за раз
$chunks = array_chunk($stocks, 1000);
foreach ($chunks as $chunk) {
$this->client->put('marketplace', '/api/v3/stocks/' . $warehouseId, [
'stocks' => $chunk
]);
}
return true;
}
/**
* Синхронизация остатков из локальной БД в WB
*/
public function syncFromDatabase(int $warehouseId): array
{
// Получаем товары из нашей БД
$products = $this->getLocalProducts();
$stocks = [];
foreach ($products as $product) {
$stocks[] = [
'sku' => $product['article'],
'amount' => max(0, $product['quantity']), // WB не принимает отрицательные
];
}
$this->updateStocks($warehouseId, $stocks);
return [
'synced' => count($stocks),
'warehouse' => $warehouseId,
];
}
private function getLocalProducts(): array
{
// Здесь запрос к вашей БД/CMS/1С
return [];
}
}Лимиты API: WB ограничивает количество запросов. Для остатков — не более 6 запросов в минуту. Используйте пакетные обновления и очереди.
Шаг 3: Обновление цен
Цены — самое опасное место интеграции с маркетплейсом. Ошибка в остатках стоит штрафа за отмену; ошибка в цене может стоить распроданного по себестоимости склада, причём за считанные часы и без возможности отменить заказы.
Обязательный минимум защиты перед отправкой:
Проверка на вменяемость каждой позиции. Отклонение новой цены от текущей больше чем на заданный процент — повод не отправлять, а уведомить человека. Потерянный множитель, съехавшая колонка в выгрузке, нули вместо цены — всё это выглядит как обычные числа и проходит любую проверку типов.
Проверка объёма изменений. Если в этом прогоне меняется цена у 90% каталога, это почти наверняка сбой источника, а не решение отдела продаж.
Нижняя граница по себестоимости. Жёсткая, на уровне кода, а не «менеджер проверит». Цена ниже закупочной не должна уходить в маркетплейс ни при каких обстоятельствах.
Журнал отправленных цен. С ним разбор инцидента занимает минуты; без него — сутки и разговор с бухгалтерией.
<?php
// src/Wildberries/PriceService.php
namespace App\Wildberries;
class PriceService
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
/**
* Получение текущих цен
*/
public function getPrices(int $limit = 1000, int $offset = 0): array
{
return $this->client->get('marketplace', '/api/v2/list/goods/filter', [
'limit' => $limit,
'offset' => $offset,
]);
}
/**
* Обновление цен
*
* @param array $prices [['nmId' => 123, 'price' => 1990], ...]
*/
public function updatePrices(array $prices): array
{
// WB принимает максимум 1000 позиций
$chunks = array_chunk($prices, 1000);
$results = [];
foreach ($chunks as $chunk) {
$response = $this->client->post('marketplace', '/api/v2/upload/task', [
'data' => array_map(fn($p) => [
'nmId' => $p['nmId'],
'price' => $p['price'],
], $chunk),
]);
$results[] = $response;
}
return $results;
}
/**
* Установка скидки
*/
public function setDiscount(int $nmId, int $discount): array
{
return $this->client->post('marketplace', '/api/v2/upload/task', [
'data' => [
[
'nmId' => $nmId,
'discount' => min(99, max(0, $discount)),
],
],
]);
}
/**
* Массовое обновление цен с учётом маржи
*/
public function syncPricesWithMargin(float $marginPercent = 20): array
{
$localProducts = $this->getLocalProductsWithCost();
$prices = [];
foreach ($localProducts as $product) {
$cost = $product['cost']; // Себестоимость
$wbCommission = 0.15; // Комиссия WB ~15%
// Рассчитываем цену с учётом комиссии и маржи
$targetMargin = $marginPercent / 100;
$price = $cost / (1 - $wbCommission - $targetMargin);
// Округляем до красивой цены
$price = $this->roundPrice($price);
$prices[] = [
'nmId' => $product['nm_id'],
'price' => $price,
];
}
return $this->updatePrices($prices);
}
/**
* Округление до "красивой" цены
*/
private function roundPrice(float $price): int
{
if ($price < 1000) {
return (int) ceil($price / 10) * 10 - 1; // 990, 890, etc.
}
if ($price < 10000) {
return (int) ceil($price / 100) * 100 - 1; // 2990, 3990, etc.
}
return (int) ceil($price / 1000) * 1000 - 1; // 14990, 19990, etc.
}
private function getLocalProductsWithCost(): array
{
return [];
}
}Шаг 4: Получение заказов
<?php
// src/Wildberries/OrderService.php
namespace App\Wildberries;
use DateTime;
class OrderService
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
/**
* Получение новых заказов
*/
public function getNewOrders(?int $next = null): array
{
$params = ['limit' => 1000];
if ($next) {
$params['next'] = $next;
}
return $this->client->get('marketplace', '/api/v3/orders/new', $params);
}
/**
* Получение всех заказов (с пагинацией)
*/
public function getAllNewOrders(): array
{
$allOrders = [];
$next = null;
do {
$response = $this->getNewOrders($next);
$orders = $response['orders'] ?? [];
$allOrders = array_merge($allOrders, $orders);
$next = $response['next'] ?? null;
} while ($next && count($orders) > 0);
return $allOrders;
}
/**
* Получение заказов за период (из статистики)
*/
public function getOrdersForPeriod(DateTime $from, DateTime $to): array
{
// Statistics API требует отдельный токен!
return $this->client->get('statistics', '/api/v1/supplier/orders', [
'dateFrom' => $from->format('Y-m-d'),
'dateTo' => $to->format('Y-m-d'),
]);
}
/**
* Подтверждение сборки заказа
*/
public function confirmOrder(int $orderId, array $skus): array
{
return $this->client->post('marketplace', '/api/v3/orders/deliver', [
'orders' => [
[
'orderId' => $orderId,
'skus' => $skus,
],
],
]);
}
/**
* Отмена заказа
*/
public function cancelOrder(int $orderId): array
{
return $this->client->post('marketplace', '/api/v3/orders/cancel', [
'orderIds' => [$orderId],
]);
}
/**
* Синхронизация заказов в локальную CRM
*/
public function syncToCRM(): array
{
$orders = $this->getAllNewOrders();
$synced = 0;
$errors = [];
foreach ($orders as $order) {
try {
$this->saveOrderToCRM($order);
$synced++;
} catch (\Exception $e) {
$errors[] = [
'orderId' => $order['id'],
'error' => $e->getMessage(),
];
}
}
return [
'total' => count($orders),
'synced' => $synced,
'errors' => $errors,
];
}
private function saveOrderToCRM(array $order): void
{
// Сохранение в вашу CRM/БД
// $order содержит: id, rid, createdAt, skus, price, ...
}
}Шаг 5: Автосинхронизация по расписанию
Laravel Schedule
<?php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
// Синхронизация остатков каждые 30 минут
$schedule->command('wb:sync-stocks')
->everyThirtyMinutes()
->withoutOverlapping();
// Получение новых заказов каждые 5 минут
$schedule->command('wb:sync-orders')
->everyFiveMinutes()
->withoutOverlapping();
// Обновление цен раз в день в 6:00
$schedule->command('wb:sync-prices')
->dailyAt('06:00')
->withoutOverlapping();
}Artisan Command для синхронизации
<?php
// app/Console/Commands/SyncWildberriesStocks.php
namespace App\Console\Commands;
use App\Wildberries\StockService;
use Illuminate\Console\Command;
class SyncWildberriesStocks extends Command
{
protected $signature = 'wb:sync-stocks {--warehouse=}';
protected $description = 'Sync stocks to Wildberries';
public function handle(StockService $service): int
{
$warehouseId = $this->option('warehouse')
?? config('services.wildberries.warehouse_id');
$this->info("Starting stock sync to warehouse {$warehouseId}...");
try {
$result = $service->syncFromDatabase($warehouseId);
$this->info("Synced {$result['synced']} items");
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error("Sync failed: " . $e->getMessage());
return Command::FAILURE;
}
}
}Обработка ошибок API
<?php
// src/Wildberries/Exception/ApiException.php
namespace App\Wildberries\Exception;
class ApiException extends \Exception
{
private ?array $response;
public function __construct(
string $message,
int $code = 0,
?array $response = null
) {
parent::__construct($message, $code);
$this->response = $response;
}
public function getResponse(): ?array
{
return $this->response;
}
/**
* Проверка, можно ли повторить запрос
*/
public function isRetryable(): bool
{
return in_array($this->code, [429, 500, 502, 503, 504]);
}
}Итоги
Реализованы клиент с повторами и учётом лимитов, синхронизация остатков и цен, получение заказов и работа по расписанию. Что важно помнить при эксплуатации такого обмена.
Токен — полный доступ к вашему кабинету продавца. Хранить в конфигурации вне репозитория, выдавать с минимальным набором прав под конкретную задачу, отзывать при увольнении сотрудника, имевшего к нему доступ.
Повторы должны различать типы ошибок. Временный сбой сети или ответ «слишком много запросов» — повторить с увеличивающейся задержкой. Ошибка «неверные данные» — повторять бессмысленно, нужно уведомить человека. Слепой повтор на любую ошибку забивает очередь и добивает API, который и так отвечает плохо.
Обработка заказов обязана быть идемпотентной. Один и тот же заказ вы получите повторно — при перезапуске, при сбое на середине, при пересечении интервалов опроса.
Мониторинг — не «следующий шаг», а часть решения. Обмен работает в фоне и ломается молча: истёк токен, изменился формат ответа, упал сервер. Без проверки «последняя успешная синхронизация не старше часа» с уведомлением вы узнаете о проблеме от службы поддержки маркетплейса или из отчёта о штрафах.
Заложите время на изменения API. Маркетплейсы меняют версии и форматы регулярно и не всегда с заметным предупреждением. Интеграция — это не законченный проект, а система, требующая сопровождения; закладывайте это в оценку сразу.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.