API SearchPlus
Для разработчиков магазинов и приложений: каталог и заказы передаются с сервера магазина, поиск, подсказки и помощник доступны со своей страницы результатов или из приложения. Витрина на сайте — та же строка скрипта, что у фида. API работает на тарифах Старт и Про.
Начало
- В панели: «Добавить сайт» → «API и скрипт». Ключ покажем один раз; перевыпустить его можно в «Подключении».
- Передайте каталог: разделы, затем товары; дальше — изменения по мере их появления и полная выгрузка раз в сутки.
- Вставьте строку скрипта на все страницы сайта — или подключите поиск с сервера к своей выдаче и приложению.
- Передавайте заказы, оплаты и отмены — для доли выручки, хитов продаж и воронки.
Ключ API и его перевыпуск, список IP, регионы и строка скрипта — в панели SearchPlus: сайт → «Настройки» → «Подключение».
Приложение и свой сервер
Ключ API — секрет магазина: с ним можно заменить каталог и тратить лимиты тарифа. Поэтому он живёт только на сервере: не в мобильном приложении, не в JavaScript страницы и не в репозитории. Ключ из приложения достаётся разбором сборки за минуты.
- Есть свой сервер (бэкенд магазина или приложения): он вызывает SearchPlus, приложение — только его.
- Своего сервера нет: поставьте небольшой промежуточный сервер — пример ниже. Он добавляет ключ, передаёт адрес покупателя и пересылает поток помощника без задержки.
Приложение ──► ваш сервер (ключ) ──► 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».
Лимиты
| Что | Сколько | Ответ сверх |
|---|---|---|
| Запросов к API | 20 в секунду на сайт — отдельно для каталога, для заказов и для поиска: выгрузка каталога не отнимает долю поиска | 429 rate_limited, Retry-After |
| Поиски и подсказки | по тарифу в сутки, общий счёт; следующая страница с search_id не считается; сутки — по Москве | 429 limit_exceeded, resets_at |
| Помощник | по тарифу в месяц от даты оплаты; пакеты запросов тратятся раньше | 429 llm_limit_exceeded, resets_at |
На одного покупателя — только если передан visitor.ip | 60 поисков и 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."] } } }
| HTTP | code | Что значит | Что делать |
|---|---|---|---|
| 401 | unauthorized | нет ключа или он перевыпущен | обновить ключ на сервере; не повторять |
| 403 | ip_not_allowed | адреса сервера нет в списке IP сайта; в ответе ip | разрешить IP в «Подключении» |
| 403 | plan_without_api | тариф без API | перейти на Старт или Про в «Лицензии и оплате» |
| 403 | installation_suspended | обслуживание сайта приостановлено | оплатить лицензию или написать в поддержку |
| 403 | assistant_disabled | помощник выключен в панели | включить в «Настройки» → «Помощник» или скрыть кнопку |
| 404 | search_not_found | поиска с таким search_id нет или ему больше 30 минут | выполнить поиск заново |
| 404 | conversation_not_found, conversation_expired | диалога нет или ему больше суток | начать новый с поиска |
| 409 | connection_module | сайт подключён модулем 1С-Битрикс | сменить способ в «Подключении» |
| 409 | catalog_from_feed | сайт на фиде: каталог берётся из фида | заказы и поиск через API работают |
| 409 | sync_too_small | полная выгрузка принесла меньше половины товаров | проверить выгрузку; удалить всё же — {"force": true} |
| 413 | payload_too_large | тело больше 10 МБ | разбить на пачки |
| 422 | invalid | неверные поля, подробности в errors | исправить запрос; не повторять |
| 422 | conversation_limit | в диалоге уже 10 вопросов | начать новый с поиска |
| 422 | not_enough_products | помощнику нужно хотя бы два товара в выдаче | не показывать кнопку при одном товаре |
| 429 | rate_limited, assistant_paused | слишком часто | повторить через retry_after секунд |
| 429 | limit_exceeded, llm_limit_exceeded | лимит тарифа исчерпан до resets_at | не повторять до этого времени; поиск — своим способом |
| 5xx, сеть | — | сбой связи или сервера | каталог и заказы — повторить; поиск — показать свой |
В пачке товаров или заказов ошибка одного не мешает остальным: отклонённые перечислены в rejected с id, code и message.
Повторы и тайм-ауты
- Каталог и заказы повторяйте при 429, 5xx и обрыве связи с паузой 1, 2, 4… секунды или по
Retry-After. Это безопасно: товар заменяется целиком, заказ с тем же номером обновляется, а не дублируется. Тайм-аут — 30 секунд. - Поиск и подсказки ждите не дольше 5 секунд и не повторяйте: если SearchPlus недоступен, покажите свой поиск магазина или сообщение и попробуйте при следующем запросе покупателя.
- Помощник отвечает потоком до 90 секунд; первая строка
: okприходит сразу. Если связь оборвалась, оставьте уже полученный текст и предложите спросить ещё раз.
// Повтор для каталога и заказов: повторная отправка безопасна — товар заменяется, заказ узнаётся по номеру
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 товаров
- id — ваши: те, что вы передали в
/products. Поиск, подсказки и помощник отвечают ими же, поэтому приложение открывает товар поidиз ответа без сопоставлений. - У товара с вариантами в выдаче — id товара; id вариантов принимаются в заказах и кликах и засчитываются своему товару.
- id не меняйте при переименовании и смене ссылки: новый id — это новый товар, история кликов и продаж старого к нему не перейдёт.
- Если сайт подключён фидом, id — из фида:
offer@idилиgroup_id; варианты безgroup_id, объединённые по ссылке, получают служебный id видаu:43aca92d…. Приложению хватитurlиз ответа.
Товары — POST /products
| Поле | Что это |
|---|---|
id * | id товара в магазине, строка до 100 символов |
name * | название |
url * | ссылка на домене сайта, его www или поддомене |
price * | цена; у товара с вариантами — любая, карточка берёт самую низкую цену вариантов |
old_price | старая цена: больше price — скидка |
currency | код валюты, по умолчанию RUB |
available | в наличии, по умолчанию true |
picture, brand, sku, barcodes | картинка, бренд, артикул, штрихкоды |
description | описание, HTML убирается |
category | id раздела из /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
Чтобы удалить всё, чего больше нет в магазине, выгрузите каталог целиком:
POST /sync(сregion, если он не основной) — в ответеsync_id;- пачки
/categoriesи/productsс этимsync_id; товары, присланные без него, пока выгрузка идёт, тоже засчитываются; 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": [] }
Связь с поиском
- Сайт со строкой скрипта. Скрипт запоминает в cookie
rag_saна домене сайта, из какого поиска покупатель открыл товар (7 дней). При оформлении заказа прочитайте её на сервере и передайте как есть. - Приложение или своя выдача. Когда покупатель открыл товар из поиска, отправьте клик и сохраните
search_idвместе с товаром в корзине. В заказе передайте его в позиции:"search_id". Поиск засчитывается, если ему не больше 7 дней. - Вариант засчитывается поиску своего товара. Cookie
rag_ab— участие в сравнении с поиском сайта, если оно идёт.
Оплаты и отмены
{"orders": [{"id": "5021", "paid": true}]}
{"orders": [{"id": "5021", "canceled": true}]}
Для смены статуса достаточно номера; статус заказа, которого ещё не было, отклоняется с кодом unknown_order. Оплата даёт шаг «Оплата» в воронке, отмена убирает заказ из выручки.
Если заказ увидел и скрипт (dataLayer), он учитывается один раз, данные API главнее. Сайт на фиде тоже может передавать заказы этим методом.
Поиск с сервера
Для своей страницы результатов, SEO или мобильного приложения: тот же поиск, что в окне на сайте, — правила, синонимы, исправление опечаток, порядок. В ответах — id товаров магазина.
Передавайте visitor.ip — адрес покупателя, который видит ваш сервер: по нему работают лимиты на покупателя и исключение адресов из статистики. Кешируйте выдачу на своей стороне, если страница доступна роботам.
Поиск — POST /search
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 минут): новым поиском не считается |
sort | relevance, price_asc, price_desc |
filter | section — 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 символов |
- Диалог — до 10 вопросов и сутки с первого. Номера товаров
nв нём постоянные: новые товары, которые помощник нашёл по ходу, получают следующие номера. conversation_idхраните на устройстве или в сессии покупателя. Послеconversation_expiredилиconversation_limitначните новый диалог с поиска.- Если помощник выключен, лимит исчерпан или в выдаче меньше двух товаров, ответ — ошибка JSON, а не поток. Кнопку помощника показывайте при двух товарах и больше.
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 | Что делать |
|---|---|---|
search | queries — запросы, по которым помощник сам искал товары | можно показать «Ищу: …» |
compare | items — товары с n, rows — свойства: name, values по товарам, differ | таблица сравнения, приходит до текста |
delta | text — следующий кусок ответа | дописать к тексту; [1] — ссылка на товар с n = 1 |
products | items — товары из ответа: n и поля карточки поиска | показать карточки, связать с [n] |
error | code, message | сбой во время ответа: оставить полученный текст, предложить повторить |
done | conversation_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 описаны там.