Зачем это нужно
Каталог нужен там, где в чеке 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_codecatalog_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-delete—409 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_code(иerror_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.