Каталог, корзина и Нацкаталог (ntin/gtin): как продавать с позициями?

Обновлено 26 августа 2026 · Справочник · Версия в Markdown
Содержание
  1. Зачем это нужно
  2. Завести каталог
  3. Продавать с корзиной
  4. Синхронизация чтением — GET /catalog
  5. Ошибки позиций
  6. Частые вопросы

Зачем это нужно

Каталог нужен там, где в чеке Kaspi покупатель должен видеть позиции (наименование, цена, количество), а не «Оплата 5000 ₸». Так работают магазины и точки с Kaspi ОФД (режим «Касса»): по позициям корзины Kaspi формирует фискальный чек.

Нацкаталог (ntin/gtin) нужен только для маркированных товаров: эти коды долетают в чек Kaspi и корректно идентифицируют товар в системе маркировки. Немаркированным товарам ntin/gtin не нужны — заводите их обычными позициями.

У организации с каталогом cart_items обязателен на POST /invoices/qr, POST /static-qr и POST /subscriptions; счёт по номеру (POST /invoices) и пакетный POST /invoices/bulk такая организация выставляет и одной суммой в amount. Организация без каталога cart_items слать не может. Разбор всех 422 вокруг корзины — в статьях Ошибка 422 cart_items и Счета с корзиной.

Завести каталог

Создание товаров — POST /catalog. Пачка 1–100 товаров за запрос. Обязательные поля позиции: name (≤255), selling_price (≥0.01), unit_id (единица измерения — список из GET /catalog/units). Опциональные: image_id (из upload-image), barcode (≤32 — лимит Kaspi, иначе barcode_too_long), ntin/gtin (≤50, из скана Нацкаталога), external_ref (≤191, клиентская ссылка — например код 1С), from_catalog.

curl -X POST https://api.apipay.kz/api/v1/catalog \
  -H "X-API-Key: ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "name": "Кофе Латте", "selling_price": 1800, "unit_id": 1, "external_ref": "1c-0001" }
    ]
  }'
  • Ответ всегда 202 — и в песочнице, и в рабочем режиме, одной и той же формы. В песочнице позиция возвращается уже активной; в рабочем режиме она получает pending, а позже станет active или failed. Не используйте catalog_item_id в счёте, пока позиция не продаётся: смотрите поле sellable.
  • Валидация по позициям. Битая позиция не роняет запрос: валидные уходят в data[], невалидные — в rejected[] (index, error_code catalog_item_invalid или barcode_too_long, error_message, опц. errors). Ключ rejected[] есть в ответе всегда. 422 остаётся только за структурой запроса: items отсутствует, не массив, пуст или длиннее 100.
  • Ответ построчный, общего идентификатора пачки в нём нет. Блок batch и poll_url удалены, ручки GET /catalog/batches/{id} не существует, а параметр ?batch_id= у GET /catalog и GET /catalog/errors отклоняется422 catalog_batch_filter_removed (пустое значение тоже). Свой набор подтверждайте точечным GET /catalog?external_refs[]=….
  • Идемпотентность — заголовок Idempotency-Key (≤191) или body-поле idempotency_key. Точный повтор того же тела отвечает 200 с idempotent_replay: true и ничего не выполняет заново; тот же ключ с другим телом либо на POST /catalog/bulk-delete409 idempotency_key_conflict (пространство ключей общее).
  • external_ref — ваша клиентская ссылка (код/GUID номенклатуры 1С, SKU). Задаётся при создании, индексируется; потом по нему читаете точечно (?external_refs[]=). В PATCH не принимается — менять нельзя. UNIQUE в пределах организации и match-and-merge его не перетирает; удалённый товар можно переиздать по тому же external_ref.

Match-and-merge (создание идемпотентно, дублей нет). Если позиция совпала с уже существующим товаром, POST /catalog возвращает живой id существующего товара с маркером matched_existing: true — а не ошибку и не дубль. Ярусы матчинга: (1) по external_ref; (2) по barcode/ntin при совпадении имени; (3) по barcode/ntin, но имя другое — тогда в ответе name_differs: true, и имя существующего товара НЕ перезаписывается. Повторная заливка того же набора идемпотентна (ноль новых строк) — синк каталога по расписанию безопасен.

  • 409 catalog_busy в ответе POST /catalog — каталог занят другой операцией по этой организации (параллельная заливка). Не ошибка данных — просто повторите запрос.

Маркированные товары — POST /catalog/scan. Резолвит штрихкод в Нацкаталоге Kaspi (синхронно, на сессии кассира):

curl -X POST https://api.apipay.kz/api/v1/catalog/scan \
  -H "X-API-Key: ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "input": "4607015232646" }'
  • Один штрихкод может дать несколько кандидатов (общий gtin, разные ntin) — какой из них товар, решает мерчант.
  • Пустой data[] и/или scan_result.code != "ok" = товар не из Нацкаталога. Это НЕ ошибка (200) — создавайте товар обычным POST /catalog без ntin/gtin.
  • Дальше берёте ntin/gtin/barcode выбранного кандидата и передаёте в POST /catalog.
  • normalized_barcode — эхо присланного значения, и в песочнице, и в рабочем режиме. Нормализация штрихкода не обещается: не стройте на этом поле логику приведения форматов.
  • Лимиты: 30/мин + 2000/сутки на ключ. Ошибки скана: 400 при недействительной сессии — требуется переподключить кассира Kaspi; 429 kaspi_throttled (тело — retry_after_seconds, заголовок Retry-After) — повторяйте не раньше указанного времени; 503 kaspi_scan_unavailable (Нацкаталог временно недоступен).

Изображения — POST /catalog/upload-image. multipart/form-data, поле image: только JPEG и PNG, ≤6 МБ, стороны 64…6000 px, площадь до 12 Мпикс. Тип определяется по содержимому файла, а не по имени и Content-Type: gif/webp/bmp/svg и подменённое расширение → 422 invalid_file_type, габариты вне диапазона → 422 image_rejected, файл больше 6 МБ → 413 file_too_large. Лимиты — 60/мин и 2000/сутки на ключ. Изображение перекодируется в JPEG ≤512×512, дедуп по MD5 результата. Ответ — { image_id }; передаёте его в POST /catalog или в PATCH (удалить картинку — is_image_deleted: true).

Продавать с корзиной

Счёт с позициями — это cart_items[] в обычном POST /invoices или POST /invoices/qr; POST /invoices/with-cart — внутренний роут SPA-кабинета, в публичном API его нет.

curl -X POST https://api.apipay.kz/api/v1/invoices \
  -H "X-API-Key: ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "87770000001",
    "description": "Заказ №123",
    "cart_items": [
      { "catalog_item_id": 1, "count": 2, "price": 4500 },
      { "catalog_item_id": 5, "count": 3 }
    ],
    "discount_percentage": 10
  }'
  • catalog_item_id (обязателен) — это поле id товара из GET /catalog; товар должен принадлежать организации, быть не удалён и иметь цену. count (обязателен, ≥1). price (опц.) — кастомная цена за единицу, заменяет каталожную для этой строки.
  • cart_items: 1–100 позиций. Сумму считает сервер по позициям; переданный amount при наличии cart_items игнорируется.
  • Скидка на весь чек — discount_percentage (1–99). Скидку внутри позиции API не принимает.
  • Поля Нацкаталога позиций (barcode/ntin/gtin) подтягиваются автоматически из каталога — их НЕ нужно передавать в cart_items руками. В прочитанном счёте они видны в items[] (nullable).
  • QR-нюанс: у POST /invoices/qr поле description ограничено 100 символами (это наименование позиции в QR-чеке Kaspi), тогда как у счёта по номеру — до 60.

Синхронизация чтением — GET /catalog

Четыре режима чтения (по приоритету параметров). По умолчанию во всех четырёх GET /catalog отдаёт только active; прочие статусы (pending/failed/deleting/deleted) — по явному ?statuses[]=….

Режим Параметры Что отдаёт Когда использовать
targeted ntins[] / barcodes[] / ids[] / external_refs[] (OR) { data: [...] } без пагинации Подтвердить свой набор: «что стало с товарами, которые я только что создал» (по external_ref/ntin)
incremental updated_after=<iso> { data, links, meta } Забрать только изменённое с прошлой синхронизации — дельта в свою систему (зеркалить удаления → добавь statuses[]=active&statuses[]=deleted)
keyset cursor=<из meta.next_cursor> meta-обёртка (курсорная: next_cursor/prev_cursor, без total) Первичная/полная выгрузка 100k+ без deep-offset
default (offset) page / per_page≤200 (default 50) meta-обёртка Обычный постраничный просмотр небольшого каталога
  • Точечная сверка ограничена суммарно. Больше 200 значений по всем наборам (external_refs[]+barcodes[]+ntins[]+ids[]) или больше 1000 строк соответствий → 422 catalog_match_overflow. Разбивайте сверку на части по ~100 значений.
  • Призраки не отдаются никогда. Строки status='deleted' без kaspi_item_id (позиции, отклонённые ещё до отправки в Kaspi) исключаются во всех режимах — даже при явном ?statuses[]=deleted. Настоящие удаления (deleted с непустым kaspi_item_id) через ?statuses[]=deleted видны.
  • Фильтра batch_id больше нет — запрос с ним отбивается 422 catalog_batch_filter_removed, и пустое значение тоже.
  • Доп. фильтры: search (по названию), barcode (точный), first_char, without_ntin.
  • Поля товара (CatalogItem): id, kaspi_item_id, name, unit_id, selling_price, image_url, barcode, ntin, gtin, unified_goods_id, external_ref, ntin_missing (barcode есть, НТИН нет → не попадёт в фискальный чек как маркированный), status, operation, sellable, in_kaspi_catalog, error_message, error_code, created_at, synced_at. Даты — UTC +00:00. Ещё два флага — matched_existing и name_differs — приходят только в ответе POST /catalog (в листинге всегда false).
  • Только для организаций с каталогом (иначе 400/404).
  • Если каталог остаётся пустым, причину отдаёт поле catalog_block_reason в ответе POST /api/v1/connections/{id}/auth/verify-otp: no_tradepoint (код торговой точки не определён), no_idn, idn_conflict, no_kaspi_org_id. Во всех случаях каталог не заработает без поддержки. Список значений открыт: неизвестный код обрабатывайте общей веткой, а не switch без default.

Статус, операция, продаваемость — три разных вопроса

Словарь status прежний: active, pending, deleting, deleted, failed. Разбирать его как раньше можно.

Но статус не отвечает на два практических вопроса, и выводить их из него не нужно — для них есть отдельные поля.

Поле На какой вопрос отвечает Значения
operation какая работа над позицией не закрыта create, update, delete или null
sellable примут ли позицию в корзину счёта, QR или подписки true / false
in_kaspi_catalog уедет ли позиция каталожным товаром с маркировкой Нацкаталога true / false

sellable: false — у снятой позиции и у позиции, над которой висит намерение удаления, даже если попытки удаления прекращены. Повторный POST /catalog или PATCH возвращает такую позицию в продажу.

Ещё один случай: снятая позиция, которую уже заводят заново, читается как pending с operation: create — в каталоге Kaspi её сейчас нет, поэтому продавать ею пока нельзя.

⚠️ Не путайте с впервые заведённой позицией: у неё sellable: true уже в ответе POST /catalog, хотя статус тоже pending. Продавать ею можно сразу — но пока in_kaspi_catalog: false, она уйдёт разовой продажей.

in_kaspi_catalog: false — счёт создастся, но позиция уедет разовой продажей: маркировка Нацкаталога в фискальный чек по ней не проводится.

⚠️ Это не то же самое, что sellable: позиция может продаваться без каталожной идентичности и наоборот. Одним флагом два факта не выражаются.

⚠️ В песочнице in_kaspi_catalog всегда false. Тестовые позиции носят синтетическую идентичность номенклатуры — это свойство тестового контура, а не поломка и не предсказание для боевой позиции. Маркировку проверяют только на боевой оси организации.

Подтверждение вебхуком — catalog.item_processed

Событие приходит по каждой позиции каталога, включая позиции большой заливки. Отключить его нельзя.

⚠️ Заливка на 50 позиций даёт до 50 доставок — приёмник должен это выдержать. Отдельного события «залив завершён» не существует: считайте залив законченным у себя, по своему списку external_ref.

В теле события: id, external_ref, kaspi_item_id, name, barcode, ntin, gtin, ntin_missing, status, operation, sellable, in_kaspi_catalog, error_code, error_message, failed_at.

Поле status там — тот же производный статус и тот же словарь, что в GET /catalog; значения в событии и в листинге совпадают всегда.

⛔ Событие catalog.batch_processed не отправляется никогда. Адрес и подпись прежние, просто тишина — по ошибке об этом не узнать. Если ваш обработчик ждал его, переключитесь на catalog.item_processed.

Переключателя у каталожных вебхуков нет: они уходят на все активные ключи организации с заданным webhook URL, ключ сверки — external_ref. Аудит доставок — read-only GET /catalog/webhook-logs (лог ротируется 3 дня).

В песочнице позиция закрывается уже в момент ответа на POST /catalog — результат читайте из тела ответа и через GET /catalog; обработчик вебхука проверяйте на боевой заливке.

Обновление и удаление:

  • PATCH /catalog/{id} — все поля опциональны. Код ответа зависит от позиции, а не от режима организации: правка песочной позиции применена уже в момент ответа (200), правка боевой принята в работу (202). Важно: ntin/gtin затирают идентичность Нацкаталога безвозвратно — передавайте их только при реальном изменении маркировки, иначе null сотрёт коды. external_ref в PATCH не принимается.
  • DELETE /catalog/{id} — тот же принцип: песочная позиция 200, боевая 202. В песочнице удаление логическое: строка получает status: deleted, читается через ?statuses[]=deleted и возвращается повторной заливкой с тем же id.
  • ⚠️ В рабочем режиме 202 — не подтверждение. Доставка в Kaspi идёт после ответа и при загруженной очереди может занять больше минуты. Исход проверяйте точечным GET /catalog, счётчиками GET /catalog/queue, журналом GET /catalog/errors или вебхуком.
  • ⚠️ PATCH по позиции с operation: delete отменяет удаление: запрос означает «позиция мне нужна». А PATCH по уже снятой позиции (status: deleted) вернёт 404 — заводите её заново обычным POST /catalog.
  • ⚠️ У одиночного DELETE неоднозначная торговая точка не даёт синхронный отказ: запрос принимается с 202, а catalog_multi_tradepoint появляется на самой позиции в поле error_code.

Ошибки позиций

  • У товара при провале статус failed, причина — в error_codeerror_message), а вид отказавшей работы — в operation. Всё читается из GET /catalog; момент отказа (failed_at) — из GET /catalog/errors. Определяйте причину по error_code, а не по тексту.
  • В корзине счёта невалидный catalog_item_id (чужой, удалён, без цены) отбивает счёт с 422 и индексом строки cart_items.N — разбор в статье Ошибка 422 cart_items.
  • Полный каталог error-кодов — на странице /errors и в публичной документации.

Частые вопросы

Где взять catalog_item_id для корзины?

Это поле id из GET /catalog. Оно же используется в return_items[] при поэлементных возвратах — см. Возвраты Kaspi через API.

Интеграция с ИИ

Не настраивайте вручную — поручите ИИ

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

Для разработчика. Полная спецификация API — apipay.kz/docs. Эта статья в Markdown — /guides/katalog-korzina-nackatalog.md. Справочник фактов для ИИ-агента — apipay.kz/llms.txt.