Как я написал плагин для WooCommerce под Yandex YCP или как купить в 1 клик из Алисы

от автора

Разбираю архитектуру интеграции магазина на 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+ товаров) — на моих тестовых стендах таких объёмов не было, и где-то могут вылезти узкие места по производительности.


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

ссылка на оригинал статьи https://habr.com/ru/articles/1037654/