> Источник: https://apipay.kz/guides/katalog-korzina-nackatalog · Обновлено: 2026-08-26 · apipay.kz
> ApiPay — независимый сервис приёма платежей поверх вашего Kaspi Pay.

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

**TL;DR.** Чтобы в чеке Kaspi у покупателя были **позиции**, а не одна сумма, счёт собирается из каталога: заводите товары через `POST /catalog` (пачка 1–100 за запрос), а счёт создаёте с полем `cart_items[]` в обычном `POST /invoices` или `POST /invoices/qr`. Отдельного эндпоинта `POST /invoices/with-cart` в публичном API **нет** — только `cart_items`. Для **маркированных** товаров коды Нацкаталога (`ntin`/`gtin`) берутся сканом штрихкода: `POST /catalog/scan` (лимит 30/мин + 2000/сутки). Читать каталог обратно в свою систему можно в 4 режимах — от постраничного просмотра до keyset-экспорта каталога на 100k+ позиций.

## Коротко

| Параметр | Значение |
|---|---|
| Создать товары | `POST /catalog` — пачка **1–100**; обязательны `name`, `selling_price` (≥0.01), `unit_id` |
| Ответ создания | Всегда `202`: в песочнице позиция уже `active`, в рабочем режиме — `pending` до подтверждения |
| Счёт с позициями | `cart_items[]` в `POST /invoices` / `POST /invoices/qr` (НЕ `with-cart`), 1–100 позиций |
| Позиция корзины | `{ "catalog_item_id": 1, "count": 2 }` — оба обязательны; `price` (опц.) переопределяет цену |
| Скан Нацкаталога | `POST /catalog/scan` `{ "input": "<штрихкод>" }`; лимит **30/мин + 2000/сутки** |
| Изображение | `POST /catalog/upload-image` — **только JPEG/PNG**, **≤6 МБ** (больше — `413`) |
| Чтение каталога | `GET /catalog` — 4 режима: targeted / incremental / keyset / offset (`per_page`≤200); **по умолчанию только `active`** |
| Подтверждение синка | вебхук `catalog.item_processed` (**по каждой позиции**) **или** чтение `GET /catalog?external_refs[]=` с явными `statuses[]` |
| Можно ли продавать позицию | поле `sellable`, а не `status` |

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

Каталог нужен там, где в чеке 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](/guides/oshibka-422-cart-items) и [Счета с корзиной](/guides/scheta-s-korzinoy-cart-items-ofd).

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

**Создание товаров — `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`.

```bash
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-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 (синхронно, на сессии кассира):

```bash
curl -X POST https://api.apipay.kz/api/v1/catalog/scan \
  -H "X-API-Key: ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "input": "4607015232646" }'
# 200: data[] — кандидаты { id, name, ntin, gtin, barcode, unit_id, image_link } + normalized_barcode + scan_result.code
```

- Один штрихкод может дать **несколько кандидатов** (общий `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 его нет.

```bash
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
  }'
# 201, status: processing. В прочитанном счёте items[] несут barcode/ntin/gtin из каталога.
```

- **`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](/guides/oshibka-422-cart-items).
- Полный каталог error-кодов — на странице [/errors](/errors) и в [публичной документации](/docs).

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

**Где взять `catalog_item_id` для корзины?**
Это поле `id` из `GET /catalog`. Оно же используется в `return_items[]` при поэлементных возвратах — см. [Возвраты Kaspi через API](/guides/vozvraty-kaspi-cherez-api).

---

ApiPay — независимый сервис и не аффилирован с АО «Kaspi Bank». Kaspi и Kaspi Pay — товарные знаки их правообладателя.
База знаний: https://apipay.kz/guides
