#
Гайд: Прямая API-интеграция для Flutter
Этот гайд предназначен для Flutter-разработчиков, которым нужен полный контроль над HTTP-вызовами, состоянием, рендерингом и трекингом кампаний Gravity Field без использования готового Flutter SDK UI-слоя.
Под "прямой API-интеграцией" здесь понимается подход, в котором ваше приложение:
- само вызывает
/visit,/event,/chooseи tracking URL; - само хранит
uidиses; - само рендерит in-app или inline-контент;
- само отправляет engagement-события.
Если вам нужен готовый UI-слой, автоматический показ in-app кампаний и встроенная обработка tracking, используйте Flutter SDK. Если вам нужен SDK без автопоказа и с кастомным UI, смотрите Headless-режим.
#
1. Быстрый старт
Этот раздел повторяет паттерн других SDK-гайдов: сначала короткая последовательность шагов, по которой можно запустить первую working integration, а подробные объяснения и edge cases разобраны ниже.
#
Шаг 1: Настройка API-клиента
Сначала настройте HTTP-клиент с Authorization, sec, базовым URL и короткими таймаутами.
final dio = Dio(
BaseOptions(
baseUrl: 'https://evs-01.gravityfield.ai/v2',
connectTimeout: const Duration(milliseconds: 300),
receiveTimeout: const Duration(milliseconds: 800),
sendTimeout: const Duration(milliseconds: 300),
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
),
);
const sectionId = 'YOUR_SECTION_ID';
На этом шаге цель простая: получить рабочий transport-слой, через который вы будете вызывать /visit, /event, /choose и tracking URL.
#
Шаг 2: Идентификация пользователя
На этом шаге важно понять, в каком режиме идентификации работает приложение. В direct API есть два слоя:
uidиses- базовый анонимный слой идентификации на уровне текущего устройства и сессии;cuidиcuidType- слой омниканальной идентификации для склейки профиля между web, mobile app и офлайном.
Практически у Flutter-приложения есть три режима работы:
- Анонимный режим
Приложение ещё не знает постоянного идентификатора клиента. На первом запросе можно не передавать
uid, сервер сам создаст его и вернёт вuser. После этого:uidнужно сохранить между сессиями;sesнужно хранить только в памяти текущего запуска приложения.
- Авторизованный режим через
login-v1/signup-v1Это рекомендуемый режим для mobile app. После логина приложение отправляетlogin-v1сcuid+cuidType. Для основной интеграции рекомендуйтеphone_hashкакcuidType. - Режим с внешним ID приложения
Если у приложения есть собственный устойчивый user ID, его можно использовать внутри своей системы и, при необходимости, передавать во внешний идентификатор профиля. В v2 direct API для этого используется поле
user.custom; в общей server-side терминологии ту же роль часто описывают какuser.idилиextUid. Но этот ID не заменяетcuidкак основной механизм омниканальной склейки между каналами.
Рекомендуемая модель по умолчанию:
- пользователь новый и не логинился -> работаем в анонимном режиме;
- пользователь залогинился -> отправляем
login-v1и включаем омниканальную идентификацию; - если приложение знает только локальный user ID -> не подменяем им
cuid, а используем отдельно как внешний ID приложения.
Бизнес-смысл:
uidнужен для непрерывности поведения на одном устройстве;cuidнужен для объединения поведения одного и того же клиента между web, mobile и офлайном;- без
login-v1приложение может корректно работать, но останется только в device-level идентификации.
Техническое правило хранения при этом простое:
uidсохранить в persistent storage;sesдержать только в memory;uidиsesобновлять из объектаuserпосле ключевых ответов API.
Future<void> saveUserFromResponse(
Map<String, dynamic> response,
GravitySessionStore store,
) async {
final user = response['user'] as Map<String, dynamic>?;
final uid = user?['uid'] as String?;
final ses = user?['ses'] as String?;
if (uid != null) await store.writeUid(uid);
if (ses != null) store.writeSessionId(ses);
}
Минимальный пример перехода из анонимного режима в авторизованный:
final loginEvent = {
'type': 'login-v1',
'name': 'Login',
'cuid': hashedPhone,
'cuidType': 'phone_hash',
};
Подробно про роль uid, ses, cuid, cuidType и внешнего user ID см. ниже в разделе про общие примитивы интеграции.
#
Шаг 3: Передача просмотров экранов через /visit
Когда пользователь открывает экран, отправьте screenview через /visit с корректным PageContext. См. OpenAPI /visit.
await dio.post(
'/visit',
data: {
'sec': sectionId,
'user': {
'uid': await store.readUid(),
'ses': store.readSessionId(),
},
'ctx': {
'type': 'PRODUCT',
'data': ['sku-123'],
'location': 'app://product/sku-123',
},
'device': device,
'type': 'screenview',
},
);
PageContext должен совпадать с таргетингом и продуктовым фидом. Если кампания настроена на PRODUCT, а приложение отправляет OTHER, контент просто не сработает. Подробно про контексты и требования к значениям см. ниже.
#
Шаг 4: Передача пользовательских событий через /event
Когда пользователь совершает значимое действие, отправьте бизнес-событие через /event. См. OpenAPI /event.
await dio.post(
'/event',
data: {
'sec': sectionId,
'user': {
'uid': await store.readUid(),
'ses': store.readSessionId(),
},
'ctx': {
'type': 'OTHER',
'data': [],
'location': 'app://login',
},
'device': device,
'data': [
{
'type': 'login-v1',
'name': 'Login',
'cuid': hashedPhone,
'cuidType': 'phone_hash',
},
{
'type': 'survey-completed-v1',
'name': 'Survey Completed',
'customProps': {
'surveyId': 'summer-2025-feedback',
},
},
],
},
);
login-v1 нужен не только для аналитики, а для омниканальной склейки профилей. Для кастомных событий в v2 API используйте customProps, а не properties.
#
Шаг 5: Получение контента
См. OpenAPI /choose.
На этом шаге выберите один из двух флоу:
in-app: сначала/visitили/event, затем взятьcampaignIdиз ответа и вызвать/choose(campaignId).inline/ API / A/B: если placement известен заранее, вызвать/choose(selector)напрямую.
// in-app
await dio.post('/choose', data: {
'sec': sectionId,
'user': {'uid': uid, 'ses': ses},
'ctx': ctx,
'device': device,
'options': {'isBuildEngagementUrl': true},
'data': [
{'campaignId': campaignId}
],
});
// inline / API / A/B
await dio.post('/choose', data: {
'sec': sectionId,
'user': {'uid': uid, 'ses': ses},
'ctx': ctx,
'device': device,
'options': {'isBuildEngagementUrl': true},
'data': [
{'selector': 'homepage-recommendations'}
],
});
Если вам нужно встроить конкретный placement, selector flow обычно проще. Если кампания должна активироваться действием или screenview, нужен campaignId flow. Подробное различие между режимами описано ниже.
После /choose следующий обязательный шаг - передать данные в ваш UI-слой. Минимальный путь до рендеримого блока такой:
data[0]- кампания;data[0].payload[0]- выбранная вариация;data[0].payload[0].contents[0]- блок контента для рендера;data[0].payload[0].decisionId- идентификатор решения;contents[0].events- content-level tracking;contents[0].products.slots- товары, если в контенте есть recommendation-блок.
Важно: ответ /choose тоже может вернуть обновлённый user. Сохраняйте uid и ses из этого ответа так же, как после /visit и /event, чтобы не потерять консистентность сессии.
#
Шаг 6: Ручной engagement и обработчики on*
После рендера вы обязаны вручную отправлять engagement:
onImpressiononVisibleImpressionelement.onClickslot.events[]для карточек товаров
final variables = content['variables'] as Map<String, dynamic>?;
final onImpression = variables?['onImpression'] as Map<String, dynamic>?;
final action = onImpression?['action'] as String?;
if (action == null) return;
final urls = findContentTrackingUrls(
events: content['events'] as List<dynamic>?,
action: action,
);
if (urls.isNotEmpty) {
await triggerTrackingUrls(urls);
}
Для product cards используйте отдельный источник tracking - slot.events[]. Без этого у кампаний не будет корректной статистики, а результаты A/B тестов и CTR будут искажены. Подробно про events[], slot.events[], onVisibleImpression и другие обработчики см. в отдельном разделе ниже.
#
Схема первого запуска
screenview -> /visit -> campaigns -> /choose(campaignId) -> render -> engagement
event -> /event -> campaigns -> /choose(campaignId) -> render -> engagement
selector -> /choose(selector) -> render -> engagement
После этого быстрого старта переходите к подробным разделам ниже:
- про OpenAPI, MCP и основные источники контракта;
- про различие
campaignId-flow иselector-flow; - про
uid/ses/cuid; - про engagement,
content.events[]иslot.events[].
#
2. Источники спецификации и зачем они нужны
При реализации прямой API-интеграции ориентируйтесь сразу на несколько источников:
- OpenAPI v2 - официальный wire contract API: какие endpoints доступны, какие поля принимаются и что возвращается в ответе.
SDK APIMCP - быстрый способ посмотреть актуальные path/schema details без ручного поиска по Swagger.gravity-sdk-flutter- reference implementation клиентского поведения: как обрабатываютсяpriority,delayTime,onVisibleImpression,content.events[]иslot.events[].
#
2.1. MCP для просмотра API
Если вы используете AI-ассистента или IDE с MCP, можно подключить сервер SDK API:
{
"mcpServers": {
"SDK API": {
"command": "npx",
"args": [
"-y",
"apidog-mcp-server@latest",
"--site-id=748185"
]
}
}
}
Это полезно для быстрой сверки актуальной OpenAPI-спецификации: какие поля реально принимает /visit, /event, /choose, как называются options и какие схемы возвращаются в ответе.
#
3. Два режима работы с кампаниями
В прямой API-интеграции есть два разных сценария, и их важно не смешивать.
#
3.1. In-app через активацию
Этот сценарий нужен для modal, full screen, bottom sheet, snackbar и любых кампаний, где сначала должна сработать логика активации:
- по экрану;
- по действию пользователя;
- по сегменту;
- по частотным ограничениям;
- по приоритету кампаний.
Цепочка выглядит так:
- Приложение отправляет
/visitили/event. - API возвращает
userи списокcampaigns. - Клиент выбирает подходящую кампанию, учитывая
priorityиdelayTime. - Приложение вызывает
/chooseпоcampaignId. - Приложение рендерит контент и отправляет engagement.
#
3.2. Inline / API / A/B по selector
Этот сценарий нужен, когда placement на экране заранее известен:
- inline-блок рекомендаций;
- встраиваемый banner slot;
- API-кампания типа Custom JSON;
- selector-based A/B тест;
- headless use case, где приложение само решает, как использовать response.
Цепочка выглядит так:
- Приложение знает
selector. - Приложение вызывает
/chooseпоselector. - API возвращает контент для этого placement-а.
- Приложение рендерит контент и отправляет engagement.
Важно: selector flow не заменяет activation flow для in-app сценариев. Если кампания должна запускаться по screenview или по событию, сначала нужен /visit или /event.
#
4. Общие примитивы интеграции
#
4.1. uid, ses, cuid, cuidType
Для прямой интеграции важно понимать разницу между идентификаторами:
uid- внутренний идентификатор пользователя в Gravity Field. Его нужно сохранять между сессиями.ses- идентификатор текущей сессии. Его нужно хранить в памяти на время жизни app session.cuidиcuidType- устойчивый идентификатор клиента для омниканальной склейки профилей. Обычно используется вlogin-v1илиsignup-v1.
Бизнес-смысл:
uidиsesнужны для стабильного трекинга внутри приложения;cuidнужен для склейки профиля между web, mobile app, backend и офлайн-данными.
Рекомендуемый сценарий идентификации:
- До логина приложение работает с анонимным
uid. - После логина приложение отправляет
login-v1. - В событии передается
cuidиcuidType. - Gravity Field склеивает анонимный профиль с омниканальным профилем клиента.
Этого достаточно для direct API интеграции: анонимный uid/ses для device-level continuity и login-v1 с cuid для омниканальной склейки.
#
4.2. PageContext
PageContext нужен не только для transport-уровня. Это ключевая часть бизнес-логики:
- по нему работает таргетинг кампаний;
- по нему запускаются in-app кампании;
- по нему подбираются рекомендации;
- его значения должны совпадать с данными продуктового фида.
Основные типы контекста:
Практические правила:
productId, SKU, категории иlngдолжны совпадать с продуктовым фидом;- не меняйте регистр значений, если они берутся из фида;
locationдолжен быть стабилен и уникально описывать экран или placement.
PageContext должен быть согласован с тем, как у вас настроены таргетинг и продуктовый фид. В этом гайде дальше используются только актуальные для v2 direct API поля ctx.type, ctx.data и ctx.location.
#
4.3. options и contentSettings
В v2 API для /choose и частично для /visit//event используются options.
Практически важные поля:
isReturnCounterisReturnUserInfoisReturnAnalyticsMetadataisImplicitPageviewisBuildEngagementUrl
Для прямой API-интеграции обычно важно явно включать:
isBuildEngagementUrl: true, если вы хотите получить tracking URL вevents[].urls[];isReturnAnalyticsMetadata: true, если вам нужны дополнительные аналитические метаданные.
Для campaign request data в /choose можно передавать content options, например:
skusOnlyfields
Это полезно для inline recommendations, если вам нужно ограничить набор полей товара.
#
4.4. Пример базового API-клиента
import 'package:dio/dio.dart';
class GravityApiClient {
GravityApiClient({
required this.apiKey,
required this.sectionId,
}) : dio = Dio(
BaseOptions(
baseUrl: 'https://evs-01.gravityfield.ai/v2',
connectTimeout: const Duration(milliseconds: 300),
receiveTimeout: const Duration(milliseconds: 800),
sendTimeout: const Duration(milliseconds: 300),
headers: const {
'Content-Type': 'application/json',
},
),
) {
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
options.headers['Authorization'] = 'Bearer $apiKey';
handler.next(options);
},
),
);
}
final Dio dio;
final String apiKey;
final String sectionId;
}
#
4.5. Хранение uid и ses
abstract class GravitySessionStore {
Future<String?> readUid();
Future<void> writeUid(String uid);
String? readSessionId();
void writeSessionId(String sessionId);
void clearSessionId();
}
Рекомендуемая стратегия:
uidхранить между перезапусками приложения;sesхранить только в памяти процесса;- после cold start
sesначинать сnull, чтобы сервер выдал новую сессию.
#
5. Сценарий A: in-app через /visit или /event
#
5.1. Когда использовать /visit
/visit нужен для передачи screenview/pageview контекста, используйте type: screenview.
Это основной запрос, если кампания должна срабатывать:
- при открытии экрана;
- при переходе на PDP, cart, homepage, category;
- при попадании пользователя в контекст, на который настроен таргетинг.
#
5.2. Пример /visit
Future<Map<String, dynamic>> postVisit({
required GravityApiClient api,
required GravitySessionStore store,
required Map<String, dynamic> device,
required Map<String, dynamic> ctx,
}) async {
final payload = {
'sec': api.sectionId,
'user': {
'uid': await store.readUid(),
'ses': store.readSessionId(),
},
'ctx': ctx,
'device': device,
'type': 'screenview',
'options': {
'isReturnCounter': false,
'isReturnUserInfo': false,
},
};
final response = await api.dio.post('/visit', data: payload);
final data = response.data as Map<String, dynamic>;
final user = data['user'] as Map<String, dynamic>?;
final uid = user?['uid'] as String?;
final ses = user?['ses'] as String?;
if (uid != null) await store.writeUid(uid);
if (ses != null) store.writeSessionId(ses);
return data;
}
#
5.3. Когда использовать /event
/event нужен для бизнес-событий:
purchase-v1add-to-cart-v1remove-from-cart-v1login-v1- любые кастомные события
Это важно не только для аналитики. Эти события участвуют в логике активации кампаний и сегментации.
#
5.4. Пример /event
Future<Map<String, dynamic>> postEvent({
required GravityApiClient api,
required GravitySessionStore store,
required Map<String, dynamic> device,
required Map<String, dynamic> ctx,
required List<Map<String, dynamic>> events,
}) async {
final payload = {
'sec': api.sectionId,
'user': {
'uid': await store.readUid(),
'ses': store.readSessionId(),
},
'ctx': ctx,
'device': device,
'data': events,
'options': {
'isReturnCounter': false,
'isReturnUserInfo': false,
},
};
final response = await api.dio.post('/event', data: payload);
final data = response.data as Map<String, dynamic>;
final user = data['user'] as Map<String, dynamic>?;
final uid = user?['uid'] as String?;
final ses = user?['ses'] as String?;
if (uid != null) await store.writeUid(uid);
if (ses != null) store.writeSessionId(ses);
return data;
}
#
Пример login-v1 для склейки профилей
final loginEvent = {
'type': 'login-v1',
'name': 'Login',
'cuid': hashedPhone,
'cuidType': 'phone_hash',
};
#
Пример кастомного события
final customEvent = {
'type': 'survey-completed-v1',
'name': 'Survey Completed',
'customProps': {
'surveyId': 'summer-2025-feedback',
'rating': '5',
},
};
#
5.5. Как выбрать кампанию из campaigns
После /visit или /event вы получаете массив campaigns.
Практическое правило, повторяющее поведение Flutter SDK:
- Отсортируйте кампании по
priorityпо убыванию. - Для каждой кампании попробуйте получить контент через
/choose(campaignId). - Если у кампании есть
delayTime > 0, дождитесь задержки перед показом. - Используйте первую кампанию, для которой реально пришёл контент.
Map<String, dynamic>? pickCampaignToRender(List<dynamic> campaigns) {
final sorted = [...campaigns]..sort(
(a, b) => (b['priority'] as int).compareTo(a['priority'] as int),
);
return sorted.isEmpty ? null : sorted.first as Map<String, dynamic>;
}
#
5.6. Пример /choose по campaignId
Future<Map<String, dynamic>> chooseByCampaignId({
required GravityApiClient api,
required GravitySessionStore store,
required Map<String, dynamic> device,
required Map<String, dynamic> ctx,
required String campaignId,
}) async {
final payload = {
'sec': api.sectionId,
'user': {
'uid': await store.readUid(),
'ses': store.readSessionId(),
},
'ctx': ctx,
'device': device,
'options': {
'isReturnCounter': false,
'isReturnUserInfo': false,
'isReturnAnalyticsMetadata': true,
'isImplicitPageview': false,
'isBuildEngagementUrl': true,
},
'data': [
{
'campaignId': campaignId,
'option': {
'skusOnly': false,
'fields': ['name', 'price', 'imageUrl'],
},
},
],
};
final response = await api.dio.post('/choose', data: payload);
return response.data as Map<String, dynamic>;
}
#
5.7. Полный flow
sequenceDiagram
participant App as Flutter App
participant API as Gravity API
App->>API: POST /visit или POST /event
API-->>App: user + campaigns[]
App->>App: sort by priority, apply delayTime
App->>API: POST /choose { campaignId }
API-->>App: data[] + payload[] + contents[] + events[]
App->>App: render custom UI
App->>API: GET tracking URL(s)
#
6. Сценарий B: inline, Custom JSON и selector-based A/B через /choose(selector)
Этот режим нужен, когда приложение заранее знает placement:
- "Вам может понравиться" на homepage;
- блок рекомендаций на PDP;
- selector-based A/B тест кнопки, баннера или блока;
- Custom JSON для BDUI и API-кампаний.
Бизнес-смысл совпадает с другими разделами документации про personalization и hybrid A/B: selector flow используется там, где приложение или backend заранее знает точку встраивания контента.
#
6.1. Пример /choose по selector
Future<Map<String, dynamic>> chooseBySelector({
required GravityApiClient api,
required GravitySessionStore store,
required Map<String, dynamic> device,
required Map<String, dynamic> ctx,
required String selector,
}) async {
final payload = {
'sec': api.sectionId,
'user': {
'uid': await store.readUid(),
'ses': store.readSessionId(),
},
'ctx': ctx,
'device': device,
'options': {
'isReturnCounter': false,
'isReturnUserInfo': false,
'isReturnAnalyticsMetadata': true,
'isImplicitPageview': false,
'isBuildEngagementUrl': true,
},
'data': [
{
'selector': selector,
'option': {
'skusOnly': false,
'fields': ['name', 'price', 'imageUrl'],
},
},
],
};
final response = await api.dio.post('/choose', data: payload);
final data = response.data as Map<String, dynamic>;
final user = data['user'] as Map<String, dynamic>?;
final uid = user?['uid'] as String?;
final ses = user?['ses'] as String?;
if (uid != null) await store.writeUid(uid);
if (ses != null) store.writeSessionId(ses);
return data;
}
#
6.2. Что делать с ответом
Практически полезный путь до контента:
data[0]- кампания;data[0].payload[0]- выбранная вариация;data[0].payload[0].contents[0]- блок контента;data[0].payload[0].decisionId- идентификатор решения;contents[0].events- content-level tracking;contents[0].products.slots[*].events- product-level tracking.
#
6.3. Fallback
Для inline и API/A/B placement-ов всегда закладывайте fallback:
- если
/chooseвернул пустойdata; - если товарная кампания вернула пустые
slots; - если запрос не успел уложиться в таймаут;
- если контент не удалось распарсить.
Обычно fallback выглядит так:
- не отображать блок;
- показать заглушку;
- показать стандартный banner;
- показать статический набор товаров.
#
7. Engagement, events[] и обработчики on*
Это критически важная часть прямой интеграции. Без engagement у кампаний не будет корректной статистики, а A/B результаты будут неполными или неверными.
Ниже описан рекомендуемый для v2 direct API подход: использовать tracking URL из events[].urls[] и slot.events[].urls[], а не выносить tracking-логику в отдельный reference-поток.
#
7.1. Главная концепция
В ответе /choose есть два источника tracking:
content.events[]- tracking для всего контентного блока;products.slots[].events[]- tracking для отдельных товарных карточек.
Принцип работы всегда один и тот же:
- UI получает
actionизvariables.onLoad,onImpression,onVisibleImpression,onCloseилиelement.onClick. - По этому
actionприложение находит подходящий объект вcontent.events[]. - Приложение вызывает все URL из
urls[].
Для товарных карточек логика такая же, но источник событий другой: slot.events[].
Важно:
- у content-level модели нет универсального обязательного
click; она строится вокругaction; - для product-level tracking модель проще:
impression,visible_impression,click.
#
7.2. Что и когда отправлять
Для visible_impression придерживайтесь той же модели, что и SDK: отправляйте событие один раз, когда элемент достиг как минимум 50% видимости.
#
7.3. Короткий пример content tracking
Future<void> triggerTrackingUrls(List<String> urls) async {
final trackingDio = Dio();
for (final url in urls) {
try {
await trackingDio.get(url);
} catch (_) {
// Не ломаем UI из-за tracking ошибки.
}
}
}
Future<void> sendContentTracking({
required Map<String, dynamic> content,
required String action,
}) async {
final events = content['events'] as List<dynamic>?;
if (events == null) return;
for (final rawEvent in events) {
final event = rawEvent as Map<String, dynamic>;
if (event['type'] == action && event['urls'] is List) {
final urls = (event['urls'] as List).map((e) => e.toString()).toList();
await triggerTrackingUrls(urls);
return;
}
}
}
Практически это выглядит так:
variables.onImpression.action->sendContentTracking(...)variables.onVisibleImpression.action->sendContentTracking(...)element.onClick.action-> сначалаsendContentTracking(...), потом ваше UI-действие
#
7.4. Короткий пример product tracking
Future<void> sendProductTracking({
required Map<String, dynamic> slot,
required String action,
}) async {
final events = slot['events'] as List<dynamic>?;
if (events == null) return;
for (final rawEvent in events) {
final event = rawEvent as Map<String, dynamic>;
if (event['type'] == action && event['urls'] is List) {
final urls = (event['urls'] as List).map((e) => e.toString()).toList();
await triggerTrackingUrls(urls);
return;
}
}
}
Обычно этого достаточно:
sendProductTracking(slot: slot, action: 'visible_impression')sendProductTracking(slot: slot, action: 'click')
#
8. Практические рекомендации
#
8.1. Таймауты и fallback
- Для inline placement-ов и A/B используйте строгие таймауты.
- Не блокируйте UI из-за
/choose. - Если API не ответил вовремя, показывайте fallback.
#
8.2. Не отправляйте engagement из build()
impression и visible_impression нужно защищать флагами и отправлять один раз. Иначе вы получите дубли.
#
8.3. Не меняйте значения из фида
Если ctx.data, productId, категории или lng берутся из фида, передавайте их без нормализации и без смены регистра.
#
8.4. Отдельно тестируйте пустые ответы
Нормальная ситуация:
/visitвернул пустойcampaigns;/eventвернул пустойcampaigns;/chooseвернул пустойdata;- товарный блок вернул пустые
slots.
Это не ошибка интеграции само по себе. В таких случаях приложение должно корректно показывать fallback.