> Источник: https://apipay.kz/guides/katalog-korzina-nackatalog · Обновлено: 2026-07-11 · 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` |
| Ответ создания | Песочница → `201` (товар сразу `active`); рабочий режим → `202` (`pending`, синк джобом) |
| Match-and-merge | Совпадение с существующим товаром → **живой `id`** + `matched_existing: true` (дубля/призрака нет); повторная заливка **идемпотентна** |
| Счёт с позициями | `cart_items[]` в `POST /invoices` / `POST /invoices/qr` (НЕ `with-cart`), 1–100 позиций |
| Позиция корзины | `{ "catalog_item_id": 1, "count": 2 }` — оба обязательны; `price` (опц.) переопределяет цену |
| Скан Нацкаталога | `POST /catalog/scan` `{ "input": "<штрихкод>" }`; лимит **30/мин + 2000/сутки** |
| Пустой результат скана | `data: []` или `scan_result.code != "ok"` = товар не из Нацкаталога — это **не ошибка** (`200`) |
| Изображение | `POST /catalog/upload-image` — jpg/png/gif/webp, **≤10 МБ**, дедуп по MD5 |
| Чтение каталога | `GET /catalog` — 4 режима: targeted / incremental / keyset / offset (`per_page`≤200); **по умолчанию только `active`** |
| Точечная сверка | targeted: суммарно **≤200 значений** по всем наборам и **≤1000 строк**, иначе `422 catalog_match_overflow` |
| Подтверждение синка | вебхук `catalog.item_processed` (default-on) **или** поллинг `GET /catalog?external_refs[]=` |
| Ссылка на 1С | `external_ref` (≤191) при создании; в `PATCH` **не принимается**; **UNIQUE** в пределах организации |

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

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

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

У организации **с каталогом** поле `cart_items` в счёте **обязательно** — «голую» сумму `amount` слать нельзя (получите 422). Обратное правило: организация **без** каталога не может слать `cart_items`. Разбор всех 422 вокруг корзины — в статье [Ошибка 422 cart_items](/guides/oshibka-422-cart-items) и [Счета с корзиной](/guides/scheta-s-korzinoy-cart-items-ofd).

> Важно: эта статья — про публичный 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`.

```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" }
    ]
  }'
# Песочница → 201 (товар сразу active). Рабочий режим → 202 (status: pending, синк в Kaspi джобом).
```

- **Песочница → `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 (синхронно, на сессии кассира):

```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`.
- **Лимиты: 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`).

```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), тогда как у счёта по номеру — до 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](/errors) и в [публичной документации](/docs).

## Частые ошибки

- **Искать `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](/guides/vozvraty-kaspi-cherez-api).

---

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