Правило №1: маппинг по `external_ref`, не по штрихкоду
Проставляйте у каждого товара external_ref — ссылку в 1С (код номенклатуры, GUID, артикул, ≤191 символа). Это единственный надёжный якорь:
external_refUNIQUE в пределах организации — один товар ApiPay на одну вашу ссылку.- Задаётся при создании; в
PATCH /catalog/{id}не принимается (менять якорь нельзя). - Сверять по нему точечно:
GET /catalog?external_refs[]=1c-000123.
Почему не штрихкод: Kaspi разрешает один товар на штрихкод/НТИН, а в 1С под одним штрихкодом могут висеть несколько SKU (разные фасовки). При сверке по штрихкоду вы не знаете, какой из id ваш. external_ref снимает неоднозначность полностью.
Match-and-merge: как ведёт себя `POST /catalog`
При создании ApiPay синхронно сопоставляет каждую позицию пачки с существующими активными товарами организации. Ярусы (по приоритету):
| Ярус | Совпадение | Что происходит |
|---|---|---|
| 1 | external_ref совпал |
Полный update: имя + цена, дозаполнение пустых ntin/gtin/barcode. Вернётся живой id |
| 2 | штрихкод или НТИН совпал и имя совпало | Update цены + дозаполнение пустых полей (в т.ч. НТИН — лечит «штрихкод есть, НТИН пуст») |
| 3 | штрихкод или НТИН совпал, имя другое | Матч без перезаписи имени/цены, маркер name_differs: true. Правьте имя явным PATCH /catalog/{id} |
- При матче ответ несёт
matched_existing: trueи живой id существующего товара;external_refсуществующего товара никогда не перетирается. - Повторная заливка того же каталога идемпотентна: все позиции вернутся
matched_existing: true, ноль новых строк, обращений в Kaspi по неизменным полям нет. - Дозаполненные/изменённые поля (имя, цена, НТИН/GTIN/штрихкод) уезжают в Kaspi асинхронно.
Переиздание снятого товара: если external_ref указывает на ранее снятый (deleted) товар — та же строка (тот же id) заново отправляется в Kaspi.
⚠️ До подтверждения такая строка читается как status: pending с operation: create, но в каталоге Kaspi её пока нет — поэтому sellable: false. Ставить её в счёт ещё нельзя.
Это исключение: у впервые заведённой позиции sellable равен true уже в ответе POST /catalog.
Если external_ref попал в товар с отказавшей операцией — вернётся его строка с текущими status, operation, error_code и failed_at, без автоматического повтора: исправьте данные и отправьте PATCH /catalog/{id}.
⛔ Если позиция уже снята (status: deleted), PATCH по ней вернёт 404 — правка ищет только живые позиции. Заводите её заново обычным POST /catalog: по тому же external_ref вернётся та же строка с тем же id.
Пример: заливка пачки
curl -X POST https://api.apipay.kz/api/v1/catalog \
-H "X-API-Key: ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "name": "Ручка гелевая синяя 0.5", "selling_price": 350, "unit_id": 1,
"barcode": "4870000000001", "external_ref": "1c-000123" },
{ "name": "Тетрадь 48 листов клетка", "selling_price": 420, "unit_id": 1,
"barcode": "4870000000002", "external_ref": "1c-000124" }
]
}'
Ответ — 202 (новые позиции — pending, сматченные — с их текущим статусом):
{
"data": [
{ "id": 5001, "external_ref": "1c-000123", "name": "Ручка гелевая синяя 0.5",
"barcode": "4870000000001", "ntin": null, "status": "pending",
"operation": "create", "sellable": true, "in_kaspi_catalog": false,
"matched_existing": false, "name_differs": false, "ntin_missing": true },
{ "id": 4980, "external_ref": "1c-000124", "name": "Тетрадь 48 листов клетка",
"barcode": "4870000000002", "ntin": null, "status": "active",
"operation": null, "sellable": true, "in_kaspi_catalog": true,
"matched_existing": true, "name_differs": false, "ntin_missing": true }
],
"rejected": []
}
Первая позиция создана заново (pending), вторая сматчена с существующим товаром (matched_existing: true, живой id 4980). ntin_missing: true — штрихкод есть, а НТИН пустой (в чек как маркировка не уйдёт).
Ключ rejected присутствует в ответе всегда: туда попадают позиции, не прошедшие проверку (error_code: catalog_item_invalid) — они не роняют весь запрос, их чинят и отправляют отдельно. Разбирайте только data[] — и часть номенклатуры пропадёт молча.
Общего идентификатора у отправленной пачки нет. Ответ построчный, и следить «за партией» не за что: список отправленных external_ref держите у себя.
Подтверждение заливки: вебхук ИЛИ чтение
Ответ 202 означает «принято в обработку», а не «готово». Итог по позиции (active или failed) приходит асинхронно.
Два равноправных способа его узнать.
Путь A — вебхук catalog.item_processed (SaaS с публичным URL)
Событие приходит по каждой позиции, включая позиции большой заливки.
⚠️ Значит, заливка на 50 позиций даёт до 50 доставок — приёмник должен это выдержать. Отдельного события «залив завершён» не существует: считайте залив законченным у себя, когда закрыт весь ваш список external_ref.
Сверяйтесь по external_ref — это ваш ключ 1С. Поле status в событии — тот же словарь, что в GET /catalog, и значения всегда совпадают; при failed смотрите error_code, момент отказа — в failed_at. Рядом едут sellable и in_kaspi_catalog.
Состав события целиком — в статье Массовая заливка каталога 1С.
⛔ Событие catalog.batch_processed не отправляется никогда. Если ваш код ждал именно его — он ждёт молча и по ошибке об этом не узнает: адрес и подпись прежние, просто тишина. Переключитесь на catalog.item_processed.
Каталожные вебхуки уходят на все активные ключи организации, у которых задан webhook URL. Проверить, что доставки реально ушли (и с каким кодом ответа), можно read-only логом GET /catalog/webhook-logs (фильтры status=success|failed, catalog_item_id, created_after). Не путать с GET /webhook-logs — там доставки по счетам. Логи каталожных вебхуков хранятся 3 дня — для длительного аудита складывайте их у себя.
В песочнице позиция закрывается уже в момент ответа на POST /catalog: результат виден в теле ответа и чтением GET /catalog. Обработчик вебхука проверяйте на боевой заливке.
Путь B — чтение targeted-GET (1С/on-prem, дёшево)
Если публичного URL для вебхука нет — просто перечитайте залитые позиции точечно по external_ref:
curl -G https://api.apipay.kz/api/v1/catalog \
-H "X-API-Key: ВАШ_КЛЮЧ" \
--data-urlencode "external_refs[]=1c-000123" \
--data-urlencode "external_refs[]=1c-000124" \
--data-urlencode "statuses[]=active" \
--data-urlencode "statuses[]=pending" \
--data-urlencode "statuses[]=failed"
{
"data": [
{ "id": 5001, "external_ref": "1c-000123", "status": "active",
"operation": null, "sellable": true, "in_kaspi_catalog": true,
"kaspi_item_id": "90210", "ntin_missing": true },
{ "id": 4980, "external_ref": "1c-000124", "status": "pending",
"operation": "create", "sellable": true, "in_kaspi_catalog": false,
"kaspi_item_id": null, "ntin_missing": true }
]
}
⚠️ Статусы перечисляйте явно. Без statuses[] придут только активные позиции, и то, что ещё в работе или упало, вы просто не увидите.
Для 1С чтение дешевле вебхука — не нужно открывать сервис наружу, а результат тот же.
Сколько работы осталось в целом, показывает GET /catalog/queue: total (заведение), updating (правки), deleting (снятие).
⛔ Фильтр batch_id у GET /catalog и GET /catalog/errors отклоняется — 422 catalog_batch_filter_removed. Отбивается даже пустой ?batch_id=, поэтому параметр надо убрать из запроса целиком.
Статусная модель и `default = active`
GET /catalog по умолчанию отдаёт только активные товары — в любом режиме (offset/keyset/incremental/targeted). Чтобы увидеть другие статусы, передавайте statuses[] явно:
?statuses[]=active&statuses[]=failed— активные + провалившиеся.- Зеркалирование удалений (чтобы гасить в 1С то, что удалили в ApiPay):
?updated_after=<iso>&statuses[]=active&statuses[]=deleted— инкремент, включающий удалённые. - «Призраки» (
deletedбезkaspi_item_id— позиции, которых никогда не было в Kaspi) не отдаются никогда, даже при?statuses[]=deleted. Настоящие удаления (deletedсkaspi_item_id) черезstatuses[]=deletedдоступны.
Словарь status прежний — пять значений, разбирать его как раньше можно.
Рядом со статусом у позиции есть три поля, которые отвечают на вопросы, на которые статус не отвечает:
| Поле | Вопрос |
|---|---|
operation (create / update / delete / null) |
какая работа над позицией не закрыта |
sellable |
можно ли уже ставить позицию в счёт |
in_kaspi_catalog |
уедет ли позиция каталожным товаром с маркировкой Нацкаталога |
⚠️ Выводить эти ответы из status не нужно, и одним флагом они не выражаются. Разбор — в статье Каталог, корзина и Нацкаталог.
Полная синхронизация: удалить остаток
Чтобы снять с продажи то, чего в выгрузке 1С больше нет, список ушедших позиций строите вы сами.
Сервер не догадывается, что именно вы считаете остатком: ни меток прогона, ни фильтров у массового удаления нет. POST /catalog/bulk-delete принимает ровно один явный список — ids[] либо external_refs[], до 200 значений.
Порядок один и тот же на каждой части списка.
1. Залейте актуальный каталог целиком. Пока заливка не прошла, вы не знаете, чего в ней нет.
2. Постройте у себя список ушедших external_ref и разбейте его на части по 200 значений.
3. Проверьте часть разведкой — dry_run: true. Ответ покажет would_delete (сколько позиций попадёт под снятие) и образец строк. Разведка ничего не меняет и Idempotency-Key не расходует.
curl -X POST https://api.apipay.kz/api/v1/catalog/bulk-delete \
-H "X-API-Key: ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{ "external_refs": ["1c-000900", "1c-000901"], "dry_run": true }'
4. Поставьте ту же часть в очередь — то же тело без dry_run, с числом из would_delete в необязательном expected_count и со своим Idempotency-Key на каждую часть.
curl -X POST https://api.apipay.kz/api/v1/catalog/bulk-delete \
-H "X-API-Key: ВАШ_КЛЮЧ" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: cleanup-2026-08-26-part-003" \
-d '{ "external_refs": ["1c-000900", "1c-000901"], "expected_count": 2 }'
expected_count — страховка от того, что множество изменилось между разведкой и запуском. Если число не сошлось, придёт 409 catalog_bulk_delete_mismatch с фактическим actual_count, и не удаляется ничего.
Ответ 202 содержит queued (сколько позиций поставлено в очередь) и buried (сколько недоставленных «призрачных» строк закрыто локально).
Ещё два поля отвечают на вопрос «что уже стояло на снятие до меня»: already_queued и already_queued_count. Всегда считайте по already_queued_count — это полное число.
⚠️ У поля already_queued форма разная: в ответе 202 это список id, обрезанный до 200 элементов (образец, а не полный перечень), а в ответе разведки dry_run — просто число. Код, который считает длину массива, на разведке сломается.
⚠️ Общего хендла у операции нет. Ответ 202 — это «принято в работу», а не «удалено». Остаток смотрите в GET /catalog/queue (счётчик deleting), строки — точечным GET /catalog, отказы — в GET /catalog/errors; итог по каждой строке присылает catalog.item_processed.
⛔ Не ставьте на этот запрос HTTP-таймаут в расчёте на завершение работы.
Это фоновая операция на часы и сутки. Позиции снимаются по одной, а если параллельно идёт заливка каталога — заметно медленнее. Порядок величин: 500 позиций — около двух часов, несколько тысяч — сутки с небольшим, при параллельной заливке умножьте примерно вчетверо. Планируйте окно исходя из этого, а не из времени ответа.
Ключ должен быть выпущен владельцем организации. Ключ, привязанный к сотруднику, получает 403 catalog_delete_owner_key_required — перевыпустите его от имени владельца. Требование есть только у массового удаления: снести каталог целиком — не рутинная операция интегратора.
Пока позиция снимается, продать её нельзя. Позиция, над которой висит намерение удаления, не принимается в корзину ни на одной платёжной поверхности (POST /invoices, /invoices/bulk, /invoices/qr, печатный QR, повторяющиеся счета, биллинг подписок) — придёт 422, а причина будет в errors["cart_items.N.catalog_item_id"]. Иначе счёт стал бы фискальным документом на товар, которого к моменту оплаты в кассе уже не будет.
⚠️ Это касается и уже напечатанных листов POST /static-qr, где такие позиции зашиты в корзину: скан листа не создаст счёт, а переиздать напечатанный лист нельзя. Если под снятие попадают позиции из действующих листов — верните их или перевыпустите листы.
⚠️ В поле sellable это видно прямо: у позиции с operation: delete оно false даже после того, как попытки удаления прекращены.
Как вернуть позицию из очереди. Пришлите её обычным POST /catalog — намерение удаления заменится, и позиция вернётся в работу. По external_ref это однозначно всегда: вернётся та же строка с тем же id.
По штрихкоду или НТИН — только если тем же штрихкодом не занят другой живой товар: заливка сольётся в него, а приговорённая позиция уйдёт по вашему плану.
⛔ Отдельного отказа «снятие уже отправлено» больше нет: код catalog_delete_in_progress удалён, и POST /catalog либо PATCH теперь просто означает «позиция мне нужна».
В песочнице удаление логическое. Строка не стирается: она получает status: deleted, читается запросом GET /catalog?statuses[]=deleted и возвращается повторной заливкой с тем же id.
Лимиты, троттлы и таймауты
| Параметр | Значение |
|---|---|
Пачка POST /catalog |
до 100 позиций за запрос |
Список POST /catalog/bulk-delete |
до 200 значений в одном списке |
| Общий лимит ключа | 200 запросов/мин (при 429 — заголовок Retry-After) |
GET /catalog/queue и GET /catalog/errors |
600 запросов/мин, общий бюджет не расходуют |
Targeted-вход GET /catalog |
суммарно ≤200 значений по ntins[]+barcodes[]+ids[]+external_refs[] |
| Targeted-выход | ≤1000 строк соответствий; превышение любого → 422 catalog_match_overflow |
| Штрихкод | ≤32 символа (лимит Kaspi) |
- Превышение таргетед-лимитов — явная
422 catalog_match_overflow(вход >200 значений или выход >1000 строк). Разбивайте сверку на части, например по 100external_ref. - Рекомендуемый клиентский read-timeout ≥ 15 секунд. Сопоставление на создании и точечное чтение больших наборов укладываются в этот бюджет; на слабом канале не ставьте таймаут в 3–5 с.
- Не распараллеливайте заливку в много потоков на один кассир. Частые параллельные запросы вызывают паузы обработки — лейте пачками по 100 последовательно.
Ошибки и что делать
| Ошибка | Где | Что делать |
|---|---|---|
catalog_item_invalid |
элемент массива rejected[] в ответе POST /catalog |
Позиция не прошла валидацию (name/selling_price/unit_id). Конкретика — в error_message и карте errors. Исправьте и переотправьте только эту позицию |
catalog_match_overflow |
422 на GET /catalog и POST /catalog/bulk-delete |
Слишком много значений (>200) или строк соответствий (>1000). Разбейте запрос на меньшие части |
catalog_busy |
409 на POST /catalog и POST /catalog/bulk-delete |
Каталог занят другой операцией по этой организации. Повторите запрос через несколько секунд |
idempotency_key_conflict |
409 на POST /catalog и POST /catalog/bulk-delete |
Этот Idempotency-Key уже занят другим телом либо другой каталожной операцией — пространство ключей общее. Возьмите новый ключ; повторять изменённый запрос со старым бесполезно |
catalog_batch_filter_removed |
422 на GET /catalog и GET /catalog/errors |
В запросе остался параметр batch_id. Уберите его целиком — пустое значение тоже отбивается |
sandbox_catalog_limit |
400 на POST /catalog |
Лимит песочницы — 1000 позиций. Тестируйте на меньшем объёме или подключите платный тариф |
catalog_delete_scope_required |
422 на POST /catalog/bulk-delete |
Не задан ни ids[], ни external_refs[] — либо заданы оба сразу. Передайте ровно один список |
catalog_bulk_delete_mismatch |
409 на POST /catalog/bulk-delete |
expected_count не совпал с фактом (он в actual_count); не удалено ничего. Повторите dry_run и возьмите свежее число |
catalog_delete_owner_key_required |
403 на POST /catalog/bulk-delete |
Ключ выпущен не владельцем организации. Перевыпустите ключ от имени владельца |
catalog_multi_tradepoint |
409 на POST /catalog/bulk-delete |
У организации несколько торговых точек, и набор не к чему отнести однозначно. Напишите в поддержку: 77003076512 |
Причину провала определяйте по error_code, а не по тексту error_message. Коды упавших позиций залива (catalog_item_duplicate, barcode_too_long и прочие) и что с ними делать — в статье Массовая заливка каталога 1С.
Частые вопросы
По какому полю маппить товары между 1С и ApiPay?
По external_ref — вашей ссылке на номенклатуру. Не по штрихкоду и не по имени: по одному штрихкоду бывает несколько связанных позиций, а имена меняются. external_ref уникален в пределах организации.
Пришёл matched_existing: true с name_differs: true — что делать?
Штрихкод/НТИН совпали с существующим товаром, но имя другое (для Kaspi это тот же товар). Имя мы не перезаписали. Если ваше имя правильное — отправьте его явным PATCH /catalog/{id}.
Как отметить у себя, что заливка закончилась?
По своему списку external_ref. Серверного признака «эта заливка завершена» нет: ответ построчный, номера партии не существует, а catalog.item_processed приходит по каждой позиции отдельно.
Да
, если это новая позиция: у неё sellable: true уже в ответе POST /catalog.
Что дальше
- Массовая заливка: тысячи позиций из 1С — Массовая заливка каталога 1С.
- Трек D: общая интеграция с 1С — Интеграция ApiPay с 1С.
- Трек M: ведёте каталог руками — статья Как заполнить каталог.