Зачем это нужно
Каталог нужен там, где в чеке Kaspi покупатель должен видеть позиции (наименование, цена, количество), а не «Оплата 5000 ₸». Так работают магазины и точки с Kaspi ОФД (режим «Касса»): счёт собирается из позиций каталога через cart_items, и Kaspi формирует по ним фискальный чек.
Нацкаталог (ntin/gtin) нужен только для маркированных товаров: эти коды долетают в чек Kaspi и корректно идентифицируют товар в системе маркировки. Немаркированным товарам ntin/gtin не нужны — заводите их обычными позициями.
У организации с каталогом поле cart_items в счёте обязательно — «голую» сумму amount слать нельзя (получите 422). Обратное правило: организация без каталога не может слать cart_items. Разбор всех 422 вокруг корзины — в статье Ошибка 422 cart_items и Счета с корзиной.
Важно: эта статья — про публичный API мерчанта с заголовком
X-API-Key. Не путайте его с партнёрскимX-Partner-Key(онбординг чужих мерчантов) — это другая поверхность.
Завести каталог
Создание товаров — 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" }
]
}'
- Песочница →
201: товар сразуactive. Рабочий режим →202: товар в статусеpending, синхронизация в Kaspi отдельным джобом — позже статус станетactiveилиfailed. Не используйтеcatalog_item_idв счёте, пока позиция неactive. 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, и имя существующего товара НЕ перезаписывается. Практический итог: повторная заливка того же набора идемпотентна (все позиции matched_existing, ноль новых строк) — синк каталога по расписанию безопасен.
409 catalog_busyв ответеPOST /catalog— не удалось взять per-org lock за 5 с (параллельная заливка на ту же организацию). Не ошибка данных — просто повторите запрос.
Маркированные товары — 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. - Лимиты: 30/мин + 2000/сутки на ключ. Ошибки скана:
400при недействительной сессии — требуется переподключить кассира Kaspi;429 kaspi_throttled(тело —retry_after_seconds, заголовокRetry-After);503 kaspi_scan_unavailable(Нацкаталог временно недоступен). При троттлинге около 90 секунд сразу отдаётся429без похода в Kaspi.
Изображения — POST /catalog/upload-image. multipart/form-data, поле image (jpg/png/gif/webp, ≤10 МБ), дедупликация по MD5. Ответ — { image_id }; этот image_id передаёте в POST /catalog или в PATCH (для удаления картинки — is_image_deleted: true).
Продавать с корзиной
Счёт с позициями — это cart_items[] в обычном POST /invoices или POST /invoices/qr. Отдельного POST /invoices/with-cart в публичном API нет (это внутренний роут SPA-кабинета, не /api/v1).
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), тогда как у счёта по номеру — до 500.
Синхронизация чтением — GET /catalog
Четыре режима чтения (по приоритету параметров). Выбирайте под задачу:
По умолчанию GET /catalog во ВСЕХ 4 режимах отдаёт только active. Прочие статусы (pending/failed/deleting/deleted) — строго по явному опт-ину ?statuses[]=….
| Режим | Параметры | Что отдаёт | Когда использовать |
|---|---|---|---|
| targeted | ntins[] / barcodes[] / ids[] / external_refs[] (OR; суммарно ≤200 значений и ≤1000 строк) |
{ data: [...] } без пагинации; по умолчанию active, прочее — через statuses[] |
Подтвердить конкретный батч: «что стало с товарами, которые я только что создал» (по external_ref/ntin) |
| incremental | updated_after=<iso> |
{ data, links, meta }; по умолчанию active — удалённые добери statuses[]=deleted |
Забрать только изменённое с прошлой синхронизации — дельта в свою систему (зеркалить удаления → добавь statuses[]=active&statuses[]=deleted) |
| keyset | cursor=<из meta.next_cursor> |
meta-обёртка (курсорная: next_cursor/prev_cursor, без total); по умолчанию active |
Первичная/полная выгрузка 100k+ без deep-offset |
| default (offset) | page / per_page≤200 (default 50) |
meta-обёртка, только active; прочие статусы — через statuses[] |
Обычный постраничный просмотр небольшого каталога |
- Точечная сверка ограничена суммарно. Больше 200 значений по всем наборам (
external_refs[]+barcodes[]+ntins[]+ids[]) или больше 1000 строк соответствий →422 catalog_match_overflow. Тихого усеченияlimit(200)больше нет — разбивайте сверку на батчи по ~100. - Призраки не отдаются никогда. Строки
status='deleted'безkaspi_item_id(позиции, отклонённые ещё до отправки в Kaspi) исключаются во всех режимах — даже при явном?statuses[]=deleted. Настоящие удаления (deletedс непустымkaspi_item_id) через?statuses[]=deletedвидны. - Доп. фильтры:
statuses[](active/pending/deleting/deleted/failed),search(по названию),barcode(точный),first_char. - Поля товара (
CatalogItem):id,kaspi_item_id,name,unit_id,selling_price,image_url,barcode,ntin,gtin,unified_goods_id,external_ref,ntin_missing(barcode есть, НТИН нет → не попадёт в фискальный чек как маркированный),status,error_message,error_code,created_at,synced_at. Даты — UTC+00:00. Ещё два флага —matched_existingиname_differs— приходят только в ответеPOST /catalog(в листинге всегдаfalse). - Только для организаций с каталогом (иначе
400/404).
Рецепт связки «создал → подтвердил». Создали товары с external_ref (коды 1С) → в рабочем режиме получили 202 (pending) → через время читаете GET /catalog?external_refs[]=1c-0001&external_refs[]=1c-0002 → смотрите status: active — синхронизирован, failed — см. error_code.
Подтверждение вебхуком — catalog.item_processed. Кроме поллинга, финальный статус можно получить push-вебхуком catalog.item_processed — он включён по умолчанию (kill-switch на стороне сервера — env KASPI_CATALOG_WEBHOOKS_ENABLED). Payload несёт external_ref как ключ сверки (плюс id, kaspi_item_id, status, error_code, ntin_missing), так что SaaS с публичным URL не обязан поллить. Аудит доставок — новый read-only эндпоинт GET /catalog/webhook-logs (лог ротируется 3 дня). Поллинг по external_refs[] и вебхук — равноправные пути; поллинг проще для 1С/on-prem, вебхук дешевле для SaaS.
Обновление и удаление:
PATCH /catalog/{id}— все поля опциональны; песочница →200, рабочий режим →202. Важно:ntin/gtinзатирают идентичность Нацкаталога безвозвратно — передавайте их только при реальном изменении маркировки, иначеnullсотрёт коды.external_refвPATCHне принимается.DELETE /catalog/{id}— песочница →200(hard delete), рабочий режим →202(статус →deleting, удаление в Kaspi джобом).
Ошибки позиций
- У товара в каталоге при провале синхронизации статус
failed+ полеerror_code(иerror_message) — читаются изGET /catalog. Определяйте причину поerror_code, а не по тексту. - В корзине счёта: если
catalog_item_idневалиден (не принадлежит организации, удалён, без цены) — счёт не пройдёт валидацию (422, с индексом строкиcart_items.N). Проверяйте товары до продажи. - Slug'и
catalog_item_not_foundиimage_upload_failedв каталоге зарезервированы: на практике провал загрузки картинки не фатален (товар создаётся без изображения), а отказы удаления/несопоставления уходят вkaspi_error/unknown_error. Полный каталог error-кодов — на странице /errors и в публичной документации.
Частые ошибки
- Искать
POST /invoices/with-cart. Его нет в публичном API — счёт с позициями создаётся полемcart_itemsвPOST /invoices/POST /invoices/qr. - Слать
amountвместе с корзиной и ждать, что итог возьмётся из него. Приcart_itemsсумму считает сервер; управляйте ценой черезpriceв строке. - Использовать
catalog_item_idиз песочницы в рабочем режиме. Тестовые организации и их каталоги удаляются при переходе в прод — id там другие. - Передавать
ntin/gtinв обычномPATCH. Это безвозвратно затрёт идентичность Нацкаталога. Трогайте их только осознанно. - Слать счёт сразу после создания позиции в рабочем режиме. Позиция создаётся асинхронно (
202,pending) — дождитесьactive.
Частые вопросы
Как выставить счёт с позициями?
Через cart_items[] в POST /invoices или POST /invoices/qr. Каждая позиция — catalog_item_id (поле id из GET /catalog) + count. Отдельного with-cart в публичном API нет.
Обязательно ли слать корзину?
Для организаций с каталогом — да; amount при наличии cart_items игнорируется. Без каталога наоборот — cart_items слать нельзя.
Штрихкод не нашёлся в скане — это ошибка?
Нет. Пустой data[] (или scan_result.code != "ok") означает, что товара нет в Нацкаталоге. Ответ 200, не ошибка — создавайте товар обычной позицией без ntin/gtin.
Как выгрузить весь каталог на 100k+ позиций?
Keyset-режим GET /catalog?cursor= (без deep-offset), листая по meta.next_cursor. Только изменённое с прошлого раза — ?updated_after=<iso> (включая удалённые).
Можно ли поменять ntin/gtin через PATCH?
Технически да, но при обычном обновлении это затрёт идентичность Нацкаталога безвозвратно. Правьте их только при реальном изменении маркировки.
Где взять catalog_item_id для корзины?
Это поле id из GET /catalog. Оно же используется в return_items[] при поэлементных возвратах — см. Возвраты Kaspi через API.