Каталог для 1С в ApiPay: синхронизация без дублей

Обновлено 26 августа 2026 · Справочник · Версия в Markdown
Содержание
  1. Правило №1: маппинг по `external_ref`, не по штрихкоду
  2. Match-and-merge: как ведёт себя `POST /catalog`
  3. Подтверждение заливки: вебхук ИЛИ чтение
  4. Статусная модель и `default = active`
  5. Полная синхронизация: удалить остаток
  6. Лимиты, троттлы и таймауты
  7. Ошибки и что делать
  8. Частые вопросы
  9. Что дальше

Правило №1: маппинг по `external_ref`, не по штрихкоду

Проставляйте у каждого товара external_ref — ссылку в 1С (код номенклатуры, GUID, артикул, ≤191 символа). Это единственный надёжный якорь:

  • external_ref UNIQUE в пределах организации — один товар 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 строк). Разбивайте сверку на части, например по 100 external_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.

Что дальше

Интеграция с ИИ

Не настраивайте вручную — поручите ИИ

Скопируйте готовый промпт и вставьте своему ИИ-ассистенту — он настроит интеграцию за вас: получит доступы, создаст тестовый счёт в песочнице и подключит вебхуки. Техническую часть делает ИИ, а не вы.

Для разработчика. Полная спецификация API — apipay.kz/docs. Эта статья в Markdown — /guides/katalog-dlya-integratorov-1c.md. Справочник фактов для ИИ-агента — apipay.kz/llms.txt.