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

# Массовая заливка каталога 1С в ApiPay: очередь, ETA, ошибки

**TL;DR.** Большой каталог заливайте пачками **до 100 позиций** через `POST /catalog`, ставя
на каждую пачку заголовок `Idempotency-Key` — повтор того же запроса не создаёт дублей.

Общего «номера залива» у API нет: список отправленных `external_ref` держите **у себя**.

Сколько работы осталось — `GET /catalog/queue`. Что стало с конкретными позициями —
`GET /catalog?external_refs[]=…`. Почему позиция не завелась — `GET /catalog/errors`.

Уведомление на ваш сервер (вебхук) приходит **по каждой позиции** — событие `catalog.item_processed`.

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

## Коротко

| Факт | Значение |
|---|---|
| Размер пачки `POST /catalog` | до **100** позиций за запрос |
| Идемпотентность запроса | заголовок `Idempotency-Key` (≤191) или body `idempotency_key` |
| Ключ маппинга | `external_ref` — ваша ссылка на номенклатуру 1С |
| Остаток работы | `GET /catalog/queue` |
| Сверка позиций | `GET /catalog?external_refs[]=…` — до **200** значений за запрос |
| Отказы | `GET /catalog/errors` — окно по моменту отказа (`failed_at`) |
| Вебхук | `catalog.item_processed` — **по каждой позиции** |
| Лимит `GET /catalog/queue` и `GET /catalog/errors` | **600 запросов/мин** на ключ (общий лимит 200/мин не расходуется) |

## Шаг 1. Заливка пачками по 100 с `Idempotency-Key`

Разбейте каталог на пачки **до 100 позиций** и отправляйте их по очереди, ровным потоком,
без распараллеливания на один кассир.

На каждую пачку ставьте свой `Idempotency-Key` — стабильную строку, например
`upload-2026-08-26-part-042`.

Если сеть оборвалась и вы не знаете, дошёл ли запрос, повторите его **с тем же ключом**:
позиции не создадутся второй раз.

```bash
curl -X POST https://api.apipay.kz/api/v1/catalog \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: upload-2026-08-26-part-042" \
  -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` («принято в обработку»), построчный:

```json
{
  "data": [
    { "id": 5001, "external_ref": "1c-000123", "status": "pending",
      "operation": "create", "sellable": true, "in_kaspi_catalog": false,
      "matched_existing": false, "name_differs": false }
  ],
  "rejected": []
}
```

- `data` — принятые позиции. Новые приходят со `status: pending` и `operation: create`:
  запрос принят, но в каталоге Kaspi позиции ещё нет.
- `rejected` — позиции, не прошедшие проверку (например без `name`). Ключ есть в ответе
  **всегда**; исправьте эти позиции и отправьте их отдельно.

**Общего идентификатора пачки в ответе нет.** Один запрос — это просто один запрос, а не
объект, за которым можно следить. Поэтому список отправленных `external_ref` сохраняйте у
себя: именно по нему вы потом сверяете результат.

> Ставьте `external_ref` (вашу ссылку 1С) у каждой позиции — это ключ маппинга и якорь
> идемпотентности: [Каталог для 1С](/guides/katalog-dlya-integratorov-1c).

### Что вернёт точный повтор

Повтор того же тела с тем же `Idempotency-Key` отвечает `200` с признаком
`idempotent_replay: true` — запрос не выполняется заново.

Строк позиций в таком ответе нет: перечитайте их запросом
`GET /catalog?external_refs[]=…`.

Тот же ключ с **другим** телом (или на массовом удалении) — это `409
idempotency_key_conflict`. Пространство ключей у приёма и у массового удаления общее,
поэтому берите новый ключ, а не подгоняйте старый.

## Шаг 2. Следим за остатком очереди

Чтобы показать клиенту прогресс, читайте `GET /catalog/queue`:

```bash
curl -G https://api.apipay.kz/api/v1/catalog/queue \
  -H "X-API-Key: YOUR_API_KEY"
```

```json
{
  "current_page": 1,
  "data": [
    { "id": 5001, "external_ref": "1c-000123", "name": "Ручка гелевая синяя 0.5",
      "queued_at": "2026-08-26T10:00:00+05:00" }
  ],
  "total": 1234,
  "updating": 0,
  "deleting": 0,
  "queue": {
    "state": "draining",
    "ahead_in_cashier_queue": 2200,
    "eta_minutes": 11,
    "throttle_retry_in_seconds": null
  }
}
```

Три счётчика — три разных вида работы:

| Поле | Что считает |
|---|---|
| `total` | позиции, которые ещё **заводятся** в каталог |
| `updating` | позиции с непринятой ещё **правкой** |
| `deleting` | позиции, ожидающие **снятия** с продажи |

`data` и `total` описывают только заведение. Пока идёт массовое удаление, `total` может
быть нулём, а работа — продолжаться: смотрите на все три числа сразу.

Блок `queue` объясняет, почему очередь в текущем состоянии:

| `state` | Что значит | Что делать |
|---|---|---|
| `draining` | очередь двигается | показывать `eta_minutes` |
| `paused_throttle` | пауза из-за ограничения частоты обработки | подождать `throttle_retry_in_seconds` секунд, обработка возобновится сама |
| `paused_hold` | накопление приостановлено владельцем (hold-режим) | снять hold в кабинете, чтобы очередь пошла |
| `not_connected` | у организации не подключён кассир Kaspi | подключить или переподключить кассира |
| `sandbox` | режим песочницы | позиции активируются сразу, очереди нет |

- `queue.eta_minutes` — оценка времени до конца очереди **в минутах**. Целое число
  приходит **только** при `state: draining`; иначе `null` — показывайте прочерк, а не
  «0 минут».
- `queue.ahead_in_cashier_queue` — сколько позиций стоит до конца **вашей** очереди
  включительно, считая позиции других организаций этого же кассира. ETA это уже учитывает.
- Набор значений `state` открытый — неизвестное состояние обрабатывайте общей веткой, а не
  падением.

Опрашивайте очередь раз в 5–15 секунд: лимит **600 запросов/мин на ключ** это позволяет, и
общий бюджет ключа он не расходует.

## Шаг 3. Сверяем конкретные позиции

Итог по вашему списку читается точечно — по тем самым `external_ref`, которые вы отправили:

```bash
curl -G https://api.apipay.kz/api/v1/catalog \
  -H "X-API-Key: YOUR_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 }
  ]
}
```

- За один запрос можно спросить до **200** значений; больше — `422
  catalog_match_overflow`. Разбивайте сверку по 100.
- Без параметра `statuses[]` отдаются **только активные** позиции. Чтобы увидеть, что
  какая-то позиция ещё в работе или упала, статусы нужно перечислить явно.
- `sellable` отвечает на практический вопрос «можно ли уже ставить позицию в счёт».
  У только что залитой позиции он `true` уже при `status: pending`.
  `false` бывает у снятой позиции, у позиции с открытым намерением удаления
  и у переиздания ранее снятой строки.
- `in_kaspi_catalog` — другой вопрос: уедет ли позиция каталожным товаром
  с маркировкой Нацкаталога. У `pending` он `false`, и до подтверждения
  счёт с этой позицией пройдёт, но маркировки в фискальном чеке не будет.

⛔ Фильтр `batch_id` у `GET /catalog` и `GET /catalog/errors` больше не существует и
**отклоняется** — `422 catalog_batch_filter_removed`. Отбивается даже пустой `?batch_id=`,
поэтому уберите параметр из запроса целиком, а не оставляйте его без значения.

## Шаг 4. Вебхук по каждой позиции

Если у вас есть публичный URL для вебхуков, итог по каждой позиции приходит событием
`catalog.item_processed` — с той же подписью, что у остальных событий.

```json
{
  "event": "catalog.item_processed",
  "catalog_item": {
    "id": 5001,
    "external_ref": "1c-000123",
    "kaspi_item_id": "90210",
    "name": "Ручка гелевая синяя 0.5",
    "status": "active",
    "operation": null,
    "sellable": true,
    "in_kaspi_catalog": true,
    "ntin_missing": true,
    "error_code": null,
    "failed_at": null
  },
  "timestamp": "2026-08-26T10:12:00+00:00"
}
```

⚠️ **Событие приходит по каждой позиции.** Заливка на 50 позиций даёт до 50 доставок, а не
одну — приёмник должен это выдержать. Планируйте нагрузку заранее.

Сверяйтесь по `external_ref` — это ваш ключ 1С. Поле `status` в событии — тот же словарь,
что в `GET /catalog`, и значения там всегда совпадают.

**«Заливка завершена» — это ваш собственный вывод.** Одного события «всё готово» нет:
отмечайте у себя приходящие `external_ref` и считайте залив законченным, когда закрыт весь
ваш список.

Доставка может повториться, поэтому обработка одной и той же позиции должна быть у вас
безопасной при повторе.

Проверить, ушли ли доставки и с каким ответом, можно read-only логом
`GET /catalog/webhook-logs` (хранится 3 дня).

## Шаг 5. Разбор отказов

Позиции, по которым работа отказала, читайте через `GET /catalog/errors`:

```bash
curl -G https://api.apipay.kz/api/v1/catalog/errors \
  -H "X-API-Key: YOUR_API_KEY" \
  --data-urlencode "from=2026-08-26 09:00" \
  --data-urlencode "to=2026-08-26 18:00"
```

```json
{
  "current_page": 1,
  "data": [
    { "id": 456, "external_ref": "1C-000456", "name": "Фильтр воздушный",
      "barcode": "4600000000001", "ntin": "00000000000001",
      "operation": "create",
      "error_code": "catalog_item_duplicate",
      "error_message": "Такая позиция уже есть в каталоге.",
      "queued_at": "2026-08-26T10:00:00+05:00",
      "failed_at": "2026-08-26T10:03:00+05:00" }
  ],
  "total": 3
}
```

**Окно `from`/`to` считается по моменту отказа — по `failed_at`.** Без `from` отдаются
последние **7 дней**; за более старым отказом передавайте `from` явно.

Дата без времени и время без смещения трактуются по Алматы (+05:00). Голая дата в `to`
включает весь день целиком.

Свои позиции в ответе отбирайте по собственному списку `external_ref` — ответ содержит
отказы всей организации за окно.

`operation` показывает, что именно не получилось: `create` — позиция не завелась,
`update` — не доехала правка, `delete` — не прошло снятие.

Причину всегда определяйте по `error_code`, а не по тексту `error_message` (текст обезличен):

| `error_code` | Что значит | Что делать |
|---|---|---|
| `catalog_item_duplicate` | Kaspi считает позицию дублем уже заведённого товара (похожее наименование) | Найдите товар через `GET /catalog` и правьте существующий через `PATCH /catalog/{id}`; проверьте, нет ли в вашем каталоге двух позиций с одинаковым именем |
| `barcode_too_long` | штрихкод длиннее 32 символов (лимит Kaspi) | Исправьте штрихкод и отправьте позицию заново |
| `catalog_item_invalid` | Kaspi отклонил данные позиции (наименование, цена, поля) | Проверьте наименование, цену и штрихкод; исправьте и отправьте заново |
| `catalog_multi_tradepoint` | у организации несколько торговых точек, и позицию не к чему отнести однозначно | Сами данные позиции здесь ни при чём — напишите в поддержку: `77003076512` |

Исправив вход, отправьте заново **только упавшие** позиции — по их `external_ref`.

## Шаг 6. Повторная заливка идемпотентна

Гоняйте синхронизацию по расписанию без страха дублей:

- **Повтор запроса** с тем же `Idempotency-Key` и тем же телом ничего не выполняет заново.
- **Повтор каталога**: позиция, совпавшая с уже заведённым товаром, возвращается его живым
  `id` с маркером `matched_existing: true` — новая строка не создаётся. Изменённые поля
  обновляются, новые позиции создаются.

Механику совпадений разбирает [Каталог для 1С](/guides/katalog-dlya-integratorov-1c).

## Если ваш код писался под «партии»

Раньше `POST /catalog` возвращал блок `batch` с номером партии, и по нему можно было
следить за заливом. Этой оси больше нет.

| Что было | Что теперь |
|---|---|
| `batch.batch_id` и `poll_url` в ответе | ответа с партией нет — держите список `external_ref` у себя |
| `GET /catalog/batches/{id}` | ручки нет (`404`) |
| `?batch_id=` у `GET /catalog` и `GET /catalog/errors` | параметр отклоняется: `422 catalog_batch_filter_removed` |
| Один вебхук `catalog.batch_processed` на весь залив | `catalog.item_processed` по каждой позиции |

⚠️ **Если вы жили только на вебхуке `catalog.batch_processed`** — обратите внимание
отдельно: это событие **не отправляется никогда**. Адрес и подпись прежние, просто тишина;
по ошибке об этом не узнать. Подпишитесь на `catalog.item_processed`.

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

**Зачем нужен `Idempotency-Key`, если каталог и так идемпотентен?**
Совпадение позиций защищает от дублей на уровне товаров, а `Idempotency-Key` — от повторной
обработки **того же запроса** при обрыве сети: вы можете безопасно повторить POST, не зная,
дошёл ли он.

**Придёт ли вебхук `catalog.item_processed` при массовой заливке?**
Да, по каждой позиции. Заливка на 50 позиций даёт до 50 доставок — рассчитывайте приёмник
на это. Отдельного события «залив завершён» нет.

**Как узнать, что заливка закончилась?**
Своим списком: отмечайте у себя закрытые `external_ref` и дополнительно смотрите на
`total`, `updating` и `deleting` в `GET /catalog/queue`.

## Что дальше

- **Логика без дублей и `external_ref`:** [Каталог для 1С: синхронизация без
  дублей](/guides/katalog-dlya-integratorov-1c).
- **Базовые поля позиции, корзина и Нацкаталог:** [Каталог, корзина и
  Нацкаталог](/guides/katalog-korzina-nackatalog).

---

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