SearchPlus

API SearchPlus

Для разработчиков магазинов и приложений: каталог и заказы передаются с сервера магазина, поиск, подсказки и помощник доступны со своей страницы результатов или из приложения. Витрина на сайте — та же строка скрипта, что у фида. API работает на тарифах Старт и Про.

Начало

  1. В панели: «Добавить сайт» → «API и скрипт». Ключ покажем один раз; перевыпустить его можно в «Подключении».
  2. Передайте каталог: разделы, затем товары; дальше — изменения по мере их появления и полная выгрузка раз в сутки.
  3. Вставьте строку скрипта на все страницы сайта — или подключите поиск с сервера к своей выдаче и приложению.
  4. Передавайте заказы, оплаты и отмены — для доли выручки, хитов продаж и воронки.

Ключ API и его перевыпуск, список IP, регионы и строка скрипта — в панели SearchPlus: сайт → «Настройки» → «Подключение».

Приложение и свой сервер

Ключ API — секрет магазина: с ним можно заменить каталог и тратить лимиты тарифа. Поэтому он живёт только на сервере: не в мобильном приложении, не в JavaScript страницы и не в репозитории. Ключ из приложения достаётся разбором сборки за минуты.

Приложение ──► ваш сервер (ключ) ──► https://searchplus.ru/api/v1
     ◄── товары с id вашего каталога ◄──
// Небольшой сервер между приложением и SearchPlus: ключ живёт только здесь.
// npm i express && SEARCHPLUS_KEY=rk_… node proxy.mjs
import express from 'express';
import { Readable } from 'node:stream';

const BASE = 'https://searchplus.ru/api/v1';
const app = express();
app.use(express.json({ limit: '64kb' }));
app.set('trust proxy', true);   // req.ip — адрес покупателя, если сервер за балансировщиком

for (const method of ['search', 'suggest', 'assistant', 'clicks']) {
  app.post(`/api/${method}`, async (req, res) => {
    // здесь же проверьте сессию пользователя приложения, если она есть
    const body = method === 'clicks' ? req.body : { ...req.body, visitor: { ip: req.ip } };
    try {
      const upstream = await fetch(BASE + '/' + method, {
        method: 'POST',
        headers: { Authorization: `Bearer ${process.env.SEARCHPLUS_KEY}`, 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(method === 'assistant' ? 90000 : 5000),
      });
      res.status(upstream.status);
      res.set('Content-Type', upstream.headers.get('content-type') ?? 'application/json');
      res.set('X-Accel-Buffering', 'no');   // nginx не копит поток помощника
      Readable.fromWeb(upstream.body).pipe(res);
    } catch {
      res.status(503).json({ error: { code: 'unavailable', message: 'Поиск временно недоступен' } });
    }
  });
}

app.listen(3000);
<?php
// proxy.php?method=search — сервер между приложением и SearchPlus: ключ живёт только здесь
$method = $_GET['method'] ?? '';
if (!in_array($method, ['search', 'suggest', 'assistant', 'clicks'], true)) {
    http_response_code(404);
    exit;
}
$body = json_decode(file_get_contents('php://input'), true) ?: [];
if ($method !== 'clicks') {
    $body['visitor'] = ['ip' => $_SERVER['REMOTE_ADDR']];   // адрес покупателя
}
while (ob_get_level() > 0) {
    ob_end_flush();
}
header('X-Accel-Buffering: no');
$ch = curl_init('https://searchplus.ru/api/v1/' . $method);
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SEARCHPLUS_KEY'), 'Content-Type: application/json'],
    CURLOPT_CONNECTTIMEOUT => 3,
    CURLOPT_TIMEOUT => $method === 'assistant' ? 90 : 5,
    CURLOPT_HEADERFUNCTION => function ($ch, $line) {
        if (preg_match('~^HTTP/\S+ (\d+)~', $line, $m)) {
            http_response_code((int) $m[1]);
        } elseif (stripos($line, 'Content-Type:') === 0) {
            header(trim($line));
        }
        return strlen($line);
    },
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) {
        echo $chunk;
        flush();   // поток помощника — сразу к приложению
        return strlen($chunk);
    },
]);
if (curl_exec($ch) === false && !headers_sent()) {
    http_response_code(503);
    header('Content-Type: application/json');
    echo json_encode(['error' => ['code' => 'unavailable', 'message' => 'Поиск временно недоступен']]);
}

На своём сервере проверяйте пользователя приложения, если нужен вход, и не пропускайте в SearchPlus лишние поля. Каталог и заказы передаёт сервер магазина по своим событиям, а не приложение.

Ключ и адрес

Адрес: https://searchplus.ru/api/v1. Запросы и ответы — JSON в UTF-8, ключ — в заголовке Authorization: Bearer. В примерах ключ берётся из переменной окружения SEARCHPLUS_KEY. Функция searchplus() ниже используется во всех остальных примерах на этой странице.

curl https://searchplus.ru/api/v1/index \
  -H "Authorization: Bearer $SEARCHPLUS_KEY"
<?php
// Ключ — в переменной окружения сервера, не в коде и не в приложении
function searchplus(string $method, string $path, ?array $body = null, int $timeout = 30): array
{
    $ch = curl_init('https://searchplus.ru/api/v1' . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 3,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('SEARCHPLUS_KEY'),
            'Content-Type: application/json',
        ],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
    }
    $raw = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($raw === false || $status >= 500) {
        throw new RuntimeException('unavailable');   // сеть, тайм-аут или сбой сервера — можно повторить
    }
    $data = json_decode($raw, true);
    if ($status >= 400) {
        throw new RuntimeException($data['error']['code'], $status);
    }
    return $data;
}

$index = searchplus('GET', '/index');
// Node.js 18+: fetch встроен. Ключ — в переменной окружения сервера
const BASE = 'https://searchplus.ru/api/v1';

export async function searchplus(method, path, body, timeoutMs = 30000) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.SEARCHPLUS_KEY}`,
      'Content-Type': 'application/json',
    },
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(timeoutMs),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(data.error?.code ?? `http_${res.status}`);
    err.status = res.status;
    err.code = data.error?.code;
    err.retryAfter = Number(res.headers.get('Retry-After')) || undefined;
    throw err;
  }
  return data;
}

const index = await searchplus('GET', '/index');
import os
import requests

BASE = "https://searchplus.ru/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SEARCHPLUS_KEY']}"


class SearchPlusError(Exception):
    def __init__(self, status, code, retry_after=None):
        super().__init__(code)
        self.status, self.code, self.retry_after = status, code, retry_after


def searchplus(method, path, body=None, timeout=30):
    r = session.request(method, BASE + path, json=body, timeout=(3, timeout))
    if r.status_code >= 400:
        error = r.json().get("error", {}) if r.headers.get("Content-Type", "").startswith("application/json") else {}
        raise SearchPlusError(r.status_code, error.get("code", f"http_{r.status_code}"), r.headers.get("Retry-After"))
    return r.json()


index = searchplus("GET", "/index")

В «Подключении» можно задать до 5 IP, с которых принимаются запросы. Без списка — с любого адреса. Запрос с другого адреса получит 403 ip_not_allowed, а в панели появится кнопка «Разрешить IP».

Лимиты

ЧтоСколькоОтвет сверх
Запросов к API20 в секунду на сайт — отдельно для каталога, для заказов и для поиска: выгрузка каталога не отнимает долю поиска429 rate_limited, Retry-After
Поиски и подсказкипо тарифу в сутки, общий счёт; следующая страница с search_id не считается; сутки — по Москве429 limit_exceeded, resets_at
Помощникпо тарифу в месяц от даты оплаты; пакеты запросов тратятся раньше429 llm_limit_exceeded, resets_at
На одного покупателя — только если передан visitor.ip60 поисков и 240 подсказок в минуту; помощник — 5 сообщений в минуту и 30 в час429 rate_limited, retry_after
Тело запроса и пачкидо 10 МБ; до 1000 товаров или разделов, до 100 заказов, до 500 кликов413 payload_too_large, 422 invalid
Элементы в индексепо тарифу, один лимит на все регионы: товары, варианты и разделы вместетовар в rejected с index_limit_exceeded

Подсказки при вводе считаются в лимит поисков: запрашивайте их с паузой 250–300 мс после последней буквы и от двух символов, а прошлый запрос отменяйте. Без visitor.ip лимиты на покупателя не действуют: адрес вашего сервера — общий для всех покупателей, его лимиты закрыли бы поиск всем сразу.

Ошибки и сбои

Ошибка — HTTP-код и JSON с машинным code и текстом для разработчика:

{ "error": { "code": "invalid", "message": "The query field is required.", "errors": { "query": ["The query field is required."] } } }
HTTPcodeЧто значитЧто делать
401unauthorizedнет ключа или он перевыпущенобновить ключ на сервере; не повторять
403ip_not_allowedадреса сервера нет в списке IP сайта; в ответе ipразрешить IP в «Подключении»
403plan_without_apiтариф без APIперейти на Старт или Про в «Лицензии и оплате»
403installation_suspendedобслуживание сайта приостановленооплатить лицензию или написать в поддержку
403assistant_disabledпомощник выключен в панеливключить в «Настройки» → «Помощник» или скрыть кнопку
404search_not_foundпоиска с таким search_id нет или ему больше 30 минутвыполнить поиск заново
404conversation_not_found, conversation_expiredдиалога нет или ему больше сутокначать новый с поиска
409connection_moduleсайт подключён модулем 1С-Битрикссменить способ в «Подключении»
409catalog_from_feedсайт на фиде: каталог берётся из фидазаказы и поиск через API работают
409sync_too_smallполная выгрузка принесла меньше половины товаровпроверить выгрузку; удалить всё же — {"force": true}
413payload_too_largeтело больше 10 МБразбить на пачки
422invalidневерные поля, подробности в errorsисправить запрос; не повторять
422conversation_limitв диалоге уже 10 вопросовначать новый с поиска
422not_enough_productsпомощнику нужно хотя бы два товара в выдачене показывать кнопку при одном товаре
429rate_limited, assistant_pausedслишком частоповторить через retry_after секунд
429limit_exceeded, llm_limit_exceededлимит тарифа исчерпан до resets_atне повторять до этого времени; поиск — своим способом
5xx, сеть—сбой связи или серверакаталог и заказы — повторить; поиск — показать свой

В пачке товаров или заказов ошибка одного не мешает остальным: отклонённые перечислены в rejected с id, code и message.

Повторы и тайм-ауты

// Повтор для каталога и заказов: повторная отправка безопасна — товар заменяется, заказ узнаётся по номеру
const FINAL = ['limit_exceeded', 'llm_limit_exceeded', 'invalid', 'unauthorized', 'ip_not_allowed', 'plan_without_api'];

async function withRetry(call, tries = 5) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await call();
    } catch (err) {
      if (FINAL.includes(err.code) || attempt === tries) throw err;
      const wait = err.retryAfter ? err.retryAfter * 1000 : 500 * 2 ** attempt;   // 1 с, 2 с, 4 с…
      await new Promise(resolve => setTimeout(resolve, wait));
    }
  }
}

await withRetry(() => searchplus('POST', '/products', { products: batch }));
import time

FINAL = {"limit_exceeded", "llm_limit_exceeded", "invalid", "unauthorized", "ip_not_allowed", "plan_without_api"}


def with_retry(call, tries=5):
    for attempt in range(1, tries + 1):
        try:
            return call()
        except (SearchPlusError, requests.RequestException) as err:
            code = getattr(err, "code", None)
            if code in FINAL or attempt == tries:
                raise
            time.sleep(float(getattr(err, "retry_after", None) or 0.5 * 2 ** attempt))


with_retry(lambda: searchplus("POST", "/products", {"products": batch}))

Регионы

Регионы с разными ценами или ассортиментом создаются в «Подключении», у каждого код: s1 — основной, s2… Параметр region в запросах каталога, заказов и поиска выбирает регион, без него — основной. Один товар в нескольких регионах занимает одно место лимита. Список регионов — в GET /index.

Каталог

Каталог передаёт сервер магазина: изменения — сразу после сохранения товара в учётной системе, полную выгрузку — раз в сутки, чтобы удалить пропавшее. Товар передаётся целиком: новая версия заменяет прежнюю. Переиндексируется только изменённое, поэтому смена одной цены дешёвая. Изменения попадают в поиск через несколько секунд.

id товаров

Товары — POST /products

ПолеЧто это
id *id товара в магазине, строка до 100 символов
name *название
url *ссылка на домене сайта, его www или поддомене
price *цена; у товара с вариантами — любая, карточка берёт самую низкую цену вариантов
old_priceстарая цена: больше price — скидка
currencyкод валюты, по умолчанию RUB
availableв наличии, по умолчанию true
picture, brand, sku, barcodesкартинка, бренд, артикул, штрихкоды
descriptionописание, HTML убирается
categoryid раздела из /categories или путь — массив названий
paramsсвойства: {"Цвет": "белый", "Объём": ["1,7 л"]}
hit, new, sort, keywordsхит, новинка, число для порядка, дополнительные слова для поиска
variantsварианты: id*, price*, params*, name, url, old_price, available, picture, sku
region, sync_idрегион пачки и полная выгрузка, если она идёт
curl -X POST https://searchplus.ru/api/v1/products \
  -H "Authorization: Bearer $SEARCHPLUS_KEY" -H "Content-Type: application/json" \
  -d '{"products": [
    {"id": "A-17", "name": "Чайник Bosch TWK 7801", "url": "https://shop.ru/p/a-17",
     "price": 3790, "old_price": 4500, "brand": "Bosch", "sku": "TWK7801", "category": "kettles",
     "picture": "https://shop.ru/i/a-17.jpg", "params": {"Мощность": "2200 Вт", "Объём": "1,7 л"}},
    {"id": "T-1", "name": "Футболка базовая", "url": "https://shop.ru/p/t-1", "price": 990,
     "variants": [
       {"id": "T-1-S", "price": 990, "params": {"Размер": "S"}},
       {"id": "T-1-L", "price": 1090, "params": {"Размер": "L"}, "available": false}]}
  ]}'
// Изменился товар в магазине — отправьте его целиком, поиск обновится через несколько секунд
$result = searchplus('POST', '/products', ['products' => [[
    'id' => 'A-17',
    'name' => 'Чайник Bosch TWK 7801',
    'url' => 'https://shop.ru/p/a-17',
    'price' => 3790,
    'old_price' => 4500,
    'available' => true,
    'brand' => 'Bosch',
    'category' => 'kettles',
    'params' => ['Мощность' => '2200 Вт', 'Объём' => '1,7 л'],
]]]);
foreach ($result['rejected'] as $bad) {
    error_log("SearchPlus отклонил {$bad['id']}: {$bad['code']} {$bad['message']}");
}
const result = await searchplus('POST', '/products', {
  products: [{
    id: 'T-1',
    name: 'Футболка базовая',
    url: 'https://shop.ru/p/t-1',
    price: 990,
    variants: [
      { id: 'T-1-S', price: 990, params: { 'Размер': 'S' } },
      { id: 'T-1-L', price: 1090, params: { 'Размер': 'L' }, available: false },
    ],
  }],
});
result.rejected.forEach(bad => console.warn('отклонён', bad.id, bad.code, bad.message));
result = searchplus("POST", "/products", {"products": [{
    "id": "A-17",
    "name": "Чайник Bosch TWK 7801",
    "url": "https://shop.ru/p/a-17",
    "price": 3790,
    "available": True,
    "category": ["Кухня", "Чайники"],   # раздел можно передать путём
}]})
for bad in result["rejected"]:
    print("отклонён", bad["id"], bad["code"], bad["message"])
{ "accepted": 2, "unchanged": 0, "rejected": [], "limit_rejected": 0, "queued": 4 }

Товар с вариантами — одна карточка с ценой «от» и наличием, если в наличии хоть один вариант. Варианты, которых нет в новой версии товара, удаляются. Новый товар сверх лимита тарифа отклоняется с кодом index_limit_exceeded, уже принятые обновляются. unchanged — товары, которые не изменились и не переиндексируются.

Разделы — POST /categories

До 1000 разделов: id, name, необязательные parent_id и url. Раздел со ссылкой находится в поиске как раздел; без ссылки — только путь и фильтр. Передавайте разделы раньше товаров, которые на них ссылаются.

curl -X POST https://searchplus.ru/api/v1/categories \
  -H "Authorization: Bearer $SEARCHPLUS_KEY" -H "Content-Type: application/json" \
  -d '{"categories": [
    {"id": "kitchen", "name": "Кухня", "url": "https://shop.ru/kitchen/"},
    {"id": "kettles", "name": "Чайники", "parent_id": "kitchen", "url": "https://shop.ru/kitchen/kettles/"}]}'

Удаление — POST /products/delete, /categories/delete

curl -X POST https://searchplus.ru/api/v1/products/delete \
  -H "Authorization: Bearer $SEARCHPLUS_KEY" -H "Content-Type: application/json" \
  -d '{"ids": ["A-17", "T-1"]}'
{ "deleted": 4 }

Товар удаляется вместе с вариантами, deleted считает и их. Неизвестный id — не ошибка. Товар, которого временно нет, лучше передать с "available": false: он останется в поиске, но ниже товаров в наличии.

Полная выгрузка — POST /sync

Чтобы удалить всё, чего больше нет в магазине, выгрузите каталог целиком:

  1. POST /sync (с region, если он не основной) — в ответе sync_id;
  2. пачки /categories и /products с этим sync_id; товары, присланные без него, пока выгрузка идёт, тоже засчитываются;
  3. POST /sync/{sync_id}/finish — товары и разделы региона, которых не было в выгрузке, удаляются.
# Раз в сутки: каталог целиком, чтобы удалить из поиска то, чего больше нет в магазине
def full_sync(categories, products, batch=500):
    sync_id = searchplus("POST", "/sync")["sync_id"]
    for i in range(0, len(categories), batch):
        searchplus("POST", "/categories", {"sync_id": sync_id, "categories": categories[i:i + batch]})
    for i in range(0, len(products), batch):
        result = searchplus("POST", "/products", {"sync_id": sync_id, "products": products[i:i + batch]})
        for bad in result["rejected"]:
            print("отклонён", bad["id"], bad["code"])
    # 409 sync_too_small — пришло меньше половины прежних товаров: ничего не удалено, проверьте выгрузку
    return searchplus("POST", f"/sync/{sync_id}/finish")   # {"deleted": 12, "received": 1200}
// Раз в сутки: каталог целиком, чтобы удалить из поиска то, чего больше нет в магазине
$sync = searchplus('POST', '/sync')['sync_id'];
foreach (array_chunk($categories, 500) as $batch) {
    searchplus('POST', '/categories', ['sync_id' => $sync, 'categories' => $batch]);
}
foreach (array_chunk($products, 500) as $batch) {
    searchplus('POST', '/products', ['sync_id' => $sync, 'products' => $batch]);
}
$done = searchplus('POST', "/sync/$sync/finish");   // {"deleted": 12, "received": 1200}
// Раз в сутки: каталог целиком, чтобы удалить из поиска то, чего больше нет в магазине
const { sync_id } = await searchplus('POST', '/sync');
for (let i = 0; i < categories.length; i += 500) {
  await searchplus('POST', '/categories', { sync_id, categories: categories.slice(i, i + 500) });
}
for (let i = 0; i < products.length; i += 500) {
  await searchplus('POST', '/products', { sync_id, products: products.slice(i, i + 500) });
}
const done = await searchplus('POST', `/sync/${sync_id}/finish`); // { deleted: 12, received: 1200 }

Если пришло меньше половины прежних товаров, finish ничего не удаляет и отвечает 409 sync_too_small; с {"force": true} удаление выполняется. Незавершённая выгрузка через 24 часа закрывается сама, ничего не удаляя.

Состояние индекса — GET /index

{ "regions": [{ "region": "s1", "name": "Основной", "host": null, "products": 38, "variants": 275, "categories": 15 }],
  "limit": 150000, "taken": 328, "queued": 0, "syncs": [] }

queued — сколько изменений ещё ждёт индексации, taken — сколько элементов уже занято из лимита limit.

Заказы — POST /orders

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

ПолеЧто это
id *номер заказа, строка до 100 символов; повтор с тем же номером обновляет заказ
items *позиции: id товара или варианта, quantity, price за единицу, необязательный search_id
total, currencyсумма товаров заказа (без неё — сумма позиций) и валюта
created_atвремя заказа ISO 8601, не старше 90 дней
paid, canceledоплачен, отменён
regionрегион, без него — основной
rag_sa, rag_abзначения одноимённых cookie покупателя как есть
curl -X POST https://searchplus.ru/api/v1/orders \
  -H "Authorization: Bearer $SEARCHPLUS_KEY" -H "Content-Type: application/json" \
  -d '{"orders": [{"id": "5021", "created_at": "2026-10-15T12:30:00+03:00",
    "items": [{"id": "A-17", "quantity": 1, "price": 3790},
              {"id": "T-1-L", "quantity": 2, "price": 1090, "search_id": "d27b5da1-4e18-4f22-bb9d-e82c6d7f9a8c"}],
    "total": 5970, "rag_sa": "ЗНАЧЕНИЕ_COOKIE_rag_sa"}]}'
// При оформлении заказа на сайте: cookie rag_sa покупателя — как есть
searchplus('POST', '/orders', ['orders' => [[
    'id' => (string) $order->id,
    'created_at' => $order->created_at->format(DATE_ATOM),
    'items' => array_map(fn ($line) => [
        'id' => $line->product_id,             // id товара или варианта, как в каталоге
        'quantity' => $line->quantity,
        'price' => $line->price,
    ], $order->lines),
    'total' => $order->total,
    'rag_sa' => $_COOKIE['rag_sa'] ?? null,
]]]);

// Оплата и отмена — по номеру
searchplus('POST', '/orders', ['orders' => [['id' => '5021', 'paid' => true]]]);
// Заказ из приложения: search_id хранится в корзине вместе с товаром, открытым из поиска
await searchplus('POST', '/orders', {
  orders: [{
    id: order.number,
    created_at: new Date().toISOString(),
    items: order.lines.map(line => ({
      id: line.productId,
      quantity: line.quantity,
      price: line.price,
      search_id: line.searchId ?? undefined,
    })),
    total: order.total,
  }],
});

// Оплата и отмена — по номеру
await searchplus('POST', '/orders', { orders: [{ id: order.number, paid: true }] });
searchplus("POST", "/orders", {"orders": [{
    "id": "5021",
    "items": [{"id": "A-17", "quantity": 1, "price": 3790, "search_id": search_id}],
}]})

searchplus("POST", "/orders", {"orders": [{"id": "5021", "canceled": True}]})
{ "accepted": 1, "rejected": [] }

Связь с поиском

Оплаты и отмены

{"orders": [{"id": "5021", "paid": true}]}
{"orders": [{"id": "5021", "canceled": true}]}

Для смены статуса достаточно номера; статус заказа, которого ещё не было, отклоняется с кодом unknown_order. Оплата даёт шаг «Оплата» в воронке, отмена убирает заказ из выручки. Если заказ увидел и скрипт (dataLayer), он учитывается один раз, данные API главнее. Сайт на фиде тоже может передавать заказы этим методом.

Для своей страницы результатов, SEO или мобильного приложения: тот же поиск, что в окне на сайте, — правила, синонимы, исправление опечаток, порядок. В ответах — id товаров магазина. Передавайте visitor.ip — адрес покупателя, который видит ваш сервер: по нему работают лимиты на покупателя и исключение адресов из статистики. Кешируйте выдачу на своей стороне, если страница доступна роботам.

curl -X POST https://searchplus.ru/api/v1/search \
  -H "Authorization: Bearer $SEARCHPLUS_KEY" -H "Content-Type: application/json" \
  -d '{"query": "чайник bosch", "limit": 24, "sort": "relevance",
       "filter": {"available": true, "price_max": 5000}, "facets": true,
       "visitor": {"ip": "198.51.100.7"}}'
$page = searchplus('POST', '/search', [
    'query' => $_GET['q'] ?? '',
    'limit' => 24,
    'facets' => true,
    'visitor' => ['ip' => $_SERVER['REMOTE_ADDR']],   // адрес покупателя, не вашего сервера
], timeout: 5);

if ($page['redirect']) {
    header('Location: ' . $page['redirect']);   // правило «запрос → страница», например «доставка»
    exit;
}
foreach ($page['items'] as $position => $item) {
    // $item['id'] — id товара в вашем каталоге
}

// Следующая страница — с search_id: новым поиском она не считается
$next = searchplus('POST', '/search', ['query' => $page['query'], 'search_id' => $page['search_id'], 'offset' => 24, 'limit' => 24], timeout: 5);
// Express: своя страница результатов или метод для приложения
app.get('/search', async (req, res) => {
  const page = await searchplus('POST', '/search', {
    query: String(req.query.q ?? ''),
    limit: 24,
    filter: { available: true },
    facets: true,
    visitor: { ip: req.ip },
  }, 5000);
  if (page.redirect) return res.redirect(page.redirect);
  res.json({ searchId: page.search_id, total: page.total, items: page.items, brands: page.facets.brands });
});
page = searchplus("POST", "/search", {
    "query": "чайник bosch",
    "limit": 24,
    "sort": "price_asc",
    "filter": {"brands": ["Bosch"], "available": True},
    "visitor": {"ip": request.remote_addr},
}, timeout=5)

for position, item in enumerate(page["items"], start=1):
    print(position, item["id"], item["name"], item["price"])
{
  "search_id": "d27b5da1-4e18-4f22-bb9d-e82c6d7f9a8c",
  "query": "чайник bosch",
  "corrected_query": null,
  "suggested_query": null,
  "redirect": null,
  "open_product_id": null,
  "total": 18,
  "items": [
    { "id": "A-17", "name": "Чайник Bosch TWK 7801", "url": "https://shop.ru/p/a-17", "picture": "https://shop.ru/i/a-17.jpg",
      "price": 3790, "currency": "RUB", "discount": 16, "available": true, "brand": "Bosch", "has_variants": false }
  ],
  "price_max": 8990,
  "facets": {
    "sections": [{ "value": "Чайники", "label": "Чайники", "count": 18 }],
    "brands": [{ "value": "Bosch", "count": 18 }],
    "price": { "min": 1990, "max": 8990, "currency": "RUB" },
    "available": 15
  }
}
ПараметрЧто это
query *запрос, до 200 символов
offset, limitсдвиг и размер страницы, до 48
search_idследующая страница прошлого поиска (30 минут): новым поиском не считается
sortrelevance, price_asc, price_desc
filtersection — value из facets.sections, brands — список, price_min, price_max, available
facetsвернуть разделы, бренды, цены и число товаров в наличии для фильтров
no_correctискать как есть, без исправления опечаток («Искать как написано»)
region, visitor.ipрегион и адрес покупателя
Поле ответаЧто показать
corrected_queryзапрос исправлен: «Показаны результаты по запросу …» и ссылка «искать как написано» (no_correct)
suggested_query«Возможно, вы искали …» — запрос не изменён
redirectправило ведёт на страницу (например, «доставка»): перейти на неё, товаров нет
open_product_idточное совпадение артикула: можно сразу открыть этот товар
totalсколько товаров найдено с фильтрами
discount, has_variantsскидка в процентах; у товара есть варианты — цена «от»
price_maxсамая высокая цена в выдаче — для ползунка цены

Подсказки — POST /suggest

{"query": "чайн", "limit": 6, "visitor": {"ip": "198.51.100.7"}}
{ "redirect": null,
  "products": [{ "id": "A-17", "name": "Чайник Bosch TWK 7801", "url": "https://shop.ru/p/a-17", "picture": "…", "price": 3790,
                 "currency": "RUB", "discount": 16, "available": true, "brand": "Bosch", "has_variants": false }],
  "categories": [{ "id": "kettles" }] }

products — товары с теми же полями, что в поиске; categories — id разделов из /categories; redirect — если запрос ведёт на страницу. Подсказка считается в лимит поисков — см. лимиты.

Помощник — POST /assistant

«Помочь выбрать» отвечает по товарам выдачи поиска и ведёт диалог. Сначала поиск, потом помощник с его search_id:

ЗапросПоля
Первый вопросsearch_id *, query — вопрос покупателя (без него — запрос поиска), mode: dialog — диалог, answer — один ответ; по умолчанию — как в панели
Следующий вопросconversation_id * из события done, message * — до 1000 символов
curl -N -X POST https://searchplus.ru/api/v1/assistant \
  -H "Authorization: Bearer $SEARCHPLUS_KEY" -H "Content-Type: application/json" \
  -d '{"search_id": "d27b5da1-4e18-4f22-bb9d-e82c6d7f9a8c", "query": "платье на вечеринку",
       "mode": "dialog", "visitor": {"ip": "198.51.100.7"}}'

Поток помощника

Ответ — text/event-stream: события event: с JSON в строке data:, между событиями пустая строка; строки с двоеточием в начале служебные. Так выглядит ответ:

: ok

event: delta
data: {"text":"Для вечеринки важны цвет, материал и цена. Среди имеющихся вариантов:"}

event: delta
data: {"text":"\n\n[1] Платье «Ночная жизнь» — чёрное, из вискозы, за 4 499 ₽."}

event: delta
data: {"text":"\n[2] Платье «Весенняя лёгкость» — фиолетовое, трикотаж, самое доступное: 2 999 ₽."}

event: products
data: {"items":[{"n":1,"id":"D-12","name":"Платье Ночная жизнь","url":"https://shop.ru/p/d-12","picture":"…","price":4499,"currency":"RUB","discount":null,"available":true,"brand":null,"has_variants":true},{"n":2,"id":"D-31","name":"Платье Весенняя лёгкость","url":"https://shop.ru/p/d-31","picture":"…","price":2999,"currency":"RUB","discount":null,"available":true,"brand":null,"has_variants":true}]}

event: done
data: {"cached":false,"conversation_id":"798c963a-8a6d-44bd-9ae5-e19fa6688221"}
СобытиеdataЧто делать
searchqueries — запросы, по которым помощник сам искал товарыможно показать «Ищу: …»
compareitems — товары с n, rows — свойства: name, values по товарам, differтаблица сравнения, приходит до текста
deltatext — следующий кусок ответадописать к тексту; [1] — ссылка на товар с n = 1
productsitems — товары из ответа: n и поля карточки поискапоказать карточки, связать с [n]
errorcode, messageсбой во время ответа: оставить полученный текст, предложить повторить
doneconversation_id — в диалоге, answer_id — в одном ответе, cached, paused — секунды паузысохранить conversation_id для следующего вопроса

Если покупатель три раза за 10 минут спрашивает не о товарах магазина, помощник замолкает для него на 5 минут: paused в done, а следующие вопросы получают 429 assistant_paused с retry_after. Работает, если передан visitor.ip.

Чтение потока на своём сервере и в приложении — запрос идёт на ваш сервер, см. пример промежуточного сервера:

// Браузер или Node.js 18+. Запрос идёт на ваш сервер, он добавляет ключ и пересылает поток как есть
async function askAssistant(body, onEvent) {
  const res = await fetch('/api/assistant', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) {   // ошибка до начала потока — обычный JSON
    const { error } = await res.json();
    throw Object.assign(new Error(error.message), error);
  }
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = '';
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;
    let end;
    while ((end = buffer.indexOf('\n\n')) !== -1) {
      const block = buffer.slice(0, end);
      buffer = buffer.slice(end + 2);
      let event = 'message', data = '';
      for (const line of block.split('\n')) {
        if (line.startsWith('event: ')) event = line.slice(7);
        else if (line.startsWith('data: ')) data += line.slice(6);
      }                                   // строки с «:» в начале (": ok") — служебные
      if (data) onEvent(event, JSON.parse(data));
    }
  }
}

let conversationId = null;
let answer = '';
await askAssistant({ search_id: searchId, mode: 'dialog' }, (event, data) => {
  if (event === 'delta') answer += data.text;              // «[1]» в тексте — товар с n = 1
  if (event === 'products') showProducts(data.items);      // n, id, name, url, picture, price…
  if (event === 'error') showError(data.message);
  if (event === 'done') conversationId = data.conversation_id;
});
// Следующий вопрос: { conversation_id: conversationId, message: 'а какое дешевле?' }
// Android, OkHttp 4. Запрос — на ваш сервер, ключ SearchPlus остаётся там
val client = OkHttpClient.Builder()
    .readTimeout(90, TimeUnit.SECONDS)
    .build()

fun askAssistant(body: JSONObject, onEvent: (String, JSONObject) -> Unit) {
    val request = Request.Builder()
        .url("https://shop.ru/api/assistant")
        .post(body.toString().toRequestBody("application/json".toMediaType()))
        .build()
    client.newCall(request).execute().use { response ->
        val source = response.body!!.source()
        if (!response.isSuccessful) {
            val error = JSONObject(source.readUtf8()).getJSONObject("error")
            throw IOException(error.getString("code"))
        }
        var event = "message"
        while (true) {
            val line = source.readUtf8Line() ?: break
            when {
                line.startsWith("event: ") -> event = line.removePrefix("event: ")
                line.startsWith("data: ") -> onEvent(event, JSONObject(line.removePrefix("data: ")))
            }
        }
    }
}
// iOS 15+. Запрос — на ваш сервер, ключ SearchPlus остаётся там
func askAssistant(_ body: [String: Any], onEvent: (String, [String: Any]) -> Void) async throws {
    var request = URLRequest(url: URL(string: "https://shop.ru/api/assistant")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONSerialization.data(withJSONObject: body)
    request.timeoutInterval = 90
    let (bytes, response) = try await URLSession.shared.bytes(for: request)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }
    var event = "message"
    for try await line in bytes.lines {
        if line.hasPrefix("event: ") {
            event = String(line.dropFirst(7))
        } else if line.hasPrefix("data: "),
                  let data = line.dropFirst(6).data(using: .utf8),
                  let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
            onEvent(event, json)
        }
    }
}
import json

def ask_assistant(body):
    with session.post(BASE + "/assistant", json=body, stream=True, timeout=(3, 90)) as r:
        if r.status_code >= 400:
            raise SearchPlusError(r.status_code, r.json()["error"]["code"])
        r.encoding = "utf-8"
        event = "message"
        for line in r.iter_lines(decode_unicode=True):
            if line.startswith("event: "):
                event = line[7:]
            elif line.startswith("data: "):
                yield event, json.loads(line[6:])


for event, data in ask_assistant({"search_id": search_id, "mode": "dialog"}):
    if event == "delta":
        print(data["text"], end="", flush=True)
    elif event == "done":
        conversation_id = data.get("conversation_id")

Клики — POST /clicks

Клик по товару из выдачи, подсказок или помощника: search_id, id товара или варианта, position — место в выдаче с 1, source — page, suggest, assistant или app, необязательное at — время ISO 8601, если клики копятся на устройстве. До 500 кликов за раз.

{"clicks": [{"search_id": "d27b5da1-4e18-4f22-bb9d-e82c6d7f9a8c", "id": "A-17", "position": 3, "source": "app"}]}
{ "stored": 1 }

Один клик на поиск и товар: повторы не считаются. На своей странице результатов клики можно не передавать: строка скрипта сама учтёт клик по ссылке с атрибутами и запомнит поиск для заказа:

<a href="/p/a-17" data-searchplus-search="d27b5da1-…" data-searchplus-product="A-17" data-searchplus-position="3">…</a>

Строка скрипта

Окно поиска, подсказки, помощник и чат на сайте — та же строка скрипта, что у фида: кнопка «В корзину», события Метрики и GA4, CSP описаны там.