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

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

**TL;DR.** Ключ маппинга товара между 1С и ApiPay — **`external_ref`** (код или GUID номенклатуры), а не штрихкод и не имя: по одному штрихкоду у мерчанта бывает несколько связанных позиций, и сверка по нему путает id.

`POST /catalog` работает в режиме **match-and-merge**: если позиция совпала с существующим товаром, возвращается **живой id** с маркером `matched_existing: true` — дубли не создаются, **повторная заливка идемпотентна**.

Итог подтверждается двумя равноправными путями: **вебхук `catalog.item_processed`** (приходит по каждой позиции) или **чтение** `GET /catalog?external_refs[]=…` — для 1С и on-prem это дешевле, наружу открываться не нужно.

Пачка — до **100** позиций; точечное чтение — до **200** значений суммарно.

Операционный плейбук больших каталогов — [Массовая заливка каталога 1С](/guides/massovaya-zagruzka-kataloga-iz-1c).

> Статья про публичный API мерчанта с заголовком `X-API-Key`. Базовый адрес — всегда `https://api.apipay.kz/api/v1`. Базовую механику полей разбирает [Каталог, корзина и Нацкаталог](/guides/katalog-korzina-nackatalog).

## Правило №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`.

### Пример: заливка пачки

```bash
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`, сматченные — с их текущим статусом):

```json
{
  "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С](/guides/massovaya-zagruzka-kataloga-iz-1c).

⛔ Событие `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`:

```bash
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"
```

```json
{
  "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` не нужно, и одним флагом они не выражаются. Разбор — в статье [Каталог, корзина и Нацкаталог](/guides/katalog-korzina-nackatalog).

## Полная синхронизация: удалить остаток

Чтобы снять с продажи то, чего в выгрузке 1С больше нет, **список ушедших позиций строите вы сами**.

Сервер не догадывается, что именно вы считаете остатком: ни меток прогона, ни фильтров у массового удаления нет. `POST /catalog/bulk-delete` принимает ровно один явный список — `ids[]` **либо** `external_refs[]`, до **200** значений.

Порядок один и тот же на каждой части списка.

**1. Залейте актуальный каталог целиком.** Пока заливка не прошла, вы не знаете, чего в ней нет.

**2. Постройте у себя список ушедших `external_ref`** и разбейте его на части по 200 значений.

**3. Проверьте часть разведкой — `dry_run: true`.** Ответ покажет `would_delete` (сколько позиций попадёт под снятие) и образец строк. Разведка ничего не меняет и `Idempotency-Key` не расходует.

```bash
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` на каждую часть.

```bash
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С](/guides/massovaya-zagruzka-kataloga-iz-1c).

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

**По какому полю маппить товары между 1С и ApiPay?**
По `external_ref` — вашей ссылке на номенклатуру. Не по штрихкоду и не по имени: по одному штрихкоду бывает несколько связанных позиций, а имена меняются. `external_ref` уникален в пределах организации.

**Пришёл `matched_existing: true` с `name_differs: true` — что делать?**
Штрихкод/НТИН совпали с существующим товаром, но имя другое (для Kaspi это тот же товар). Имя мы не перезаписали. Если ваше имя правильное — отправьте его явным `PATCH /catalog/{id}`.

**Как отметить у себя, что заливка закончилась?**
По своему списку `external_ref`. Серверного признака «эта заливка завершена» нет: ответ построчный, номера партии не существует, а `catalog.item_processed` приходит по каждой позиции отдельно.

**Позиция вернулась со `status: pending` — можно ставить её в счёт?**

**Да**, если это новая позиция: у неё `sellable: true` уже в ответе `POST /catalog`.

Но пока `in_kaspi_catalog: false`, позиция уедет разовой продажей, и маркировка Нацкаталога в фискальный чек по ней не попадёт — для чека дождитесь `in_kaspi_catalog: true`.

Исключение — переиздание ранее снятой позиции: у неё `sellable: false` до подтверждения.

Смотрите на `sellable`, а не на статус.

## Что дальше

- **Массовая заливка:** тысячи позиций из 1С — [Массовая заливка каталога 1С](/guides/massovaya-zagruzka-kataloga-iz-1c).
- **Трек D:** общая интеграция с 1С — [Интеграция ApiPay с 1С](/guides/integraciya-apipay-s-1c).
- **Трек M:** ведёте каталог руками — статья [Как заполнить каталог](/guides/zapolnenie-kataloga-apipay).

---

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