openapi: 3.1.0
info:
  title: API магазина на CMS «Океано»
  version: 1.0.0
  summary: Товары, остатки, заказы, покупатели и вебхуки магазина.
  description: |
    REST API v1 управляет магазином на «Океано» с вашего сервера: товары и остатки, разделы и бренды, заказы, покупатели.
    О событиях магазина сообщают вебхуки (раздел `webhooks` ниже).

    Страница для разработчиков: https://okeysait.ru/developers

    ## Ключ
    Ключ создаёт владелец в панели магазина: «Настройки» → «API и вебхуки» → «Создать ключ». Вид ключа — `oks_…`,
    показывается один раз. Ключ привязан к одному магазину и к выбранным правам; его можно отозвать в любой момент.
    Передавайте в каждом запросе заголовок `Authorization: Bearer oks_…`.

    **Ключ работает только на сервере.** CORS не включён, секрет в браузере виден всем. Для витрины в браузере есть
    Storefront API без ключа: скрипт `https://<магазин>/embed.js` и `https://<магазин>/ajax/embed.php`
    (каталог, карточка товара, заказ).

    На самом сайте продукта okeysait.ru ключ с правом `orders:write` выдаёт только сотрудник КОН: оплаченный заказ там подключает тариф. Владелец получит `403 staff_only`.

    ## Права ключа
    | Право | Что открывает |
    |---|---|
    | `products:read` | товары, разделы, бренды |
    | `products:write` | создание и правка товаров, остатков, разделов, брендов; удаление товаров |
    | `orders:read` | чтение заказов; в заказе есть контакты покупателя (имя, телефон, почта, адрес) — персональные данные |
    | `orders:write` | создание заказов, смена статуса, оплата, заметка менеджера |
    | `customers:read` | чтение покупателей (персональные данные, отдельное право) |

    `GET /shop` доступен любому действующему ключу.

    ## Лимиты
    | | Бесплатный тариф | Платные тарифы и свои сайты |
    |---|---|---|
    | Ключей | 1 | 10 |
    | Вебхуков | 1 | 20 |
    | Запросов в минуту на ключ | 60 | 600 |

    Сверх лимита — `429 rate_limited` и заголовок `Retry-After` (секунд до новой минуты).

    ## Формат
    - JSON в кодировке UTF-8, поля в `snake_case`.
    - Деньги — строкой с двумя знаками (`"1290.00"`), валюта — `RUB`.
    - Даты — ISO 8601 с поясом (`2026-10-28T09:30:00+00:00`).
    - Списки возвращают `{"data": [...], "next_cursor": "..."}`. Параметры `limit` (1–250, по умолчанию 50) и `cursor`:
      передайте `next_cursor` из ответа, чтобы получить следующую страницу; `null` — страниц больше нет. Порядок — по возрастанию номера.
    - Номера объектов в адресах — целые числа.

    ## Ошибки
    Ответ с ошибкой: `{"error": {"code": "not_found", "message": "Не найдено"}}`. Смотрите `code`, а не `message`: текст может уточняться.

    | Код | HTTP | Значение |
    |---|---|---|
    | `missing_key` | 401 | Нет заголовка `Authorization: Bearer oks_…` |
    | `invalid_key` | 401 | Ключ не найден или отозван |
    | `insufficient_scope` | 403 | У ключа нет права на это действие |
    | `rate_limited` | 429 | Слишком много запросов, повторите через минуту (`Retry-After`) |
    | `not_found` | 404 | Объекта или пути нет |
    | `method_not_allowed` | 405 | Метод для этого пути не поддерживается |
    | `bad_json` | 400 | Тело запроса — не JSON |
    | `bad_cursor` | 400 | Неверный `cursor` |
    | `bad_date` | 400 | Неверная дата в `updated_since`, нужен ISO 8601 |
    | `products_limit` | 409 | Достигнут лимит товаров тарифа |
    | `categories_limit` | 409 | Достигнут лимит разделов тарифа |
    | `name_required` | 422 | Нужно название |
    | `variants_required` | 422 | Нужен хотя бы один вариант (пустой список `variants` не принимается) |
    | `bad_variants` | 422 | Неверный список вариантов или позиций |
    | `variant_not_found` | 422 | Вариант не найден или принадлежит другому товару |
    | `bad_price` | 422 | Цена — число от 0 до 1 000 000 000 |
    | `bad_stock` | 422 | Остаток — целое число от 0 до 1 000 000 000 или `null` |
    | `bad_images` | 422 | Фото — список ссылок `http(s)://…` |
    | `bad_categories` | 422 | Неверный список разделов, или раздела нет |
    | `bad_brand` | 422 | Нет такого бренда |
    | `bad_parent` | 422 | Родительский раздел не найден или создаёт цикл |
    | `items_required` | 422 | Нужен непустой список `items` |
    | `too_many_items` | 422 | Слишком много позиций за один запрос (остатки — 500, заказ — 100) |
    | `customer_required` | 422 | Нужно имя, телефон или почта покупателя |
    | `bad_email` | 422 | Неверная почта |
    | `bad_quantity` | 422 | Количество — целое число от 1 до 9999 |
    | `bad_status` | 422 | Нет такого статуса заказа |
    | `unpay_not_supported` | 422 | Снять отметку оплаты через API нельзя |
    | `insufficient_stock` | 409 | Не хватает товара на складе для оплаты заказа; ничего не создано и не изменено |
    | `idempotency_mismatch` | 422 | Этот `Idempotency-Key` уже использован с другим телом запроса |
    | `idempotency_conflict` | 409 | Запрос с этим `Idempotency-Key` ещё выполняется |
    | `order_failed` | 422, 500 | Не удалось создать заказ; частично созданного заказа не остаётся |
    | `brand_failed` | 500 | Не удалось создать бренд |
    | `internal_error` | 500 | Внутренняя ошибка, попробуйте позже |
    | `site_unavailable` | 503 | Магазин недоступен |
    | `service_unavailable` | 503 | Сервис временно недоступен |

    Адрес вебхука проверяется при сохранении в панели. Там возможны ещё пять кодов: `bad_url` (неверный адрес),
    `https_required` (только `https://`), `bad_port` (порт 443 или 8443), `host_not_found` (адреса нет в DNS),
    `private_address` (внутренние адреса запрещены).

    ## Вебхуки
    Подпишитесь на события в панели («Настройки» → «API и вебхуки»), и магазин сам отправит POST на ваш https-адрес.
    Подробности — в разделе `webhooks` ниже и на странице для разработчиков.
  contact:
    name: Океано
    url: https://okeysait.ru/developers
servers:
  - url: https://api.okeysait.ru/v1
    description: API v1
security:
  - bearerAuth: []
tags:
  - name: Магазин
    description: Проверка ключа и данные магазина.
  - name: Товары
    description: Товары с вариантами (цена, остаток, артикул), разделами и фото.
  - name: Остатки
    description: Остатки и цены пачкой по артикулу.
  - name: Разделы и бренды
    description: Справочники каталога. Удаление — только в панели.
  - name: Заказы
    description: Заказы, создание с защитой от дублей, оплата и статусы.
  - name: Покупатели
    description: Зарегистрированные покупатели, только чтение.
  - name: Вебхуки
    description: События магазина, которые мы отправляем на ваш адрес (POST с подписью).
paths:
  /shop:
    get:
      tags: [Магазин]
      operationId: getShop
      summary: Магазин и права ключа
      description: Название, адрес, тариф и права ключа. Годится как проверка ключа. Нужен любой действующий ключ.
      responses:
        '200':
          description: Магазин.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Shop'
              example:
                site: myshop
                name: Мой магазин
                host: myshop.okeysait.ru
                url: https://myshop.okeysait.ru
                currency: RUB
                plan: free
                limits:
                  requests_per_minute: 60
                key:
                  name: Склад
                  scopes: ["products:read", "products:write"]
                scopes: ["products:read", "products:write"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
  /products:
    get:
      tags: [Товары]
      operationId: listProducts
      summary: Список товаров
      description: |
        Право `products:read`. Страницы по возрастанию номера. Чтобы забрать только изменения, передавайте `updated_since`
        со временем прошлой выгрузки.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/UpdatedSince'
        - name: category_id
          in: query
          description: Только товары этого раздела.
          schema: {type: integer}
        - name: sku
          in: query
          description: Только товары с вариантом, у которого этот артикул.
          schema: {type: string}
        - name: visible
          in: query
          description: Только видимые (`1`, `true`) или скрытые (`0`, `false`).
          schema: {type: string, enum: ['0', '1', 'true', 'false']}
      responses:
        '200':
          description: Страница товаров.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListOfProduct'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [Товары]
      operationId: createProduct
      summary: Создать товар
      description: |
        Право `products:write`. Нужны `name` и хотя бы один вариант. Артикул, цена и остаток — у варианта.
        Фото передаются ссылками, магазин скачивает их сам.
        Когда лимит товаров тарифа исчерпан — `409 products_limit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductInput'
            example:
              name: Свеча «Лес»
              visible: true
              category_ids: [3]
              images: ['https://example.com/forest.jpg']
              variants:
                - {sku: CANDLE-F, price: '1290.00', stock: 25}
      responses:
        '201':
          description: Созданный товар.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
  /products/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Товары]
      operationId: getProduct
      summary: Товар
      description: Право `products:read`.
      responses:
        '200':
          description: Товар.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      tags: [Товары]
      operationId: updateProduct
      summary: Изменить товар
      description: |
        Право `products:write`. Передайте только те поля, которые меняются.

        Списки заменяют прежние целиком:
        - `variants` — вариант с `id` обновляется (только переданные поля), вариант без `id` создаётся, варианты,
          которых нет в списке, удаляются. Пустой список `[]` не принимается (`variants_required`).
        - `category_ids` — товар остаётся ровно в этих разделах; `[]` убирает из всех.
        - `images` — остаются фото с этими ссылками, остальные удаляются, новые добавляются; `[]` убирает все.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductInput'
            example:
              visible: false
              variants:
                - {id: 77, price: '1190.00', stock: 20}
      responses:
        '200':
          description: Товар после изменения.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags: [Товары]
      operationId: deleteProduct
      summary: Удалить товар
      description: Право `products:write`. Удаление необратимо. Приходит событие `product.deleted`.
      responses:
        '200':
          description: Товар удалён.
          content:
            application/json:
              schema:
                type: object
                required: [deleted, id]
                properties:
                  deleted: {type: boolean, const: true}
                  id: {type: integer}
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /stock:
    patch:
      tags: [Остатки]
      operationId: updateStock
      summary: Остатки и цены пачкой
      description: |
        Право `products:write`. До 500 позиций за запрос; каждая находит варианты по артикулу и меняет `stock`, `price`
        и `compare_price` (только переданные поля). Если один артикул у нескольких вариантов, обновятся все.
        Ответ — результат по каждой позиции в том же порядке; артикул, которого нет, не мешает остальным.
        Неверное значение (`bad_price`, `bad_stock`) отклоняет весь запрос: ничего не изменится.

        `stock: null` — склад не ведётся, количество не ограничено.

        Большая пачка (больше 200 изменений) даёт один вебхук `catalog.imported` вместо события на каждый товар.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    $ref: '#/components/schemas/StockItem'
            example:
              items:
                - {sku: CANDLE-F, stock: 18}
                - {sku: CANDLE-S, price: '990.00', compare_price: '1290.00'}
      responses:
        '200':
          description: Результат по каждой позиции.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/StockResult'
              example:
                data:
                  - {sku: CANDLE-F, result: updated, variants: 1}
                  - {sku: CANDLE-S, result: not_found}
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
  /categories:
    get:
      tags: [Разделы и бренды]
      operationId: listCategories
      summary: Список разделов
      description: Право `products:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Страница разделов.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListOfCategory'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [Разделы и бренды]
      operationId: createCategory
      summary: Создать раздел
      description: |
        Право `products:write`. Нужно `name`. `slug` — собственная часть адреса раздела, например `svechi`;
        полный адрес с родителями (`/catalog/odezhda/svechi`) магазин строит сам. Без `slug` он образуется из названия
        латиницей; занятый `slug` получает числовой хвост.
        Родитель не найден — `422 bad_parent`. Лимит разделов тарифа исчерпан — `409 categories_limit`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoryInput'
            example: {name: Свечи, parent_id: null, slug: svechi}
      responses:
        '201':
          description: Созданный раздел.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Category'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
  /categories/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Разделы и бренды]
      operationId: getCategory
      summary: Раздел
      description: Право `products:read`.
      responses:
        '200':
          description: Раздел.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Category'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      tags: [Разделы и бренды]
      operationId: updateCategory
      summary: Изменить раздел
      description: |
        Право `products:write`. Родитель, который сам раздел или его потомок, — `422 bad_parent`.
        Удаления нет: у раздела могут быть товары, удаляйте в панели.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoryInput'
      responses:
        '200':
          description: Раздел после изменения.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Category'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
  /brands:
    get:
      tags: [Разделы и бренды]
      operationId: listBrands
      summary: Список брендов
      description: Право `products:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Страница брендов.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListOfBrand'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [Разделы и бренды]
      operationId: createBrand
      summary: Создать бренд
      description: Право `products:write`. Нужно `name`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BrandInput'
            example: {name: Лесная мануфактура}
      responses:
        '201':
          description: Созданный бренд.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Brand'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
  /brands/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Разделы и бренды]
      operationId: getBrand
      summary: Бренд
      description: Право `products:read`.
      responses:
        '200':
          description: Бренд.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Brand'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      tags: [Разделы и бренды]
      operationId: updateBrand
      summary: Изменить бренд
      description: Право `products:write`. Удаления нет — только в панели.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BrandInput'
      responses:
        '200':
          description: Бренд после изменения.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Brand'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
  /orders:
    get:
      tags: [Заказы]
      operationId: listOrders
      summary: Список заказов
      description: |
        Право `orders:read`. Чтобы забирать новые и изменённые заказы, храните время последнего опроса и передавайте его
        в `updated_since`. Заказ попадает в выборку и при смене статуса, оплате, правке заметки.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/UpdatedSince'
        - name: status
          in: query
          description: Только заказы с этим номером статуса.
          schema: {type: integer}
        - name: paid
          in: query
          description: Только оплаченные (`1`, `true`) или неоплаченные (`0`, `false`).
          schema: {type: string, enum: ['0', '1', 'true', 'false']}
      responses:
        '200':
          description: Страница заказов.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListOfOrder'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags: [Заказы]
      operationId: createOrder
      summary: Создать заказ
      description: |
        Право `orders:write`. Позиции — по `variant_id` или по `sku`. Нужно имя, телефон или почта покупателя.

        **Повтор без дублей.** Передайте заголовок `Idempotency-Key` (до 200 символов, ключ помнится 24 часа).
        Повторный запрос с тем же ключом и тем же телом вернёт сохранённый ответ с заголовком `Idempotent-Replayed: true`
        и не создаст второй заказ. Тот же ключ с другим телом — `422 idempotency_mismatch`; запрос, пока первый ещё
        выполняется, — `409 idempotency_conflict` (повторите чуть позже). Ответ с ошибкой не запоминается: после исправления
        можно повторить с тем же ключом.

        **Оплата.** `paid: true` создаёт заказ сразу оплаченным и списывает остатки. Если товара не хватает —
        `409 insufficient_stock`, ничего не создано.

        **Уведомления.** По умолчанию владелец магазина получает обычное письмо и сообщение о новом заказе, покупатель
        не получает ничего. Управляйте объектом `notify`: `{"admin": false, "customer": true}`.
        Письмо покупателю уходит, только если указана его почта.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: 'Уникальная строка на каждый заказ (например, номер заказа во внешней системе). Значимы первые 200 символов.'
          schema: {type: string}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderInput'
            example:
              customer: {name: Анна Лесная, phone: '+79990001122', email: anna@example.com, address: 'Москва, ул. Лесная, 1'}
              items:
                - {sku: CANDLE-F, quantity: 2}
              comment: Позвоните за час
              paid: false
              notify: {admin: true, customer: false}
      responses:
        '201':
          description: 'Созданный заказ. Повтор с тем же `Idempotency-Key` возвращает тот же ответ и заголовок `Idempotent-Replayed: true`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
  /orders/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Заказы]
      operationId: getOrder
      summary: Заказ
      description: Право `orders:read`.
      responses:
        '200':
          description: Заказ.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      tags: [Заказы]
      operationId: updateOrder
      summary: Изменить заказ
      description: |
        Право `orders:write`. Можно сменить статус (`status_id`), отметить оплату (`paid: true`), записать заметку менеджера
        и комментарий.

        Оплата закрывает заказ и списывает остатки, как оплата на сайте. Не хватает товара — `409 insufficient_stock`,
        в заказе ничего не меняется. Снять отметку оплаты нельзя (`paid: false` — `422 unpay_not_supported`).
        Повторная отметка уже оплаченного заказа ничего не делает.
        Номера статусов — в панели магазина, в разделе заказов; неизвестный номер — `422 bad_status`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderUpdate'
            example: {status_id: 2, manager_note: 'Отправлено, трек 123456'}
      responses:
        '200':
          description: Заказ после изменения.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
  /customers:
    get:
      tags: [Покупатели]
      operationId: listCustomers
      summary: Список покупателей
      description: Право `customers:read`. Зарегистрированные покупатели магазина.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Страница покупателей.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListOfCustomer'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
  /customers/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Покупатели]
      operationId: getCustomer
      summary: Покупатель
      description: Право `customers:read`.
      responses:
        '200':
          description: Покупатель.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Customer'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
webhooks:
  order.created:
    post:
      tags: [Вебхуки]
      operationId: eventOrderCreated
      summary: Создан заказ
      description: Новый заказ с сайта, через embed.js или API. В `data` — заказ целиком, с позициями.
      requestBody:
        $ref: '#/components/requestBodies/EventOrder'
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
  order.paid:
    post:
      tags: [Вебхуки]
      operationId: eventOrderPaid
      summary: Заказ оплачен
      description: |
        Заказ отмечен оплаченным (оплата на сайте, из панели или через API). При оплате `order.paid` приходит всегда;
        вместе с ним могут прийти `order.updated` и `order.status_changed` (например, при оплате картой или через Robokassa) —
        отсекайте повторы по id заказа и `updated_at`.
      requestBody:
        $ref: '#/components/requestBodies/EventOrder'
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
  order.status_changed:
    post:
      tags: [Вебхуки]
      operationId: eventOrderStatusChanged
      summary: Сменился статус заказа
      description: Новый статус — в `data.status`. Вместе с ним приходит и `order.updated`; при оплате (например, через Robokassa) он может прийти вместе с `order.paid`.
      requestBody:
        $ref: '#/components/requestBodies/EventOrder'
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
  order.updated:
    post:
      tags: [Вебхуки]
      operationId: eventOrderUpdated
      summary: Заказ изменён
      description: |
        Изменились данные заказа: статус, покупатель, адрес, заметка, состав и другое. Если вы подписаны на `order.created`,
        для заказа, созданного в том же запросе, `order.updated` не приходит.
        **При смене статуса приходят оба события — `order.status_changed` и `order.updated`; при оплате к `order.paid` могут добавиться
        `order.updated` и `order.status_changed`. Отсекайте повторы по id заказа и `updated_at`.**
      requestBody:
        $ref: '#/components/requestBodies/EventOrder'
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
  product.created:
    post:
      tags: [Вебхуки]
      operationId: eventProductCreated
      summary: Создан товар
      description: В `data` — товар целиком, как в `GET /products/{id}`.
      requestBody:
        $ref: '#/components/requestBodies/EventProduct'
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
  product.updated:
    post:
      tags: [Вебхуки]
      operationId: eventProductUpdated
      summary: Изменён товар
      description: |
        Изменились поля товара, его варианты, разделы или фото. Несколько правок одного товара за один запрос дают одно событие.
        Если товар к моменту отправки уже удалён, в `data` будет `{"id": 42, "deleted": true}`.
      requestBody:
        $ref: '#/components/requestBodies/EventProduct'
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
  product.deleted:
    post:
      tags: [Вебхуки]
      operationId: eventProductDeleted
      summary: Удалён товар
      description: В `data` — номер и артикулы удалённого товара.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: {const: product.deleted}
                    data:
                      type: object
                      required: [id]
                      properties:
                        id: {type: integer}
                        skus:
                          type: array
                          items: {type: string}
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
  stock.changed:
    post:
      tags: [Вебхуки]
      operationId: eventStockChanged
      summary: Изменились остатки или цены
      description: |
        Изменились остаток, цена или старая цена вариантов. Изменения за один запрос собираются
        в одно событие. В `data.items` — текущее состояние затронутых вариантов, не больше 200:
        если изменений за запрос больше 200, вместо `stock.changed` приходит один `catalog.imported`.
        Поле `truncated` оставлено для совместимости и всегда `false`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: {const: stock.changed}
                    data:
                      $ref: '#/components/schemas/StockChangedData'
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
  catalog.imported:
    post:
      tags: [Вебхуки]
      operationId: eventCatalogImported
      summary: Массовое изменение каталога
      description: |
        Больше 200 изменений товаров и остатков за одну операцию либо загрузка файлом: вместо события на каждый товар приходит
        одно `catalog.imported` со счётчиками. Получив его, перечитайте каталог через `GET /products?updated_since=…`.
        При подписке на события товаров и остатков это событие добавляется автоматически.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: {const: catalog.imported}
                    data:
                      $ref: '#/components/schemas/CatalogImportedData'
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
  customer.created:
    post:
      tags: [Вебхуки]
      operationId: eventCustomerCreated
      summary: Зарегистрирован покупатель
      description: 'В `data` — покупатель, как в `GET /customers/{id}`. Персональные данные: подписывайтесь, только если они нужны.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: {const: customer.created}
                    data:
                      $ref: '#/components/schemas/Customer'
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
  feedback.created:
    post:
      tags: [Вебхуки]
      operationId: eventFeedbackCreated
      summary: Новое сообщение из формы обратной связи
      description: Посетитель написал через форму магазина.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: {const: feedback.created}
                    data:
                      $ref: '#/components/schemas/Feedback'
      responses:
        '2XX':
          $ref: '#/components/responses/EventAccepted'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: oks_…
      description: Ключ `oks_…` из панели магазина («Настройки» → «API и вебхуки»). Только с сервера, не из браузера.
  parameters:
    Id:
      name: id
      in: path
      required: true
      description: Номер объекта.
      schema: {type: integer, minimum: 1}
    Limit:
      name: limit
      in: query
      description: Сколько объектов вернуть, от 1 до 250. По умолчанию 50.
      schema: {type: integer, minimum: 1, maximum: 250, default: 50}
    Cursor:
      name: cursor
      in: query
      description: Значение `next_cursor` из прошлого ответа.
      schema: {type: string}
    UpdatedSince:
      name: updated_since
      in: query
      description: 'Только объекты, изменённые не раньше этого времени. ISO 8601, например `2026-10-28T00:00:00Z` или `2026-10-28T00:00:00+03:00`. В адресе запроса знак `+` кодируйте как `%2B` (иначе он станет пробелом) либо пишите время в UTC с буквой `Z`.'
      schema: {type: string, format: date-time}
  requestBodies:
    EventOrder:
      required: true
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Event'
              - type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Order'
    EventProduct:
      required: true
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Event'
              - type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Product'
  responses:
    EventAccepted:
      description: |
        Любой ответ с кодом 2xx в течение 10 секунд считается успешной доставкой. Остальное — неудача:
        доставка повторится (через 1 мин, 5 мин, 30 мин, 2 ч, 6 ч и 12 ч; всего 7 попыток). Редиректы не выполняются.
    Unauthorized:
      description: Нет ключа или он недействителен (`missing_key`, `invalid_key`).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: {code: invalid_key, message: Ключ не найден или отозван}}
    Forbidden:
      description: У ключа нет нужного права (`insufficient_scope`).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: {code: insufficient_scope, message: У ключа нет права на это действие}}
    NotFound:
      description: Объекта нет (`not_found`).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: {code: not_found, message: Не найдено}}
    BadRequest:
      description: Тело — не JSON (`bad_json`), неверный `cursor` (`bad_cursor`) или дата (`bad_date`).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: {code: bad_cursor, message: Неверный cursor}}
    Conflict:
      description: |
        Конфликт: `products_limit`, `categories_limit` (лимит тарифа), `insufficient_stock` (не хватает товара для оплаты),
        `idempotency_conflict` (такой же запрос ещё выполняется).
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: {code: insufficient_stock, message: Не хватает товара на складе для оплаты заказа}}
    Unprocessable:
      description: |
        Данные не прошли проверку. Код ошибки называет причину: `name_required`, `variants_required`, `bad_variants`,
        `variant_not_found`, `bad_price`, `bad_stock`, `bad_images`, `bad_categories`, `bad_brand`, `bad_parent`,
        `items_required`, `too_many_items`, `customer_required`, `bad_email`, `bad_quantity`, `bad_status`,
        `unpay_not_supported`, `idempotency_mismatch`, `order_failed`.
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: {code: bad_price, message: Цена должна быть числом}}
    RateLimited:
      description: Превышен лимит запросов ключа (`rate_limited`).
      headers:
        Retry-After:
          description: Через сколько секунд можно повторить.
          schema: {type: integer}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example: {error: {code: rate_limited, message: 'Слишком много запросов, повторите через минуту'}}
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: {type: string, description: 'Код ошибки для программы, список в описании API.'}
            message: {type: string, description: Пояснение на русском для человека.}
    Money:
      type: string
      pattern: '^\d+\.\d{2}$'
      examples: ['1290.00']
      description: Сумма строкой с двумя знаками, в рублях.
    Date:
      type: [string, 'null']
      format: date-time
      description: Дата и время ISO 8601 с поясом; `null`, если даты нет.
    Shop:
      type: object
      required: [site, name, host, url, currency, plan, limits, key, scopes]
      properties:
        site: {type: string, description: Внутренний ключ магазина.}
        name: {type: string}
        host: {type: string, description: Основной адрес магазина.}
        url: {type: string, format: uri}
        currency: {type: string, const: RUB}
        plan: {type: string, enum: [free, paid], description: Бесплатный или платный тариф (свои сайты считаются платными).}
        limits:
          type: object
          properties:
            requests_per_minute: {type: [integer, 'null'], description: 'Запросов в минуту на ключ (`null` — без лимита).'}
        key:
          type: object
          properties:
            name: {type: string}
            scopes:
              type: array
              items: {$ref: '#/components/schemas/Scope'}
        scopes:
          type: array
          items: {$ref: '#/components/schemas/Scope'}
    Scope:
      type: string
      enum: ["products:read", "products:write", "orders:read", "orders:write", "customers:read"]
    Variant:
      type: object
      required: [id, sku, name, price, compare_price, stock]
      properties:
        id: {type: integer}
        sku: {type: string, description: Артикул.}
        name: {type: string, description: 'Название варианта («Красный, 50 мл»); у товара без вариантов пусто.'}
        price: {$ref: '#/components/schemas/Money'}
        compare_price:
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: 'null'
          description: Старая цена (зачёркнутая); `null`, если не задана.
        stock:
          type: [integer, 'null']
          description: Остаток. `null` — склад не ведётся, количество не ограничено.
    VariantInput:
      type: object
      description: |
        Вариант в запросе. С `id` — обновление существующего варианта этого товара, без `id` — новый.
        Передавайте только меняемые поля. Цена нового варианта по умолчанию 0.
      properties:
        id: {type: integer}
        sku: {type: string, maxLength: 100}
        name: {type: string, maxLength: 255}
        price:
          description: Число или строка, от 0 до 1 000 000 000.
          type: [string, number]
        compare_price:
          description: Старая цена; `null` или 0 убирают её.
          type: [string, number, 'null']
        stock:
          description: Целое от 0 до 1 000 000 000; `null` — склад не ведётся.
          type: [integer, 'null']
          minimum: 0
          maximum: 1000000000
    Product:
      type: object
      required: [id, name, slug, url, visible, featured, brand_id, annotation, body, category_ids, images, variants, currency, created_at, updated_at]
      properties:
        id: {type: integer}
        name: {type: string}
        slug: {type: string, description: Часть адреса товара.}
        url: {type: string, format: uri, description: Адрес товара на витрине.}
        visible: {type: boolean}
        featured: {type: boolean, description: Отмечен как рекомендуемый.}
        brand_id: {type: [integer, 'null']}
        annotation: {type: string, description: Краткое описание.}
        body: {type: string, description: Полное описание (HTML).}
        category_ids:
          type: array
          items: {type: integer}
        images:
          type: array
          description: Ссылки на фото, первое — главное.
          items: {type: string, format: uri}
        variants:
          type: array
          items: {$ref: '#/components/schemas/Variant'}
        currency: {type: string, const: RUB}
        created_at: {$ref: '#/components/schemas/Date'}
        updated_at: {$ref: '#/components/schemas/Date'}
    ProductInput:
      type: object
      description: При создании обязательны `name` и `variants`. При изменении — только меняемые поля.
      properties:
        name: {type: string}
        slug: {type: string}
        visible: {type: boolean, default: true}
        featured: {type: boolean}
        brand_id: {type: [integer, 'null'], description: Номер бренда; `null` или 0 — без бренда.}
        annotation: {type: string}
        body: {type: string}
        category_ids:
          type: array
          description: Разделы товара. Заменяет прежний список.
          items: {type: integer}
        images:
          type: array
          description: Ссылки `http(s)://…`. Заменяет прежний список фото.
          items: {type: string, format: uri}
        variants:
          type: array
          description: Варианты. Заменяет прежний список целиком; пустой список не принимается.
          minItems: 1
          items: {$ref: '#/components/schemas/VariantInput'}
    StockItem:
      type: object
      required: [sku]
      properties:
        sku: {type: string}
        stock: {type: [integer, 'null'], minimum: 0, maximum: 1000000000, description: 'Новый остаток; `null` — склад не ведётся.'}
        price: {type: [string, number]}
        compare_price: {type: [string, number, 'null']}
    StockResult:
      type: object
      required: [sku, result]
      properties:
        sku: {type: string}
        result:
          type: string
          enum: [updated, not_found, bad_sku, nothing_to_update]
          description: |
            `updated` — обновлено; `not_found` — артикула нет в магазине; `bad_sku` — артикул пустой;
            `nothing_to_update` — не передано ни `stock`, ни `price`, ни `compare_price`.
        variants: {type: integer, description: 'Сколько вариантов обновлено (только при `updated`).'}
    Category:
      type: object
      required: [id, parent_id, name, slug, visible, position]
      properties:
        id: {type: integer}
        parent_id: {type: [integer, 'null'], description: Родительский раздел; `null` — корневой.}
        name: {type: string}
        slug: {type: string, description: 'Собственная часть адреса раздела, без пути родителей.'}
        visible: {type: boolean}
        position: {type: integer}
    CategoryInput:
      type: object
      properties:
        name: {type: string, maxLength: 255}
        slug: {type: string}
        parent_id: {type: [integer, 'null'], description: 'Родительский раздел; `null` или 0 — корневой.'}
        visible: {type: boolean, default: true}
        position: {type: integer}
    Brand:
      type: object
      required: [id, name, slug, visible]
      properties:
        id: {type: integer}
        name: {type: string}
        slug: {type: string}
        visible: {type: boolean}
    BrandInput:
      type: object
      properties:
        name: {type: string, maxLength: 255}
        slug: {type: string}
        visible: {type: boolean, default: true}
    OrderItem:
      type: object
      required: [product_id, variant_id, sku, name, price, quantity, total]
      properties:
        product_id: {type: [integer, 'null']}
        variant_id: {type: [integer, 'null']}
        sku: {type: string}
        name: {type: string, description: Название товара и варианта.}
        price: {$ref: '#/components/schemas/Money'}
        quantity: {type: integer}
        total: {$ref: '#/components/schemas/Money'}
    Order:
      type: object
      required: [id, number, status, paid, paid_at, customer, comment, manager_note, items, delivery_price, discount_percent, coupon_code, total, currency, created_at, updated_at]
      properties:
        id: {type: integer}
        number: {type: string, description: Номер заказа для покупателя.}
        status:
          type: object
          properties:
            id: {type: integer}
            name: {type: string}
        paid: {type: boolean}
        paid_at: {$ref: '#/components/schemas/Date'}
        customer:
          type: object
          properties:
            user_id: {type: [integer, 'null'], description: 'Номер зарегистрированного покупателя, если заказ сделан после входа.'}
            name: {type: string}
            email: {type: string}
            phone: {type: string}
            address: {type: string}
        comment: {type: string, description: Комментарий покупателя.}
        manager_note: {type: string, description: 'Заметка менеджера, покупателю не видна.'}
        items:
          type: array
          items: {$ref: '#/components/schemas/OrderItem'}
        delivery_price: {$ref: '#/components/schemas/Money'}
        discount_percent: {type: number}
        coupon_code: {type: string}
        total: {$ref: '#/components/schemas/Money'}
        currency: {type: string, const: RUB}
        created_at: {$ref: '#/components/schemas/Date'}
        updated_at: {$ref: '#/components/schemas/Date'}
    OrderInput:
      type: object
      required: [customer, items]
      properties:
        customer:
          type: object
          description: Нужно хотя бы одно из полей `name`, `phone`, `email`.
          properties:
            name: {type: string, maxLength: 255}
            phone: {type: string, maxLength: 60}
            email: {type: string, format: email}
            address: {type: string, maxLength: 500}
        items:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: object
            description: 'Вариант указывается через `variant_id` или `sku`.'
            properties:
              variant_id: {type: integer}
              sku: {type: string}
              quantity: {type: integer, minimum: 1, maximum: 9999, default: 1}
              price:
                description: Цена позиции, если она отличается от цены в каталоге.
                type: [string, number]
        comment: {type: string, maxLength: 2000}
        delivery_id: {type: integer, description: Номер способа доставки из панели.}
        payment_method_id: {type: integer, description: Номер способа оплаты из панели.}
        paid:
          type: boolean
          default: false
          description: Создать заказ сразу оплаченным. Нужен остаток; иначе `409 insufficient_stock`.
        notify:
          type: object
          description: Кого уведомить о заказе.
          properties:
            admin:
              type: boolean
              default: true
              description: Письмо и сообщение владельцу магазина, как о заказе с сайта.
            customer:
              type: boolean
              default: false
              description: Письмо покупателю (если указана его почта).
    OrderUpdate:
      type: object
      properties:
        status_id: {type: integer, description: Номер статуса из панели.}
        paid: {type: boolean, description: Только `true`. Снять оплату нельзя.}
        manager_note: {type: string, maxLength: 2000}
        comment: {type: string, maxLength: 2000}
    Customer:
      type: object
      required: [id, name, email, phone, enabled, created_at]
      properties:
        id: {type: integer}
        name: {type: string}
        email: {type: string}
        phone: {type: string}
        enabled: {type: boolean, description: Аккаунт не заблокирован.}
        created_at: {$ref: '#/components/schemas/Date'}
    Feedback:
      type: object
      required: [id, name, email, phone, message, created_at]
      properties:
        id: {type: integer}
        name: {type: string}
        email: {type: string}
        phone: {type: string}
        message: {type: string}
        created_at: {$ref: '#/components/schemas/Date'}
    ListOfProduct:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Product'}
        next_cursor: {type: [string, 'null'], description: Передайте в `cursor` для следующей страницы; `null` — это последняя.}
    ListOfCategory:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Category'}
        next_cursor: {type: [string, 'null']}
    ListOfBrand:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Brand'}
        next_cursor: {type: [string, 'null']}
    ListOfOrder:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Order'}
        next_cursor: {type: [string, 'null']}
    ListOfCustomer:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: {$ref: '#/components/schemas/Customer'}
        next_cursor: {type: [string, 'null']}
    Event:
      type: object
      description: |
        Тело вебхука. Заголовки запроса: `X-Okeano-Event` (тип события), `X-Okeano-Delivery` (номер доставки),
        `X-Okeano-Signature` (подпись `t=<unix>,v1=<hex>`, HMAC-SHA256 от строки `<t>.<тело>` с секретом `whsec_…`).
        Доставка «хотя бы один раз»: одно событие может прийти повторно, различайте по `id`. Порядок доставки не гарантируется.
      required: [id, type, created_at, shop, data]
      properties:
        id: {type: string, examples: [evt_1042], description: Номер события. Одинаков у всех повторов; по нему отбрасывайте дубли.}
        type:
          type: string
          enum: [order.created, order.paid, order.status_changed, order.updated, product.created, product.updated, product.deleted, stock.changed, catalog.imported, customer.created, feedback.created, ping]
          description: '`ping` — проверочное событие из панели, `data` у него `{"message": "…"}`.'
        created_at: {type: string, format: date-time}
        shop:
          type: object
          properties:
            site: {type: string}
            host: {type: string}
        data:
          type: object
          description: Объект в том же виде, что в API (у `ping`, `stock.changed`, `catalog.imported`, `product.deleted` — свой состав).
    StockChangedData:
      type: object
      required: [items, truncated]
      properties:
        items:
          type: array
          maxItems: 200
          items:
            type: object
            properties:
              variant_id: {type: integer}
              product_id: {type: integer}
              id: {type: integer, description: 'То же, что `variant_id`.'}
              sku: {type: string}
              name: {type: string, description: Всегда пусто.}
              price: {$ref: '#/components/schemas/Money'}
              compare_price:
                oneOf:
                  - $ref: '#/components/schemas/Money'
                  - type: 'null'
              stock: {type: [integer, 'null']}
        truncated: {type: boolean, description: 'Всегда `false` (поле оставлено для совместимости): больше 200 изменений за запрос приходят одним `catalog.imported`.'}
    CatalogImportedData:
      type: object
      required: [source, counts]
      properties:
        source: {type: string, description: 'Откуда изменения. `import-file` — загрузка файлом, иначе имя точки входа.'}
        counts:
          type: object
          properties:
            created: {type: integer}
            updated: {type: integer}
            deleted: {type: integer}
            stock: {type: integer, description: Изменений остатков.}
