Разбираю архитектуру интеграции магазина на WordPress + WooCommerce с YCP — протоколом Яндекса для покупок через Алису AI, Поиск и Ритм

В конце мая 2026 Яндекс открыл Yandex Commerce Protocol для всех — теперь любой онлайн-магазин может подключить продажи через Алису AI, Поиск и рекомендательную ленту Ритм. Из коробки готовые решения есть для Яндекс KIT, Яндекс Маркета и 1С-Битрикс. Для всего остального — API.

У меня магазин на WooCommerce, и решения «нажать кнопку и заработало» для меня не было. Я мог либо ждать, пока Яндекс или кто-то ещё сделает интеграцию для WordPress (с непредсказуемым ETA), либо разобраться с API и написать плагин сам. Выбрал второе. На разработку ушло около двух недель работы по вечерам, плюс ещё неделя на отладку с тестовым кабинетом Яндекса. По итогу получился open-source плагин на GPL-2.0, в котором закрыты все 10 эндпоинтов спецификации YCP v1: github.com/perfinn/YCP-Yandex-Commerce-Woocommerce. Официальный сайт релиза

В статье разберу не «как поставить» (это в README), а как технически устроена интеграция: какие эндпоинты протокол требует реализовать, как маппить заказы из Яндекса в WC, где можно наступить на грабли (статусы заказов, письма, HPOS-совместимость), и почему я добавил идемпотентность по session_id с самого начала. Может, кому-то поможет либо взять плагин под свой проект, либо написать аналог под другую платформу.


Что вообще делает YCP с точки зрения архитектуры

Чтобы было понятно, что мы реализуем — короткий обзор протокола.

YCP — это HTTPS REST API от Яндекса к вашему сайту. Не наоборот. То есть когда покупатель в Алисе или Поиске нажимает «Купить в 1 клик», запросы летят от инфраструктуры Яндекса к серверу магазина. Магазин должен предоставить набор эндпоинтов, через которые Яндекс получает информацию о товарах, рассчитывает доставку, оформляет заказ и потом синхронизирует его статус.

В отличие от классических маркетплейс-интеграций (где магазин выгружает фид и забывает), тут двусторонний live-протокол. Каждый раз, когда пользователь видит товар в результатах Алисы — Яндекс делает запрос к магазину «уточни актуальную цену и наличие». Каждый раз, когда что-то меняется со статусом заказа — синхронизация через API.

Это значит, что плагин должен держать REST API всегда доступным, быстрым и идемпотентным. И тут начинается интересное.


Структура плагина

Прежде чем разбирать конкретные эндпоинты, покажу общий скелет. WordPress-плагин строится по стандартной схеме: главный PHP-файл с заголовком плагина, классы под отдельные ответственности, action/filter хуки для интеграции с WP и WooCommerce.

yandex-ycp-woo/
├── yandex-ycp-woo.php       # Главный файл, хуки активации, регистрация
├── includes/
│   ├── class-ycp-rest.php       # Регистрация REST API эндпоинтов
│   ├── class-ycp-auth.php       # Bearer-токен авторизация
│   ├── class-ycp-checkout.php   # Логика создания заказа из Яндекса
│   ├── class-ycp-delivery.php   # Варианты доставки (СДЭК, ПВЗ)
│   ├── class-ycp-orders.php     # Маппинг товаров, обновление статусов
│   ├── class-ycp-logger.php     # Лог в WP option (для отладки)
│   └── class-ycp-admin.php      # Страница настроек в админке
├── assets/
│   └── admin.css
└── readme.txt                   # Стандарт WordPress.org

Регистрация эндпоинтов идёт через стандартный для WP механизм register_rest_route в action rest_api_init:

add_action( 'rest_api_init', function() {
    $namespace = 'ycp/v1';
 
    // GET /ycp/v1/warehouses
    register_rest_route( $namespace, '/warehouses', [
        'methods'             => 'GET',
        'callback'            => [ 'YCP_Rest', 'get_warehouses' ],
        'permission_callback' => [ 'YCP_Auth', 'check_bearer' ],
    ]);
 
    // POST /ycp/v1/checkout
    register_rest_route( $namespace, '/checkout', [
        'methods'             => 'POST',
        'callback'            => [ 'YCP_Rest', 'create_checkout' ],
        'permission_callback' => [ 'YCP_Auth', 'check_bearer' ],
    ]);
 
    // ... ещё 8 эндпоинтов
});

Здесь важный момент с permission_callback. Это не опциональная штука, это обязательный механизм безопасности WP REST API. Если поставить туда __return_true, WordPress будет ругаться, и эндпоинт станет публично доступным без авторизации. Что для платёжного API недопустимо.

Авторизация в YCP идёт по Bearer-токену, который Яндекс генерирует в кабинете и присылает в каждом запросе:

class YCP_Auth {
    public static function check_bearer( WP_REST_Request $request ) {
        $stored_token = get_option( 'ycpy_bearer_token' );
        if ( empty( $stored_token ) ) {
            return new WP_Error( 'no_token_configured', 'Token not set', [ 'status' => 503 ] );
        }
 
        // Основной заголовок — Authorization: Bearer ...
        $auth = $request->get_header( 'authorization' );
        if ( $auth && stripos( $auth, 'Bearer ' ) === 0 ) {
            $token = trim( substr( $auth, 7 ) );
            if ( hash_equals( $stored_token, $token ) ) {
                return true;
            }
        }
 
        // Fallback на X-API-Key — иногда так удобнее тестировать через curl
        $api_key = $request->get_header( 'x_api_key' );
        if ( $api_key && hash_equals( $stored_token, $api_key ) ) {
            return true;
        }
 
        return new WP_Error( 'unauthorized', 'Invalid token', [ 'status' => 401 ] );
    }
}

Использование hash_equals вместо обычного === — это защита от timing attacks. Для платёжного API такая мелочь критична, и WordPress-разработчики часто её забывают.


Эндпоинт 1: warehouses

Самый простой. Яндекс при подключении магазина первым делом запрашивает список ваших складов. Из админки настройки нашего плагина я даю заполнить только один основной склад — для большинства мелких магазинов на WooCommerce этого достаточно.

public static function get_warehouses( WP_REST_Request $request ) {
    $warehouse = [
        'id'      => 'main',
        'name'    => get_option( 'ycpy_warehouse_name', 'Основной склад' ),
        'address' => [
            'country'  => 'RU',
            'city'     => get_option( 'ycpy_warehouse_city' ),
            'street'   => get_option( 'ycpy_warehouse_street' ),
            'building' => get_option( 'ycpy_warehouse_building' ),
        ],
        'phone'   => get_option( 'ycpy_warehouse_phone' ),
    ];
 
    return rest_ensure_response([
        'warehouses' => [ $warehouse ],
    ]);
}

Тут ничего сложного, но обращу внимание на rest_ensure_response() — это WP-обёртка, которая гарантирует правильный формат ответа с заголовками. Без неё может улететь голый array, и Яндекс не распарсит.


Эндпоинт 2: basket/check — проверка корзины

Вот это уже интересно. Когда пользователь смотрит товар в Алисе и нажимает «Купить», Яндекс перед показом универсального чекаута спрашивает магазин: «у нас в корзине вот эти товары — они актуальные, цены те же, наличие есть, габариты для расчёта доставки какие?».

public static function basket_check( WP_REST_Request $request ) {
    $body  = $request->get_json_params();
    $items = $body['items'] ?? [];
 
    $checked_items = [];
    $unavailable   = [];
 
    foreach ( $items as $item ) {
        $product = self::find_product( $item['id'] );
 
        if ( ! $product || ! $product->is_in_stock() ) {
            $unavailable[] = [ 'id' => $item['id'], 'reason' => 'out_of_stock' ];
            continue;
        }
 
        $checked_items[] = [
            'id'         => $item['id'],
            'price'      => self::format_money( $product->get_price() ),
            'currency'   => 'RUB',
            'quantity'   => $item['quantity'],
            'available'  => $product->get_stock_quantity() ?? 999,
            'dimensions' => self::get_product_dimensions( $product ),
        ];
    }
 
    return rest_ensure_response([
        'items'       => $checked_items,
        'unavailable' => $unavailable,
    ]);
}

Маппинг товаров — отдельная история. Яндекс присылает id товара, и непонятно, что это: SKU в WC или числовой post_id WooCommerce-продукта. Я сделал двойной поиск:

private static function find_product( $id ) {
    // 1. Пробуем как SKU (артикул)
    $product_id = wc_get_product_id_by_sku( $id );
    if ( $product_id ) {
        return wc_get_product( $product_id );
    }
 
    // 2. Если ID числовой — пробуем как post_id
    if ( is_numeric( $id ) ) {
        $product = wc_get_product( (int) $id );
        if ( $product && $product->exists() ) {
            return $product;
        }
    }
 
    return null;
}

Это гибкое поведение — мерчант может в фиде Яндекса указывать что удобнее: SKU или числовой ID WC-товара. Главное, чтобы при маппинге в обратную сторону всё нашлось.

С габаритами тоже есть нюанс. WooCommerce из коробки даёт поля weight, length, width, height — но многие магазины их не заполняют. А без габаритов Яндекс не сможет посчитать стоимость доставки СДЭК, и заказ не оформится. Я добавил дефолтные значения для товаров без указанных размеров:

private static function get_product_dimensions( $product ) {
    $weight = $product->get_weight() ?: get_option( 'ycpy_default_weight', 0.5 );
    $length = $product->get_length() ?: get_option( 'ycpy_default_length', 15 );
    $width  = $product->get_width()  ?: get_option( 'ycpy_default_width', 10 );
    $height = $product->get_height() ?: get_option( 'ycpy_default_height', 5 );
 
    return [
        'weight' => (float) $weight,
        'length' => (float) $length,
        'width'  => (float) $width,
        'height' => (float) $height,
    ];
}

Дефолты задаются в админке плагина — это компромисс между «магазин обязан заполнить размеры всех товаров» и «давайте просто что-то отдадим, чтобы заказ хотя бы прошёл». Я выбираю «лучше отдать дефолт и предупредить мерчанта», чем «отказать в покупке».


Эндпоинт 3: checkout — создание заказа

Самый ответственный эндпоинт. Это когда Яндекс реально хочет создать у нас в WC новый заказ. И тут самая большая ловушка, на которую я наступил во время разработки.

Ловушка 1: письма «Новый заказ на 0 ₽».

Если просто создавать заказ в WC через wc_create_order() со статусом pending, WooCommerce немедленно срабатывает на хуки order-created и шлёт письмо администратору магазина «Новый заказ #1234 на сумму 0 ₽». Почему 0? Потому что Яндекс ещё не прислал финальную сумму — она будет известна позже, после расчёта доставки и применения промокодов.

Решение — создавать заказ в нестандартном статусе checkout-draft, который WC использует для «висящих» корзин:

public static function create_checkout( WP_REST_Request $request ) {
    $body       = $request->get_json_params();
    $session_id = $body['session_id'] ?? '';
 
    // Идемпотентность: проверяем, не создан ли уже заказ по этой сессии
    $existing = self::find_order_by_session( $session_id );
    if ( $existing ) {
        return rest_ensure_response([
            'order_id' => $existing->get_id(),
            'status'   => 'exists',
        ]);
    }
 
    // Создаём заказ в специальном статусе draft
    $order = wc_create_order( [
        'status' => 'checkout-draft',
    ] );
 
    // Сохраняем session_id для последующего поиска
    $order->update_meta_data( '_ycp_session_id', $session_id );
    $order->update_meta_data( '_ycp_origin', 'yandex' );
 
    // Добавляем товары из запроса
    foreach ( $body['items'] as $item_data ) {
        $product = self::find_product( $item_data['id'] );
        if ( $product ) {
            $order->add_product( $product, $item_data['quantity'] );
        }
    }
 
    // Адрес доставки
    if ( ! empty( $body['delivery_address'] ) ) {
        $order->set_address( self::map_address( $body['delivery_address'] ), 'shipping' );
    }
 
    $order->calculate_totals();
    $order->save();
 
    return rest_ensure_response([
        'order_id'   => $order->get_id(),
        'status'     => 'created',
        'session_id' => $session_id,
    ]);
}

Статус checkout-draft WC не показывает на странице «Заказы» по умолчанию (это технический статус), не шлёт писем, и не считает заказ «реально оформленным». Только когда придёт запрос на placed, мы переведём заказ в pending, и вот тогда WC отработает все стандартные хуки.

Ловушка 2: идемпотентность.

API Яндекса гарантирует at-least-once delivery — то есть запрос может прийти дважды, например если у магазина был timeout на ответ. Если просто создавать заказ каждый раз, получим два дубля одного и того же заказа.

Решение — поиск по session_id перед созданием:

private static function find_order_by_session( $session_id ) {
    if ( empty( $session_id ) ) {
        return null;
    }
 
    // HPOS-совместимый запрос
    $orders = wc_get_orders([
        'limit'      => 1,
        'meta_key'   => '_ycp_session_id',
        'meta_value' => $session_id,
        'status'     => [ 'checkout-draft', 'pending', 'processing', 'on-hold' ],
    ]);
 
    return ! empty( $orders ) ? $orders[0] : null;
}

Тут второй важный момент — HPOS (High-Performance Order Storage). WooCommerce с версии 8.0 поддерживает новое хранилище заказов на основе кастомных таблиц вместо wp_posts. На новых сайтах оно включено по умолчанию. Если писать запросы через прямой SQL к wp_postmeta (как многие старые плагины), на HPOS-сайтах ничего не найдётся.

Поэтому я использую везде высокоуровневые wc_get_orders(), $order->update_meta_data(), $order->get_meta() — они работают в обоих хранилищах. Это требует чуть больше дисциплины, но плагин становится совместимым «навсегда».


Эндпоинт 4: checkout/placed — заказ подтверждён

Когда пользователь нажал «Оплатить» в виджете Яндекса, прилетает placed. Финальная сумма, выбранный способ доставки, статус оплаты — всё уже сформировано на стороне Яндекса. Наша задача — перевести WC-заказ из checkout-draft в нормальный статус.

public static function checkout_placed( WP_REST_Request $request ) {
    $body       = $request->get_json_params();
    $session_id = $body['session_id'] ?? '';
 
    $order = self::find_order_by_session( $session_id );
    if ( ! $order ) {
        return new WP_Error( 'order_not_found', 'No draft order for session', [ 'status' => 404 ] );
    }
 
    // Записываем финальные данные
    $order->update_meta_data( '_ycp_yandex_order_id', $body['yandex_order_id'] );
    $order->update_meta_data( '_ycp_payment_method', $body['payment_method'] );
    $order->set_total( $body['total_amount'] );
 
    // Способ оплаты определяет статус
    if ( $body['payment_status'] === 'paid' ) {
        // Оплата прошла на стороне Яндекса (Яндекс Пэй, Сплит) → processing
        $order->update_status( 'processing', 'Оплачено через Яндекс Чекаут' );
    } else {
        // Наложенный платёж → pending (магазин обработает сам)
        $order->update_status( 'pending', 'Заказ оформлен, оплата при получении' );
    }
 
    return rest_ensure_response([
        'status'   => 'ok',
        'order_id' => $order->get_id(),
    ]);
}

Когда статус заказа переходит из checkout-draft в pending или processing, WooCommerce автоматически отправляет администратору письмо с правильной финальной суммой. Это и есть тот «флоу checkout-draft → pending», который решает проблему пустых писем.


Эндпоинты 5–10: остальная синхронизация

Чтобы не превращать статью в reference manual, остальные эндпоинты опишу кратко:

POST /checkout/cancel — пользователь закрыл виджет, не оформив. Удаляем draft-заказ или помечаем его как cancelled.

POST /checkout/delivery/options — возвращаем варианты доставки. Тут я подтягиваю активные методы из WC Shipping, плюс отдельно поддерживаю «самовывоз из ПВЗ».

GET /checkout/delivery/pickup_points — для магазинов с собственными ПВЗ. Я добавил в админке простой UI «список ПВЗ магазина» с CRUD-операциями.

GET /order — Яндекс синхронизирует статусы. Возвращаем историю изменений статуса заказа.

POST /order/cancel — отмена оформленного заказа. Переводим WC-заказ в cancelled, восстанавливаем сток.

POST /order/delivered — заказ доставлен. Переводим заказ в completed.

Все эти эндпоинты идемпотентны по тому же session_id или yandex_order_id, чтобы повторные запросы не делали ничего вредного.


Логирование: главный инструмент отладки

Когда работаешь с внешним API, без логов жить нельзя. На моей стороне нет способа повторить запрос Яндекса вручную — я могу только реагировать на то, что прилетело. Поэтому логирование критично.

Я не стал тащить файловую запись или БД — для маленьких магазинов это лишний overhead. Сделал лог в WP option с ротацией по последним 100 записям:

class YCP_Logger {
    const OPTION_NAME = 'ycpy_log';
    const MAX_ENTRIES = 100;
 
    public static function log( $type, $endpoint, $request_body, $response, $error = null ) {
        $log = get_option( self::OPTION_NAME, [] );
 
        array_unshift( $log, [
            'timestamp'    => current_time( 'mysql' ),
            'type'         => $type, // REQUEST, RESPONSE, EXCEPTION
            'endpoint'     => $endpoint,
            'request_body' => is_string( $request_body ) 
                              ? $request_body 
                              : wp_json_encode( $request_body ),
            'response'     => is_string( $response ) 
                              ? $response 
                              : wp_json_encode( $response ),
            'error'        => $error,
        ]);
 
        // Оставляем только последние N записей
        if ( count( $log ) > self::MAX_ENTRIES ) {
            $log = array_slice( $log, 0, self::MAX_ENTRIES );
        }
 
        update_option( self::OPTION_NAME, $log, false ); // autoload = false
    }
}

Вызывается перед каждым ответом эндпоинта, плюс из глобального try-catch на случай исключений. В админке плагина — таблица с фильтрацией по типу. Когда мерчант приходит с проблемой «заказ не оформляется», первое, что я прошу — открыть лог и найти запись с типом EXCEPTION. Чаще всего там сразу видно, что прислал Яндекс и где упало.

autoload = false — важный нюанс. По умолчанию WP-опции загружаются вместе с каждым запросом к сайту. Если лог огромный, это замедлит каждую страницу. Отключаем autoload — опция читается только когда явно запрошена.


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

Несколько уроков из разработки.

WC HPOS — это будущее, и игнорировать его нельзя. Если бы я писал плагин «по-старому» через $wpdb->postmeta, он бы не работал у половины современных магазинов. Все обращения к данным заказов — только через WC API.

Идемпотентность с первого дня. Я мог бы добавить проверку на дубли «когда-нибудь потом». В реальности это бы означало пачку дублированных заказов на боевых магазинах в первый же день. Делай идемпотентность с первого коммита.

Локальные дефолты для нестабильных данных. Магазины — это всегда грязные данные. Половина товаров без габаритов, четверть — без артикулов, в каждом десятом — пустые описания. Плагин, который требует «все товары должны быть идеально заполнены», не взлетит. Плагин с разумными дефолтами и понятными warning-ами — взлетит.

Лог в админке важнее любой документации. Когда у мерчанта что-то не работает, никто не будет лезть в FTP смотреть /wp-content/uploads/ycp-logs/. Кнопка «Лог последних запросов» прямо в админке решает 80% запросов в поддержку.

Статус checkout-draft — недооценённая фишка WooCommerce. До этой работы я о нём не знал. Это очень удобно для любых сценариев «отложенного оформления заказа» — корзины с saved later, multi-step checkout, и теперь — внешние чекауты вроде Яндекса.


Что дальше

Плагин сейчас закрывает все 10 эндпоинтов YCP v1 и работает на нескольких боевых магазинах, включая мой. Из планов:

  • Поддержка multi-warehouse (Яндекс это поддерживает, но я пока вынес наружу только один склад)

  • Интеграция с WC Multilingual для магазинов на нескольких языках

  • Bulk-операции по массовому экспорту товаров в Яндекс Товары (сейчас это делается через стандартный YML-фид)

  • Поддержка партиал-оплат и подписочных моделей, если Яндекс их откроет в YCP v2 Если у вас тоже WooCommerce, и вы хотите подключить продажи через Алису AI — попробуйте плагин. GPL-2.0, бесплатно, поддержка по issue в репозитории: github.com/perfinn/YCP-Yandex-Commerce-Woocommerce.

PR и баг-репорты приветствую. Особенно полезно было бы получить фидбек от магазинов с большим ассортиментом (10k+ товаров) — на моих тестовых стендах таких объёмов не было, и где-то могут вылезти узкие места по производительности.


Полезные ссылки: